diff --git a/apps/api/src/modules/action/action.controller.ts b/apps/api/src/modules/action/action.controller.ts index 362ad7db79..20cf1c7b9c 100644 --- a/apps/api/src/modules/action/action.controller.ts +++ b/apps/api/src/modules/action/action.controller.ts @@ -17,8 +17,11 @@ export class ActionController { @LoginedUser() user: UserModel, @Query('resultId') resultId: string, ): Promise { - const result = await this.actionService.getActionResult(user, { resultId }); - return buildSuccessResponse(actionResultPO2DTO(result)); + const result = await this.actionService.getActionResult(user, { + resultId, + sanitizeForDisplay: true, + }); + return buildSuccessResponse(actionResultPO2DTO(result, { sanitizeForDisplay: true })); } @UseGuards(JwtAuthGuard) diff --git a/apps/api/src/modules/action/action.dto.ts b/apps/api/src/modules/action/action.dto.ts index b8c3370e02..e375e02ca1 100644 --- a/apps/api/src/modules/action/action.dto.ts +++ b/apps/api/src/modules/action/action.dto.ts @@ -32,14 +32,16 @@ export type ActionDetail = ActionResultModel & { modelInfo?: ModelInfo; }; -export function actionStepPO2DTO(step: ActionStepDetail): ActionStep { +export type SanitizeOptions = { sanitizeForDisplay?: boolean }; + +export function actionStepPO2DTO(step: ActionStepDetail, options?: SanitizeOptions): ActionStep { return { ...pick(step, ['name', 'content', 'reasoningContent']), logs: safeParseJSON(step.logs || '[]'), artifacts: safeParseJSON(step.artifacts || '[]'), structuredData: safeParseJSON(step.structuredData || '{}'), tokenUsage: safeParseJSON(step.tokenUsage || '[]'), - toolCalls: step.toolCalls?.map(toolCallResultPO2DTO), + toolCalls: step.toolCalls?.map((tc) => toolCallResultPO2DTO(tc, options)), }; } @@ -53,7 +55,39 @@ export function actionMessagePO2DTO(message: ActionMessageModel): ActionMessage }; } -export function toolCallResultPO2DTO(toolCall: ToolCallResultModel): ToolCallResult { +/** + * Sanitize tool output for frontend display + * Removes large content fields that are not needed for display + */ +export function sanitizeToolOutput( + toolName: string, + output: Record, +): Record { + // For read_file, remove the content field from data as it can be very large + if (toolName === 'read_file' && output?.data && typeof output.data === 'object') { + const data = output.data as Record; + if ('content' in data) { + return { + ...output, + data: { + ...data, + content: '[Content omitted for display]', + }, + }; + } + } + return output; +} + +export function toolCallResultPO2DTO( + toolCall: ToolCallResultModel, + options?: { sanitizeForDisplay?: boolean }, +): ToolCallResult { + const rawOutput = safeParseJSON(toolCall.output || '{}'); + const output = options?.sanitizeForDisplay + ? sanitizeToolOutput(toolCall.toolName, rawOutput) + : rawOutput; + return { callId: toolCall.callId, uid: toolCall.uid, @@ -61,7 +95,7 @@ export function toolCallResultPO2DTO(toolCall: ToolCallResultModel): ToolCallRes toolName: toolCall.toolName, stepName: toolCall.stepName, input: safeParseJSON(toolCall.input || '{}'), - output: safeParseJSON(toolCall.output || '{}'), + output, error: toolCall.error || '', status: toolCall.status as 'executing' | 'completed' | 'failed', createdAt: toolCall.createdAt.getTime(), @@ -70,7 +104,7 @@ export function toolCallResultPO2DTO(toolCall: ToolCallResultModel): ToolCallRes }; } -export function actionResultPO2DTO(result: ActionDetail): ActionResult { +export function actionResultPO2DTO(result: ActionDetail, options?: SanitizeOptions): ActionResult { return { ...pick(result, [ 'resultId', @@ -100,7 +134,7 @@ export function actionResultPO2DTO(result: ActionDetail): ActionResult { storageKey: result.storageKey, createdAt: result.createdAt.toJSON(), updatedAt: result.updatedAt.toJSON(), - steps: result.steps?.map(actionStepPO2DTO), + steps: result.steps?.map((s) => actionStepPO2DTO(s, options)), messages: result.messages, files: result.files, toolsets: safeParseJSON(result.toolsets || '[]'), diff --git a/apps/api/src/modules/action/action.service.ts b/apps/api/src/modules/action/action.service.ts index 0f7dadaadf..d511ffd827 100644 --- a/apps/api/src/modules/action/action.service.ts +++ b/apps/api/src/modules/action/action.service.ts @@ -22,7 +22,7 @@ import { ActionMessage as ActionMessageModel, ToolCallResult as ToolCallResultModel, } from '@prisma/client'; -import { ActionDetail, actionMessagePO2DTO } from '../action/action.dto'; +import { ActionDetail, actionMessagePO2DTO, sanitizeToolOutput } from '../action/action.dto'; import { PrismaService } from '../common/prisma.service'; import { providerItem2ModelInfo } from '../provider/provider.dto'; import { ProviderService } from '../provider/provider.service'; @@ -35,6 +35,7 @@ import { InvokeSkillJobData } from '../skill/skill.dto'; type GetActionResultParams = GetActionResultData['query'] & { includeFiles?: boolean; + sanitizeForDisplay?: boolean; }; @Injectable() @@ -60,7 +61,7 @@ export class ActionService { ) {} async getActionResult(user: User, param: GetActionResultParams): Promise { - const { resultId, version, includeFiles = false } = param; + const { resultId, version, includeFiles = false, sanitizeForDisplay = false } = param; const result = await this.prisma.actionResult.findFirst({ where: { @@ -74,7 +75,9 @@ export class ActionService { throw new ActionResultNotFoundError(); } - const enrichedResult = await this.enrichActionResultWithDetails(user, result); + const enrichedResult = await this.enrichActionResultWithDetails(user, result, { + sanitizeForDisplay, + }); if (includeFiles) { enrichedResult.files = await this.driveService.listAllDriveFiles(user, { @@ -92,6 +95,7 @@ export class ActionService { private async enrichActionResultWithDetails( user: User, result: ActionResult, + options?: { sanitizeForDisplay?: boolean }, ): Promise { const item = (result.providerItemId @@ -131,6 +135,14 @@ export class ActionService { if (message.type === 'tool' && message.toolCallId) { const toolCallResult = toolCallResultMap.get(message.toolCallId); if (toolCallResult) { + const rawOutput = safeParseJSON(toolCallResult.output || '{}') ?? { + rawOutput: toolCallResult.output, + }; + // Apply sanitization if needed + const output = options?.sanitizeForDisplay + ? sanitizeToolOutput(toolCallResult.toolName, rawOutput) + : rawOutput; + // Attach the tool call result to the message enrichedMessage.toolCallResult = { callId: toolCallResult.callId, @@ -139,9 +151,7 @@ export class ActionService { toolName: toolCallResult.toolName, stepName: toolCallResult.stepName, input: safeParseJSON(toolCallResult.input || '{}') ?? {}, - output: safeParseJSON(toolCallResult.output || '{}') ?? { - rawOutput: toolCallResult.output, - }, + output, error: toolCallResult.error || '', status: toolCallResult.status as 'executing' | 'completed' | 'failed', createdAt: toolCallResult.createdAt.getTime(), @@ -158,7 +168,9 @@ export class ActionService { return { ...result, steps: [], messages: enrichedMessages, modelInfo }; } - const stepsWithToolCalls = this.toolCallService.attachToolCallsToSteps(steps, toolCalls); + const stepsWithToolCalls = this.toolCallService.attachToolCallsToSteps(steps, toolCalls, { + sanitizeForDisplay: options?.sanitizeForDisplay, + }); return { ...result, steps: stepsWithToolCalls, messages: enrichedMessages, modelInfo }; } diff --git a/apps/api/src/modules/copilot/copilot.dto.ts b/apps/api/src/modules/copilot/copilot.dto.ts index ab2c9b2990..06e67fd1ed 100644 --- a/apps/api/src/modules/copilot/copilot.dto.ts +++ b/apps/api/src/modules/copilot/copilot.dto.ts @@ -13,6 +13,6 @@ export const copilotSessionPO2DTO = ( ...pick(session, ['sessionId', 'title', 'canvasId']), createdAt: session.createdAt.toJSON(), updatedAt: session.updatedAt.toJSON(), - results: session.results?.map(actionResultPO2DTO), + results: session.results?.map((result) => actionResultPO2DTO(result)), }; }; diff --git a/apps/api/src/modules/drive/drive.controller.ts b/apps/api/src/modules/drive/drive.controller.ts index 617e5cfc2d..ae806ff143 100644 --- a/apps/api/src/modules/drive/drive.controller.ts +++ b/apps/api/src/modules/drive/drive.controller.ts @@ -13,6 +13,7 @@ import { } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guard/jwt-auth.guard'; +import { OptionalJwtAuthGuard } from '../auth/guard/optional-jwt-auth.guard'; import { DriveService } from './drive.service'; import { LoginedUser } from '../../utils/decorators/user.decorator'; import { @@ -101,9 +102,9 @@ export class DriveController { } @Get('file/content/:fileId') - @UseGuards(JwtAuthGuard) + @UseGuards(OptionalJwtAuthGuard) async serveDriveFile( - @LoginedUser() user: User, + @LoginedUser() user: User | null, @Param('fileId') fileId: string, @Query('download') download: string, @Res() res: Response, @@ -112,10 +113,9 @@ export class DriveController { const origin = req.headers.origin; // First, get only metadata (no file content loaded yet) - const { contentType, filename, lastModified } = await this.driveService.getDriveFileMetadata( - user, - fileId, - ); + // Uses unified access: checks externalOss (public) first, then internalOss (private) if user is authenticated + const { contentType, filename, lastModified, isPublic } = + await this.driveService.getUnifiedFileMetadata(fileId, user); // Check HTTP cache and get cache headers const cacheResult = checkHttpCache(req, { @@ -136,10 +136,11 @@ export class DriveController { } // Cache is stale, load the full file content - let { data } = await this.driveService.getDriveFileStream(user, fileId); + let { data } = await this.driveService.getUnifiedFileStream(fileId, user); // Process content for download: replace private URLs with public URLs in markdown/html - if (download) { + // Only process if user is authenticated (private files) + if (download && user && !isPublic) { data = await this.driveService.processContentForDownload(user, data, filename, contentType); } diff --git a/apps/api/src/modules/drive/drive.service.ts b/apps/api/src/modules/drive/drive.service.ts index b046835bc6..92eb9e9745 100644 --- a/apps/api/src/modules/drive/drive.service.ts +++ b/apps/api/src/modules/drive/drive.service.ts @@ -603,10 +603,16 @@ export class DriveService implements OnModuleInit { .replace(/[ \t]+/g, ' ') // Compress more than 2 consecutive line breaks to 2 line breaks .replace(/\n{3,}/g, '\n\n') + // Normalize excessive horizontal rules (4+ dashes/underscores/equals) to standard markdown hr + .replace(/^[ \t]*[-_=]{4,}[ \t]*$/gm, '---') + // Deduplicate consecutive horizontal rules (--- followed by newlines and ---) + .replace(/(^---\n)+---$/gm, '---') // Trim whitespace at start and end of each line .split('\n') .map((line) => line.trim()) .join('\n') + // Deduplicate consecutive horizontal rules after trimming + .replace(/(\n---)+\n---/g, '\n---') // Trim overall content .trim() ); @@ -650,12 +656,10 @@ export class DriveService implements OnModuleInit { if (cache?.parseStatus === 'success') { try { const stream = await this.internalOss.getObject(cache.contentStorageKey); + // Content is already normalized and processed during storage let content = await streamToBuffer(stream).then((b) => b.toString('utf8')); - content = content?.replace(/x00/g, '') || ''; - content = this.normalizeWhitespace(content); - - // Truncate content if it exceeds max word limit before storing + // Re-apply truncation in case maxWords config changed const maxWords = this.config.get('drive.maxContentWords') || 3000; content = this.truncateContent(content, maxWords); @@ -741,16 +745,16 @@ export class DriveService implements OnModuleInit { let processedContent = result.content?.replace(/x00/g, '') || ''; processedContent = this.normalizeWhitespace(processedContent); - // Truncate content if it exceeds max word limit before storing - const maxWords = this.config.get('drive.maxContentWords') || 3000; - processedContent = this.truncateContent(processedContent, maxWords); - - // Store to OSS + // Store normalized content to OSS (without truncation, so we can adjust limits later) const contentStorageKey = `drive-parsed/${user.uid}/${fileId}.txt`; - await this.internalOss.putObject(contentStorageKey, result.content); + await this.internalOss.putObject(contentStorageKey, processedContent); + + // Truncate content for return (but not for storage) + const maxWords = this.config.get('drive.maxContentWords') || 3000; + const truncatedContent = this.truncateContent(processedContent, maxWords); - // Calculate word count - const wordCount = readingTime(processedContent).words; + // Calculate word count from truncated content + const wordCount = readingTime(truncatedContent).words; // Save cache record (upsert ensures concurrency safety) await this.prisma.driveFileParseCache.upsert({ @@ -791,10 +795,10 @@ export class DriveService implements OnModuleInit { } this.logger.info( - `Successfully parsed and cached file ${fileId}, content length: ${processedContent.length}, word count: ${wordCount}`, + `Successfully parsed and cached file ${fileId}, content length: ${truncatedContent.length}, word count: ${wordCount}`, ); - return { ...this.toDTO(driveFile), content: processedContent }; + return { ...this.toDTO(driveFile), content: truncatedContent }; } catch (error) { this.logger.error( `Failed to parse drive file ${fileId}: ${JSON.stringify({ message: error.message })}`, @@ -1406,6 +1410,184 @@ export class DriveService implements OnModuleInit { } } + /** + * Unified file access - checks externalOss first (public), then internalOss (private with auth) + * This consolidates both public and private file access into a single method. + * + * Access logic: + * 1. First check if file exists in externalOss (public bucket) - no auth required + * 2. If not in externalOss, check if user is logged in + * 3. If logged in, verify file belongs to user and serve from internalOss + * 4. Otherwise return 404 + */ + async getUnifiedFileMetadata( + fileId: string, + user?: User | null, + ): Promise<{ + contentType: string; + filename: string; + lastModified: Date; + isPublic: boolean; + }> { + const driveFile = await this.prisma.driveFile.findFirst({ + select: { + uid: true, + name: true, + type: true, + storageKey: true, + updatedAt: true, + }, + where: { fileId, deletedAt: null }, + }); + + if (!driveFile) { + throw new NotFoundException(`Drive file not found: ${fileId}`); + } + + const storageKey = driveFile.storageKey ?? ''; + if (!storageKey) { + throw new NotFoundException(`Drive file storage key missing: ${fileId}`); + } + + // Step 1: Check if file exists in externalOss (public) + let externalObjectInfo: Awaited> | null = null; + try { + externalObjectInfo = await this.externalOss.statObject(storageKey); + } catch (error) { + this.logger.debug(`External OSS stat failed for ${storageKey}: ${error.message}`); + } + if (externalObjectInfo) { + const filename = driveFile.name || path.basename(storageKey) || 'file'; + const contentType = + driveFile.type || getSafeMimeType(filename, mime.getType(filename) ?? undefined); + + const dbUpdatedAt = new Date(driveFile.updatedAt); + const ossLastModified = externalObjectInfo.lastModified; + const lastModified = ossLastModified > dbUpdatedAt ? ossLastModified : dbUpdatedAt; + + return { + contentType, + filename, + lastModified, + isPublic: true, + }; + } + + // Step 2: File not in externalOss, check user authentication and ownership + if (!user?.uid) { + throw new NotFoundException(`Drive file not found: ${fileId}`); + } + + if (driveFile.uid !== user.uid) { + throw new NotFoundException(`Drive file not found: ${fileId}`); + } + + // Step 3: User owns the file, check internalOss + const internalObjectInfo = await this.internalOss.statObject(storageKey); + if (!internalObjectInfo) { + throw new NotFoundException(`Drive file not found in storage: ${fileId}`); + } + + const dbUpdatedAt = new Date(driveFile.updatedAt); + const ossLastModified = internalObjectInfo.lastModified; + const lastModified = ossLastModified > dbUpdatedAt ? ossLastModified : dbUpdatedAt; + + return { + contentType: driveFile.type || 'application/octet-stream', + filename: driveFile.name, + lastModified, + isPublic: false, + }; + } + + /** + * Unified file stream - checks externalOss first (public), then internalOss (private with auth) + */ + async getUnifiedFileStream( + fileId: string, + user?: User | null, + ): Promise<{ + data: Buffer; + contentType: string; + filename: string; + lastModified: Date; + isPublic: boolean; + }> { + const driveFile = await this.prisma.driveFile.findFirst({ + select: { + uid: true, + canvasId: true, + name: true, + storageKey: true, + type: true, + updatedAt: true, + }, + where: { fileId, deletedAt: null }, + }); + + if (!driveFile) { + throw new NotFoundException(`Drive file not found: ${fileId}`); + } + + const storageKey = driveFile.storageKey ?? ''; + if (!storageKey) { + throw new NotFoundException(`Drive file storage key missing: ${fileId}`); + } + + // Step 1: Try to get from externalOss (public) + try { + const externalReadable = await this.externalOss.getObject(storageKey); + if (externalReadable) { + const data = await streamToBuffer(externalReadable); + const filename = driveFile.name || path.basename(storageKey) || 'file'; + const contentType = + driveFile.type || getSafeMimeType(filename, mime.getType(filename) ?? undefined); + + return { + data, + contentType, + filename, + lastModified: new Date(driveFile.updatedAt), + isPublic: true, + }; + } + } catch (error) { + // File not in externalOss, continue to check internalOss + if ( + error?.code !== 'NoSuchKey' && + error?.code !== 'NotFound' && + !error?.message?.includes('The specified key does not exist') + ) { + this.logger.warn(`Error checking externalOss for ${fileId}: ${error.message}`); + } + } + + // Step 2: File not in externalOss, check user authentication and ownership + if (!user?.uid) { + throw new NotFoundException(`Drive file not found: ${fileId}`); + } + + if (driveFile.uid !== user.uid) { + throw new NotFoundException(`Drive file not found: ${fileId}`); + } + + // Step 3: User owns the file, get from internalOss + const internalReadable = await this.internalOss.getObject(storageKey); + if (!internalReadable) { + throw new NotFoundException(`File content not found: ${fileId}`); + } + + const data = await streamToBuffer(internalReadable); + + return { + data, + contentType: driveFile.type || 'application/octet-stream', + filename: driveFile.name, + lastModified: new Date(driveFile.updatedAt), + isPublic: false, + }; + } + /** * Regex pattern to match drive file content URLs * Matches pattern: /v1/drive/file/content/df-xxx diff --git a/apps/api/src/modules/tool-call/tool-call.service.ts b/apps/api/src/modules/tool-call/tool-call.service.ts index 56a4d27ad2..2cf162f6c2 100644 --- a/apps/api/src/modules/tool-call/tool-call.service.ts +++ b/apps/api/src/modules/tool-call/tool-call.service.ts @@ -2,9 +2,11 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ActionStepMeta } from '@refly/openapi-schema'; import type { Response } from 'express'; import { randomUUID } from 'node:crypto'; +import { safeParseJSON } from '@refly/utils'; import { writeSSEResponse } from '../../utils/response'; import { PrismaService } from '../common/prisma.service'; import { ActionStep, ToolCallResult } from '@prisma/client'; +import { sanitizeToolOutput } from '../action/action.dto'; export type ToolEventPayload = { run_id?: string; metadata?: { toolsetKey?: string; name?: string }; @@ -233,7 +235,11 @@ export class ToolCallService { * @param toolCalls - Array of tool calls with optional stepName * @returns Steps with attached tool calls and merged XML content */ - attachToolCallsToSteps(steps: ActionStep[], toolCalls: ToolCallResult[]): Array { + attachToolCallsToSteps( + steps: ActionStep[], + toolCalls: ToolCallResult[], + options?: { sanitizeForDisplay?: boolean }, + ): Array { if (!steps || steps.length === 0) { return []; } @@ -265,6 +271,13 @@ export class ToolCallService { if (!call || typeof call !== 'object' || !call?.callId) { return null; } + + // Parse output JSON string and optionally sanitize + const rawOutput = safeParseJSON(call.output || '{}') ?? {}; + const output = options?.sanitizeForDisplay + ? sanitizeToolOutput(call.toolName, rawOutput) + : rawOutput; + return this.generateToolUseXML({ toolCallId: call.callId, includeResult: call?.status !== ToolCallStatus.EXECUTING, @@ -280,7 +293,7 @@ export class ToolCallService { toolsetName: call.toolsetId, }, input: call.input, - output: call.output, + output, startTs: call.createdAt.getTime(), updatedTs: call.updatedAt.getTime() ?? call.createdAt.getTime(), }); diff --git a/apps/api/src/modules/voucher/voucher.constants.ts b/apps/api/src/modules/voucher/voucher.constants.ts index f66ac443c8..0ebcd12e66 100644 --- a/apps/api/src/modules/voucher/voucher.constants.ts +++ b/apps/api/src/modules/voucher/voucher.constants.ts @@ -3,7 +3,7 @@ */ // Daily popup trigger limit per user -export const DAILY_POPUP_TRIGGER_LIMIT = 3; +export const DAILY_POPUP_TRIGGER_LIMIT = 999; // Default LLM score when scoring fails (50 = 50% discount) export const DEFAULT_LLM_SCORE = 50; diff --git a/apps/api/src/modules/voucher/voucher.service.ts b/apps/api/src/modules/voucher/voucher.service.ts index 779c89c1bf..ae69511528 100644 --- a/apps/api/src/modules/voucher/voucher.service.ts +++ b/apps/api/src/modules/voucher/voucher.service.ts @@ -633,17 +633,32 @@ export class VoucherService implements OnModuleInit { /** * Create a sharing invitation for a voucher + * Users can share if they are the owner OR if they claimed the voucher via invitation */ async createInvitation(uid: string, voucherId: string): Promise { - // Get the voucher + // Get the voucher (without uid filter - we'll check permission separately) const voucher = await this.prisma.voucher.findFirst({ - where: { voucherId, uid }, + where: { voucherId }, }); if (!voucher) { throw new Error('Voucher not found'); } + // Check permission: owner OR claimant (same logic as validateVoucher) + const isOwner = voucher.uid === uid; + const isClaimant = await this.prisma.voucherInvitation.findFirst({ + where: { + voucherId, + inviteeUid: uid, + status: InvitationStatus.CLAIMED, + }, + }); + + if (!isOwner && !isClaimant) { + throw new Error('Voucher not found'); + } + const invitationId = genVoucherInvitationID(); const inviteCode = genInviteCode(); diff --git a/packages/agent-tools/src/builtin/index.ts b/packages/agent-tools/src/builtin/index.ts index 25b9c58b09..c97e97dd27 100644 --- a/packages/agent-tools/src/builtin/index.ts +++ b/packages/agent-tools/src/builtin/index.ts @@ -291,22 +291,13 @@ export class BuiltinGenerateDoc extends AgentBaseTool { schema = z.object({ title: z.string().describe('Title of the document to generate'), content: z.string().describe( - `Document content. When referencing files from context, replace the filename with its fileId using these formats: - -Supported fileId placeholder formats: -- \`file-content://df-\` - Direct content URL (for embedded media) -- \`file://df-\` - Share page URL (for clickable links) -- \`fileId://df-\` - Share page URL -- \`@file:df-\` - Share page URL -- \`files/df-\` - Share page URL -- \`df-\` - Direct content URL (standalone) - -Usage by document type: -- Markdown: \`![alt text](df-)\` or \`![alt text](file-content://df-)\` -- HTML: \`\` or \`\` -- Plain text/other: Use direct fileId \`df-\` which converts to content URL - -IMPORTANT: Always use the fileId (format: df-xxx) from context, NOT the original filename.`, + `Document content. When embedding files from context, use these placeholder formats: +- \`file-content://df-\` - Direct content URL (for , embedded media) +- \`file://df-\` - Share page URL (for , clickable links) + +Example: ![image](file-content://df-xxx) or + +IMPORTANT: Use the fileId (format: df-xxx) from context, NOT the original filename.`, ), }); description = @@ -358,32 +349,45 @@ IMPORTANT: Always use the fileId (format: df-xxx) from context, NOT the original /** * Replace file placeholders in content with HTTP URLs. - * Supports multiple formats: - * - `file-content://df-xxx` → Direct file content URL (for images, etc.) - * - `file://df-xxx` → Share page URL (for links/previews) - * - `fileId://df-xxx` → Share page URL - * - `@file:df-xxx` → Share page URL - * - `files/df-xxx` → Share page URL - * - `df-xxx` (standalone) → Direct file content URL + * Supported formats: + * - `file-content://df-xxx` → Direct file content URL (for images, embedded media) + * - `file://df-xxx` → Share page URL (for links) */ private async replaceFilePlaceholders(content: string): Promise { if (!content) { return content; } + // Check for placeholder formats + const hasFileContent = content.includes('file-content://df-'); + const hasFile = content.includes('file://df-'); + + if (!hasFileContent && !hasFile) { + return content; + } + try { - // Pattern to find all fileIds in various formats - // Uses lookbehind to ensure 'df-' is not preceded by alphanumeric (avoid matching 'pdf-xxx') - const fileIdPattern = /(?(); + for (const [, fileId] of contentMatches) { + allFileIds.add(fileId); + } + for (const [, fileId] of shareMatches) { + allFileIds.add(fileId); } - // Collect unique file IDs - const uniqueFileIds = [...new Set(allMatches.map(([, fileId]) => fileId))]; + if (allFileIds.size === 0) { + return content; + } + const uniqueFileIds = Array.from(allFileIds); const { reflyService, user } = this.params; // Fetch all drive files and generate URLs @@ -393,7 +397,6 @@ IMPORTANT: Always use the fileId (format: df-xxx) from context, NOT the original const { url, contentUrl } = await reflyService.createShareForDriveFile(user, fileId); return { fileId, shareUrl: url, contentUrl }; } catch (error) { - // If file not found or URL generation fails, log and keep original placeholder console.error( `[BuiltinGenerateDoc] Failed to create share URL for fileId ${fileId}:`, error, @@ -426,38 +429,12 @@ IMPORTANT: Always use the fileId (format: df-xxx) from context, NOT the original // Replace file://df-xxx with share page URLs (but not file-content://) result = result.replace( - /(? shareUrlMap.get(fileId) ?? match, - ); - - // Replace fileId://df-xxx with share page URLs - result = result.replace( - /fileId:\/\/(df-[a-z0-9]+)/gi, + /file:\/\/(df-[a-z0-9]+)/gi, (match, fileId: string) => shareUrlMap.get(fileId) ?? match, ); - // Replace @file:df-xxx with share page URLs - result = result.replace( - /@file:(df-[a-z0-9]+)/gi, - (match, fileId: string) => shareUrlMap.get(fileId) ?? match, - ); - - // Replace files/df-xxx with share page URLs - result = result.replace( - /files\/(df-[a-z0-9]+)/gi, - (match, fileId: string) => shareUrlMap.get(fileId) ?? match, - ); - - // Replace standalone df-xxx (not already processed) with content URLs - // This handles cases like markdown images: ![image](df-xxx) - result = result.replace(/(? { - // Only replace if we have a URL for this fileId - return contentUrlMap.get(fileId) ?? match; - }); - return result; } catch (error) { - // Log error and return original content to avoid breaking document generation console.error('[BuiltinGenerateDoc] Error replacing file placeholders:', error); return content; } @@ -472,7 +449,15 @@ export class BuiltinGenerateCodeArtifact extends AgentBaseTool\` - Direct content URL (for , , embedded media) +- \`file://df-\` - Share page URL (for , clickable links) + +Example: ![image](file-content://df-xxx) or + +IMPORTANT: Use the fileId (format: df-xxx) from context, NOT the original filename.`, + ), }); description = `Generate renderable content files that display as rich previews in the UI. @@ -491,10 +476,8 @@ export class BuiltinGenerateCodeArtifact extends AgentBaseTool\` tag requires **explicit numeric width and height** (e.g., \`width="300" height="200"\`). Do NOT use \`auto\` — it's invalid in SVG -- Markdown supports standard image syntax: ![alt](full-url)`; +## Note +- SVG \`\` tag requires explicit numeric width and height (e.g., \`width="300" height="200"\`)`; protected params: BuiltinToolParams; @@ -510,10 +493,14 @@ export class BuiltinGenerateCodeArtifact extends AgentBaseTool { try { const { reflyService, user } = this.params; + + // Replace file placeholders with HTTP URLs before writing + const processedContent = await this.replaceFilePlaceholders(input.content); + const file = await reflyService.writeFile(user, { name: input.filename, type: 'text/plain', - content: input.content, + content: processedContent, canvasId: config.configurable?.canvasId, resultId: config.configurable?.resultId, resultVersion: config.configurable?.version, @@ -535,6 +522,99 @@ export class BuiltinGenerateCodeArtifact extends AgentBaseTool { + if (!content) { + return content; + } + + // Check for placeholder formats + const hasFileContent = content.includes('file-content://df-'); + const hasFile = content.includes('file://df-'); + + if (!hasFileContent && !hasFile) { + return content; + } + + try { + // Match both formats + const contentMatchPattern = /file-content:\/\/(df-[a-z0-9]+)/gi; + const shareMatchPattern = /file:\/\/(df-[a-z0-9]+)/gi; + + const contentMatches = Array.from(content.matchAll(contentMatchPattern)); + const shareMatches = Array.from(content.matchAll(shareMatchPattern)); + + // Collect all unique file IDs + const allFileIds = new Set(); + for (const [, fileId] of contentMatches) { + allFileIds.add(fileId); + } + for (const [, fileId] of shareMatches) { + allFileIds.add(fileId); + } + + if (allFileIds.size === 0) { + return content; + } + + const uniqueFileIds = Array.from(allFileIds); + const { reflyService, user } = this.params; + + // Fetch all drive files and generate URLs + const urlResults = await Promise.all( + uniqueFileIds.map(async (fileId) => { + try { + const { url, contentUrl } = await reflyService.createShareForDriveFile(user, fileId); + return { fileId, shareUrl: url, contentUrl }; + } catch (error) { + console.error( + `[BuiltinGenerateCodeArtifact] Failed to create share URL for fileId ${fileId}:`, + error, + ); + return { fileId, shareUrl: null, contentUrl: null }; + } + }), + ); + + // Build URL maps + const shareUrlMap = new Map(); + const contentUrlMap = new Map(); + + for (const { fileId, shareUrl, contentUrl } of urlResults) { + if (shareUrl) { + shareUrlMap.set(fileId, shareUrl); + } + if (contentUrl) { + contentUrlMap.set(fileId, contentUrl); + } + } + + let result = content; + + // Replace file-content://df-xxx with direct content URLs + result = result.replace( + /file-content:\/\/(df-[a-z0-9]+)/gi, + (match, fileId: string) => contentUrlMap.get(fileId) ?? match, + ); + + // Replace file://df-xxx with share page URLs (but not file-content://) + result = result.replace( + /file:\/\/(df-[a-z0-9]+)/gi, + (match, fileId: string) => shareUrlMap.get(fileId) ?? match, + ); + + return result; + } catch (error) { + console.error('[BuiltinGenerateCodeArtifact] Error replacing file placeholders:', error); + return content; + } + } } export class BuiltinSendEmail extends AgentBaseTool { @@ -623,7 +703,7 @@ IMPORTANT: Use \`file-content://\` for ,