mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
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:
@@ -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`, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
120
apps/sim/lib/copilot/vfs/file-reader.ts
Normal file
120
apps/sim/lib/copilot/vfs/file-reader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user