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 <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-03-12 22:08:31 -07:00
committed by GitHub
parent 7a1b0a99e6
commit 2d8899b2ff
7 changed files with 269 additions and 114 deletions

View File

@@ -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`, {

View File

@@ -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

View File

@@ -231,6 +231,10 @@ export function useChat(
const [activeResourceId, setActiveResourceId] = useState<string | null>(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<AbortController | null>(null)
const chatIdRef = useRef<string | undefined>(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,

View File

@@ -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<AgentContext[]> {
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<AgentContext | null> {
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<AgentContext | null> {
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<AgentContext | null> {
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<AgentContext | null> {
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),
}
}

View File

@@ -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<FileReadResult | null> {
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
}
}

View File

@@ -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,

View File

@@ -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.