Skip to content
7 changes: 5 additions & 2 deletions apps/api/src/modules/action/action.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ export class ActionController {
@LoginedUser() user: UserModel,
@Query('resultId') resultId: string,
): Promise<GetActionResultResponse> {
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)
Expand Down
46 changes: 40 additions & 6 deletions apps/api/src/modules/action/action.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
}

Expand All @@ -53,15 +55,47 @@ 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<string, unknown>,
): Record<string, unknown> {
// 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<string, unknown>;
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,
toolsetId: toolCall.toolsetId,
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(),
Expand All @@ -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',
Expand Down Expand Up @@ -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 || '[]'),
Expand Down
26 changes: 19 additions & 7 deletions apps/api/src/modules/action/action.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,6 +35,7 @@ import { InvokeSkillJobData } from '../skill/skill.dto';

type GetActionResultParams = GetActionResultData['query'] & {
includeFiles?: boolean;
sanitizeForDisplay?: boolean;
};

@Injectable()
Expand All @@ -60,7 +61,7 @@ export class ActionService {
) {}

async getActionResult(user: User, param: GetActionResultParams): Promise<ActionDetail> {
const { resultId, version, includeFiles = false } = param;
const { resultId, version, includeFiles = false, sanitizeForDisplay = false } = param;

const result = await this.prisma.actionResult.findFirst({
where: {
Expand All @@ -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, {
Expand All @@ -92,6 +95,7 @@ export class ActionService {
private async enrichActionResultWithDetails(
user: User,
result: ActionResult,
options?: { sanitizeForDisplay?: boolean },
): Promise<ActionDetail> {
const item =
(result.providerItemId
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand All @@ -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 };
}

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/copilot/copilot.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
};
17 changes: 9 additions & 8 deletions apps/api/src/modules/drive/drive.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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, {
Expand All @@ -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);
}

Expand Down
Loading