Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
}, [file])

const handleOpenInFiles = useCallback(() => {
router.push(`/workspace/${workspaceId}/files?fileId=${fileId}`)
router.push(`/workspace/${workspaceId}/files?fileId=${encodeURIComponent(fileId)}`)
}, [router, workspaceId, fileId])

return (
Expand Down
36 changes: 32 additions & 4 deletions apps/sim/lib/copilot/orchestrator/tool-executor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { validateMcpDomain } from '@/lib/mcp/domain-check'
import { mcpService } from '@/lib/mcp/service'
import { generateMcpServerId } from '@/lib/mcp/utils'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import {
deleteCustomTool,
getCustomToolById,
Expand All @@ -24,7 +25,7 @@ import {
} from '@/lib/workflows/custom-tools/operations'
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
import { getWorkflowById } from '@/lib/workflows/utils'
import { isMcpTool } from '@/executor/constants'
import { isMcpTool, isUuid } from '@/executor/constants'
import { executeTool } from '@/tools'
import { getTool, resolveToolId } from '@/tools/utils'
import {
Expand Down Expand Up @@ -1029,15 +1030,42 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
list: (p, c) => executeVfsList(p, c),

// Resource visibility
open_resource: async (p: OpenResourceParams) => {
open_resource: async (p: OpenResourceParams, c: ExecutionContext) => {
const validated = validateOpenResourceParams(p)
if (!validated.success) {
return { success: false, error: validated.error }
}

const params = validated.params
const resourceType = params.type
const resourceId = params.id
let resourceId = params.id
let title: string = resourceType

if (resourceType === 'file') {
if (!c.workspaceId) {
return {
success: false,
error:
'Opening a workspace file requires workspace context. Pass the file UUID from files/<name>/meta.json.',
}
}
if (!isUuid(params.id)) {
return {
success: false,
error:
'open_resource for files requires the canonical UUID from files/<name>/meta.json (the "id" field). Do not pass VFS paths, display names, or file_<name> strings.',
}
}
const record = await getWorkspaceFile(c.workspaceId, params.id)
if (!record) {
return {
success: false,
error: `No workspace file with id "${params.id}". Confirm the UUID from meta.json.`,
}
}
resourceId = record.id
title = record.name
}

return {
success: true,
Expand All @@ -1046,7 +1074,7 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
{
type: resourceType as 'workflow' | 'table' | 'knowledgebase' | 'file',
id: resourceId,
title: resourceType,
title,
},
],
}
Expand Down
37 changes: 12 additions & 25 deletions apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { workflow, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/orchestrator/tool-executor/upload-file-reader'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import { getServePathPrefix } from '@/lib/uploads'
import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
Expand All @@ -12,22 +13,6 @@ import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'

const logger = createLogger('MaterializeFile')

async function findUploadRecord(fileName: string, chatId: string) {
const rows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)
return rows[0] ?? null
}

function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
const pathPrefix = getServePathPrefix()
return {
Expand All @@ -41,21 +26,23 @@ function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
storageContext: 'mothership' as const,
}
}

async function executeSave(fileName: string, chatId: string): Promise<ToolCallResult> {
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
if (!row) {
return {
success: false,
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
}
}

const [updated] = await db
.update(workspaceFiles)
.set({ context: 'workspace', chatId: null })
.where(
and(
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
isNull(workspaceFiles.deletedAt)
)
)
.where(and(eq(workspaceFiles.id, row.id), isNull(workspaceFiles.deletedAt)))
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })

if (!updated) {
Expand Down Expand Up @@ -84,7 +71,7 @@ async function executeImport(
workspaceId: string,
userId: string
): Promise<ToolCallResult> {
const row = await findUploadRecord(fileName, chatId)
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
if (!row) {
return {
success: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import { getServePathPrefix } from '@/lib/uploads'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'

Expand All @@ -21,9 +22,50 @@ function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): Workspa
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
storageContext: 'mothership',
}
}

/**
* Resolve a mothership upload row by `originalName`, preferring an exact DB match (limit 1) and
* only scanning all chat uploads when that misses (e.g. macOS U+202F vs ASCII space in the name).
*/
export async function findMothershipUploadRowByChatAndName(
chatId: string,
fileName: string
): Promise<typeof workspaceFiles.$inferSelect | null> {
const exactRows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
eq(workspaceFiles.originalName, fileName),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)

if (exactRows[0]) {
return exactRows[0]
}

const allRows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
isNull(workspaceFiles.deletedAt)
)
)

const segmentKey = normalizeVfsSegment(fileName)
return allRows.find((r) => normalizeVfsSegment(r.originalName) === segmentKey) ?? null
}

/**
* List all chat-scoped uploads for a given chat.
*/
Expand Down Expand Up @@ -51,30 +93,18 @@ export async function listChatUploads(chatId: string): Promise<WorkspaceFileReco
}

/**
* Read a specific uploaded file by name within a chat session.
* Read a specific uploaded file by display name within a chat session.
* Resolves names with `normalizeVfsSegment` so macOS screenshot spacing (e.g. U+202F)
* matches when the model passes a visually equivalent path.
*/
export async function readChatUpload(
filename: string,
chatId: string
): Promise<FileReadResult | null> {
try {
const rows = await db
.select()
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.chatId, chatId),
eq(workspaceFiles.context, 'mothership'),
eq(workspaceFiles.originalName, filename),
isNull(workspaceFiles.deletedAt)
)
)
.limit(1)

if (rows.length === 0) return null

const record = toWorkspaceFileRecord(rows[0])
return readFileRecord(record)
const row = await findMothershipUploadRowByChatAndName(chatId, filename)
if (!row) return null
return readFileRecord(toWorkspaceFileRecord(row))
} catch (err) {
logger.warn('Failed to read chat upload', {
filename,
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/copilot/vfs/file-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ 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 MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
const MAX_IMAGE_READ_BYTES = 20 * 1024 * 1024 // 20 MB

const TEXT_TYPES = new Set([
'text/plain',
Expand Down Expand Up @@ -53,7 +53,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
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)]`,
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 20MB)]`,
totalLines: 1,
}
}
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/lib/copilot/vfs/normalize-segment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Normalize a string for use as a single VFS path segment (workflow name, file name, etc.).
* Applies NFC normalization, trims, strips ASCII control characters, maps `/` to `-`, and
* collapses Unicode whitespace (including U+202F as in macOS screenshot names) to a single
* ASCII space.
*/
export function normalizeVfsSegment(name: string): string {
return name
.normalize('NFC')
.trim()
.replace(/[\x00-\x1f\x7f]/g, '')
.replace(/\//g, '-')
.replace(/\s+/g, ' ')
}
Loading
Loading