From 2d8899b2ff8edd3848fa517643e605c7e54c19d3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 12 Mar 2026 22:08:31 -0700 Subject: [PATCH] feat(context) pass resource tab as context (#3555) * feat(context) add currenttly open resource file to context for agent * Simplify resource resolution * Skip initialize vfs * Restore ff * Add back try catch * Remove redundant code * Remove json serialization/deserialization loop --------- Co-authored-by: Theodore Li --- apps/sim/app/api/copilot/chat/route.ts | 3 +- apps/sim/app/api/mothership/chat/route.ts | 34 ++++- .../[workspaceId]/home/hooks/use-chat.ts | 14 ++ apps/sim/lib/copilot/process-contents.ts | 98 +++++++++++++- apps/sim/lib/copilot/vfs/file-reader.ts | 120 ++++++++++++++++++ apps/sim/lib/copilot/vfs/index.ts | 3 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 111 +--------------- 7 files changed, 269 insertions(+), 114 deletions(-) create mode 100644 apps/sim/lib/copilot/vfs/file-reader.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 8da3452329..d3c6e16978 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -178,7 +178,8 @@ export async function POST(req: NextRequest) { const processed = await processContextsServer( normalizedContexts as any, authenticatedUserId, - message + message, + resolvedWorkspaceId ) agentContexts = processed logger.info(`[${tracker.requestId}] Contexts processed for request`, { diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 55c8de4b80..2293dbd211 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -9,6 +9,10 @@ import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload' import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming' import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' +import { + processContextsServer, + resolveActiveResourceContext, +} from '@/lib/copilot/process-contents' import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' @@ -24,6 +28,11 @@ const FileAttachmentSchema = z.object({ size: z.number(), }) +const ResourceAttachmentSchema = z.object({ + type: z.enum(['workflow', 'table', 'file', 'knowledgebase']), + id: z.string().min(1), +}) + const MothershipMessageSchema = z.object({ message: z.string().min(1, 'Message is required'), workspaceId: z.string().min(1, 'workspaceId is required'), @@ -32,6 +41,7 @@ const MothershipMessageSchema = z.object({ createNewChat: z.boolean().optional().default(false), fileAttachments: z.array(FileAttachmentSchema).optional(), userTimezone: z.string().optional(), + resourceAttachments: z.array(ResourceAttachmentSchema).optional(), contexts: z .array( z.object({ @@ -82,6 +92,7 @@ export async function POST(req: NextRequest) { createNewChat, fileAttachments, contexts, + resourceAttachments, userTimezone, } = MothershipMessageSchema.parse(body) @@ -90,13 +101,32 @@ export async function POST(req: NextRequest) { let agentContexts: Array<{ type: string; content: string }> = [] if (Array.isArray(contexts) && contexts.length > 0) { try { - const { processContextsServer } = await import('@/lib/copilot/process-contents') - agentContexts = await processContextsServer(contexts as any, authenticatedUserId, message) + agentContexts = await processContextsServer( + contexts as any, + authenticatedUserId, + message, + workspaceId + ) } catch (e) { logger.error(`[${tracker.requestId}] Failed to process contexts`, e) } } + if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) { + const results = await Promise.allSettled( + resourceAttachments.map((r) => + resolveActiveResourceContext(r.type, r.id, workspaceId, authenticatedUserId) + ) + ) + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + agentContexts.push(result.value) + } else if (result.status === 'rejected') { + logger.error(`[${tracker.requestId}] Failed to resolve resource attachment`, result.reason) + } + } + } + let currentChat: any = null let conversationHistory: any[] = [] let actualChatId = chatId 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 b5d883fa50..58d501a118 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -231,6 +231,10 @@ export function useChat( const [activeResourceId, setActiveResourceId] = useState(null) const onResourceEventRef = useRef(options?.onResourceEvent) onResourceEventRef.current = options?.onResourceEvent + const resourcesRef = useRef(resources) + resourcesRef.current = resources + const activeResourceIdRef = useRef(activeResourceId) + activeResourceIdRef.current = activeResourceId const abortControllerRef = useRef(null) const chatIdRef = useRef(initialChatId) @@ -752,6 +756,15 @@ export function useChat( abortControllerRef.current = abortController try { + const currentActiveId = activeResourceIdRef.current + const currentResources = resourcesRef.current + const activeRes = currentActiveId + ? currentResources.find((r) => r.id === currentActiveId) + : undefined + const resourceAttachments = activeRes + ? [{ type: activeRes.type, id: activeRes.id }] + : undefined + const response = await fetch(MOTHERSHIP_CHAT_API_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -762,6 +775,7 @@ export function useChat( createNewChat: !chatIdRef.current, ...(chatIdRef.current ? { chatId: chatIdRef.current } : {}), ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), + ...(resourceAttachments ? { resourceAttachments } : {}), userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }), signal: abortController.signal, diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 46709ef441..f1f9da7266 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -2,7 +2,11 @@ import { db } from '@sim/db' import { copilotChats, document, knowledgeBase, templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' +import { readFileRecord } from '@/lib/copilot/vfs/file-reader' +import { serializeTableMeta } from '@/lib/copilot/vfs/serializers' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { getTableById } from '@/lib/table/service' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { isHiddenFromDisplay } from '@/blocks/types' @@ -20,6 +24,7 @@ export type AgentContextType = | 'templates' | 'workflow_block' | 'docs' + | 'active_resource' export interface AgentContext { type: AgentContextType @@ -76,7 +81,8 @@ export async function processContexts( export async function processContextsServer( contexts: ChatContext[] | undefined, userId: string, - userMessage?: string + userMessage?: string, + workspaceId?: string ): Promise { if (!Array.isArray(contexts) || contexts.length === 0) return [] const tasks = contexts.map(async (ctx) => { @@ -92,7 +98,11 @@ export async function processContextsServer( ) } if (ctx.kind === 'knowledge' && ctx.knowledgeId) { - return await processKnowledgeFromDb(ctx.knowledgeId, ctx.label ? `@${ctx.label}` : '@') + return await processKnowledgeFromDb( + ctx.knowledgeId, + ctx.label ? `@${ctx.label}` : '@', + workspaceId + ) } if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { return await processBlockMetadata( @@ -305,10 +315,14 @@ async function processPastChatViaApi(chatId: string, tag?: string) { async function processKnowledgeFromDb( knowledgeBaseId: string, - tag: string + tag: string, + workspaceId?: string ): Promise { try { - // Load KB metadata + const conditions = [eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)] + if (workspaceId) { + conditions.push(eq(knowledgeBase.workspaceId, workspaceId)) + } const kbRows = await db .select({ id: knowledgeBase.id, @@ -316,7 +330,7 @@ async function processKnowledgeFromDb( updatedAt: knowledgeBase.updatedAt, }) .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .where(and(...conditions)) .limit(1) const kb = kbRows?.[0] if (!kb) return null @@ -532,3 +546,77 @@ async function processExecutionLogFromDb( return null } } + +// --------------------------------------------------------------------------- +// Active resource context resolution (direct DB lookups, workspace-scoped) +// --------------------------------------------------------------------------- + +/** + * Resolves the content of the currently active resource tab via direct DB + * queries. Each resource type has a dedicated handler that fetches only the + * single resource needed — avoiding the full VFS materialisation overhead. + */ +export async function resolveActiveResourceContext( + resourceType: string, + resourceId: string, + workspaceId: string, + _userId: string +): Promise { + try { + switch (resourceType) { + case 'workflow': { + const ctx = await processWorkflowFromDb(resourceId, '@active_resource') + if (!ctx) return null + return { type: 'active_resource', tag: '@active_resource', content: ctx.content } + } + case 'knowledgebase': { + const ctx = await processKnowledgeFromDb(resourceId, '@active_resource', workspaceId) + if (!ctx) return null + return { type: 'active_resource', tag: '@active_resource', content: ctx.content } + } + case 'table': { + return await resolveTableResource(resourceId) + } + case 'file': { + return await resolveFileResource(resourceId, workspaceId) + } + default: + return null + } + } catch (error) { + logger.error('Failed to resolve active resource context', { resourceType, resourceId, error }) + return null + } +} + +async function resolveTableResource(tableId: string): Promise { + const table = await getTableById(tableId) + if (!table) return null + return { + type: 'active_resource', + tag: '@active_resource', + content: serializeTableMeta(table), + } +} + +async function resolveFileResource( + fileId: string, + workspaceId: string +): Promise { + const record = await getWorkspaceFile(workspaceId, fileId) + if (!record) return null + const fileResult = await readFileRecord(record) + const meta = { + id: record.id, + name: record.name, + contentType: record.type, + size: record.size, + uploadedAt: record.uploadedAt.toISOString(), + content: fileResult?.content || `[Could not read ${record.name}]`, + } + return { + type: 'active_resource', + tag: '@active_resource', + content: JSON.stringify(meta, null, 2), + } +} diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts new file mode 100644 index 0000000000..d0dfca9355 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -0,0 +1,120 @@ +import { createLogger } from '@sim/logger' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { isImageFileType } from '@/lib/uploads/utils/file-utils' + +const logger = createLogger('FileReader') + +const MAX_TEXT_READ_BYTES = 512 * 1024 // 512 KB +const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB + +const TEXT_TYPES = new Set([ + 'text/plain', + 'text/csv', + 'text/markdown', + 'text/html', + 'text/xml', + 'application/json', + 'application/xml', + 'application/javascript', +]) + +const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt']) + +function isReadableType(contentType: string): boolean { + return TEXT_TYPES.has(contentType) || contentType.startsWith('text/') +} + +function getExtension(filename: string): string { + const dot = filename.lastIndexOf('.') + return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '' +} + +export interface FileReadResult { + content: string + totalLines: number + attachment?: { + type: string + source: { + type: 'base64' + media_type: string + data: string + } + } +} + +/** + * Read and return the content of a workspace file record. + * Handles images (base64 attachment), parseable documents (PDF, DOCX, etc.), + * binary files, and plain text with size guards. + */ +export async function readFileRecord(record: WorkspaceFileRecord): Promise { + try { + if (isImageFileType(record.type)) { + if (record.size > MAX_IMAGE_READ_BYTES) { + return { + content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB)]`, + totalLines: 1, + } + } + const buffer = await downloadWorkspaceFile(record) + return { + content: `Image: ${record.name} (${(record.size / 1024).toFixed(1)}KB, ${record.type})`, + totalLines: 1, + attachment: { + type: 'image', + source: { + type: 'base64', + media_type: record.type, + data: buffer.toString('base64'), + }, + }, + } + } + + const ext = getExtension(record.name) + if (PARSEABLE_EXTENSIONS.has(ext)) { + const buffer = await downloadWorkspaceFile(record) + try { + const { parseBuffer } = await import('@/lib/file-parsers') + const result = await parseBuffer(buffer, ext) + const content = result.content || '' + return { content, totalLines: content.split('\n').length } + } catch (parseErr) { + logger.warn('Failed to parse document', { + fileName: record.name, + ext, + error: parseErr instanceof Error ? parseErr.message : String(parseErr), + }) + return { + content: `[Could not parse ${record.name} (${record.type}, ${record.size} bytes)]`, + totalLines: 1, + } + } + } + + if (!isReadableType(record.type)) { + return { + content: `[Binary file: ${record.name} (${record.type}, ${record.size} bytes). Cannot display as text.]`, + totalLines: 1, + } + } + + if (record.size > MAX_TEXT_READ_BYTES) { + return { + content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, + totalLines: 1, + } + } + + const buffer = await downloadWorkspaceFile(record) + const content = buffer.toString('utf-8') + return { content, totalLines: content.split('\n').length } + } catch (err) { + logger.warn('Failed to read workspace file', { + fileName: record.name, + error: err instanceof Error ? err.message : String(err), + }) + return null + } +} diff --git a/apps/sim/lib/copilot/vfs/index.ts b/apps/sim/lib/copilot/vfs/index.ts index a8ef34cd81..70a8d5e620 100644 --- a/apps/sim/lib/copilot/vfs/index.ts +++ b/apps/sim/lib/copilot/vfs/index.ts @@ -6,7 +6,8 @@ export type { GrepOutputMode, ReadResult, } from '@/lib/copilot/vfs/operations' -export type { FileReadResult } from '@/lib/copilot/vfs/workspace-vfs' +export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' +export { readFileRecord } from '@/lib/copilot/vfs/file-reader' export { getOrMaterializeVFS, sanitizeName, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 52fafdfeda..98046be765 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -55,11 +55,8 @@ import { import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listTables } from '@/lib/table/service' -import { - downloadWorkspaceFile, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' +import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { readFileRecord, type FileReadResult } from '@/lib/copilot/vfs/file-reader' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -400,71 +397,11 @@ export class WorkspaceVFS { (f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC') ) if (!record) return null - - if (isImageFileType(record.type)) { - if (record.size > MAX_IMAGE_READ_BYTES) { - return { - content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB)]`, - totalLines: 1, - } - } - const buffer = await downloadWorkspaceFile(record) - return { - content: `Image: ${record.name} (${(record.size / 1024).toFixed(1)}KB, ${record.type})`, - totalLines: 1, - attachment: { - type: 'image', - source: { - type: 'base64', - media_type: record.type, - data: buffer.toString('base64'), - }, - }, - } - } - - const ext = getExtension(record.name) - if (PARSEABLE_EXTENSIONS.has(ext)) { - const buffer = await downloadWorkspaceFile(record) - try { - const { parseBuffer } = await import('@/lib/file-parsers') - const result = await parseBuffer(buffer, ext) - const content = result.content || '' - return { content, totalLines: content.split('\n').length } - } catch (parseErr) { - logger.warn('Failed to parse document', { - fileName: record.name, - ext, - error: parseErr instanceof Error ? parseErr.message : String(parseErr), - }) - return { - content: `[Could not parse ${record.name} (${record.type}, ${record.size} bytes)]`, - totalLines: 1, - } - } - } - - if (!isReadableType(record.type)) { - return { - content: `[Binary file: ${record.name} (${record.type}, ${record.size} bytes). Cannot display as text.]`, - totalLines: 1, - } - } - - if (record.size > MAX_TEXT_READ_BYTES) { - return { - content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, - totalLines: 1, - } - } - - const buffer = await downloadWorkspaceFile(record) - const content = buffer.toString('utf-8') - return { content, totalLines: content.split('\n').length } + return readFileRecord(record) } catch (err) { - logger.warn('Failed to read workspace file content', { + logger.warn('Failed to list workspace files for readFileContent', { + workspaceId: this._workspaceId, path, - fileName, error: err instanceof Error ? err.message : String(err), }) return null @@ -1221,43 +1158,7 @@ export async function getOrMaterializeVFS( return vfs } -const MAX_TEXT_READ_BYTES = 512 * 1024 // 512 KB -const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB - -const TEXT_TYPES = new Set([ - 'text/plain', - 'text/csv', - 'text/markdown', - 'text/html', - 'text/xml', - 'application/json', - 'application/xml', - 'application/javascript', -]) - -const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt']) - -function isReadableType(contentType: string): boolean { - return TEXT_TYPES.has(contentType) || contentType.startsWith('text/') -} - -function getExtension(filename: string): string { - const dot = filename.lastIndexOf('.') - return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '' -} - -export interface FileReadResult { - content: string - totalLines: number - attachment?: { - type: string - source: { - type: 'base64' - media_type: string - data: string - } - } -} +export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' /** * Sanitize a name for use as a VFS path segment.