Fix structured attachment rendering (#69)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering
This commit is contained in:
38
apps/dashboard/src/RoomAttachmentGallery.tsx
Normal file
38
apps/dashboard/src/RoomAttachmentGallery.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { RoomThreadEntry } from './roomThread';
|
||||
|
||||
type RoomAttachment = NonNullable<RoomThreadEntry['attachments']>[number];
|
||||
|
||||
function attachmentDisplayName(attachment: RoomAttachment): string {
|
||||
if (attachment.name) return attachment.name;
|
||||
const segments = attachment.path.split(/[\\/]/);
|
||||
return segments.at(-1) || 'attachment';
|
||||
}
|
||||
|
||||
function attachmentUrl(attachment: RoomAttachment): string {
|
||||
return `/api/attachments?path=${encodeURIComponent(attachment.path)}`;
|
||||
}
|
||||
|
||||
export function RoomAttachmentGallery({
|
||||
attachments,
|
||||
}: {
|
||||
attachments?: RoomThreadEntry['attachments'];
|
||||
}) {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="room-attachments">
|
||||
{attachments.map((attachment) => {
|
||||
const name = attachmentDisplayName(attachment);
|
||||
const url = attachmentUrl(attachment);
|
||||
return (
|
||||
<li className="room-attachment" key={`${attachment.path}:${name}`}>
|
||||
<a href={url} rel="noreferrer" target="_blank">
|
||||
<img alt={name} loading="lazy" src={url} />
|
||||
<span>{name}</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -161,4 +161,51 @@ describe('RoomCardV2', () => {
|
||||
expect(html).toContain('<code>pnpm openapi:sync</code>');
|
||||
expect(html).not.toContain('`origin/dev`');
|
||||
});
|
||||
|
||||
it('renders message attachments as dashboard images', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(RoomCardV2, {
|
||||
activity: activity({
|
||||
messages: [
|
||||
{
|
||||
id: 'bot-attachment',
|
||||
sender: 'bot-1',
|
||||
senderName: 'owner',
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
timestamp: '2026-04-28T02:00:00.000Z',
|
||||
isFromMe: false,
|
||||
isBotMessage: true,
|
||||
sourceKind: 'bot',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
activityLoading: false,
|
||||
busy: false,
|
||||
draft: '',
|
||||
entry,
|
||||
expanded: true,
|
||||
inboxItems: [],
|
||||
locale: 'ko',
|
||||
onDraftChange: () => {},
|
||||
onSendMessage: () => {},
|
||||
onToggle: () => {},
|
||||
pinned: true,
|
||||
t,
|
||||
...formatters,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('class="room-attachments"');
|
||||
expect(html).toContain(
|
||||
'/api/attachments?path=%2Ftmp%2Fbar-chart-label-fit-playwright.png',
|
||||
);
|
||||
expect(html).toContain('bar-chart-label-fit-playwright.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Send } from 'lucide-react';
|
||||
import type { DashboardOverview, DashboardRoomActivity } from './api';
|
||||
import type { Locale, Messages } from './i18n';
|
||||
import { ParsedBody } from './ParsedBody';
|
||||
import { RoomAttachmentGallery } from './RoomAttachmentGallery';
|
||||
import {
|
||||
buildRoomThreadEntries,
|
||||
isWatcherRoomMessage,
|
||||
@@ -779,6 +780,7 @@ function RoomTimelineEntry({
|
||||
</header>
|
||||
<div className="room-timeline-body">
|
||||
<ParsedBody text={entry.content} />
|
||||
<RoomAttachmentGallery attachments={entry.attachments} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,11 @@ export interface DashboardRoomActivity {
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
attachments?: Array<{
|
||||
path: string;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
}>;
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
@@ -141,6 +146,11 @@ export interface DashboardRoomActivity {
|
||||
verdict: string | null;
|
||||
createdAt: string;
|
||||
outputText: string;
|
||||
attachments?: Array<{
|
||||
path: string;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
}>;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type RoomThreadEntry = {
|
||||
id: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
attachments?: RoomMessage['attachments'];
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
@@ -51,6 +52,7 @@ function toMessageEntry(message: RoomMessage): RoomThreadEntry {
|
||||
id: message.id,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
timestamp: message.timestamp,
|
||||
isFromMe: message.isFromMe,
|
||||
isBotMessage: message.isBotMessage,
|
||||
@@ -64,6 +66,7 @@ function toOutputEntry(output: RoomOutput): RoomThreadEntry | null {
|
||||
id: `out:${output.id}`,
|
||||
senderName: output.role,
|
||||
content: output.outputText,
|
||||
attachments: output.attachments,
|
||||
timestamp: output.createdAt,
|
||||
isFromMe: false,
|
||||
isBotMessage: true,
|
||||
|
||||
@@ -4039,6 +4039,38 @@ progress::-moz-progress-bar {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.room-attachments {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.room-attachment a {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: min(220px, 100%);
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.room-attachment img {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.room-attachment span {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
color: var(--muted-soft);
|
||||
}
|
||||
|
||||
/* thread — flat */
|
||||
.room-thread {
|
||||
list-style: none;
|
||||
|
||||
101
src/channels/discord-outbound.ts
Normal file
101
src/channels/discord-outbound.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
extractImageTagPaths,
|
||||
normalizeEjclawStructuredOutput,
|
||||
} from '../agent-protocol.js';
|
||||
import type { OutboundAttachment } from '../types.js';
|
||||
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
||||
|
||||
export interface PreparedDiscordOutbound {
|
||||
text: string;
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'none';
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
function extractMarkdownImageAttachments(text: string): {
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
} {
|
||||
const attachments: OutboundAttachment[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
|
||||
const trimmed = rawPath.trim();
|
||||
if (IMAGE_EXTS.test(trimmed)) {
|
||||
if (!seen.has(trimmed)) {
|
||||
attachments.push({
|
||||
path: trimmed,
|
||||
name: path.basename(trimmed),
|
||||
});
|
||||
seen.add(trimmed);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
||||
const lineMatch = trimmed.match(/#L(\d+)/);
|
||||
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
|
||||
});
|
||||
|
||||
return { cleanText, attachments };
|
||||
}
|
||||
|
||||
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
|
||||
return paths
|
||||
.filter((filePath) => IMAGE_EXTS.test(filePath))
|
||||
.map((filePath) => ({
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
}));
|
||||
}
|
||||
|
||||
export function prepareDiscordOutbound(
|
||||
text: string,
|
||||
optionAttachments: OutboundAttachment[] | undefined,
|
||||
): PreparedDiscordOutbound {
|
||||
const normalized = normalizeEjclawStructuredOutput(text);
|
||||
if (normalized.output?.visibility === 'silent') {
|
||||
return {
|
||||
text: '',
|
||||
cleanText: '',
|
||||
attachments: [],
|
||||
attachmentSource: 'none',
|
||||
silent: true,
|
||||
};
|
||||
}
|
||||
|
||||
const structuredOutput =
|
||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
|
||||
const structuredAttachments =
|
||||
optionAttachments && optionAttachments.length > 0
|
||||
? optionAttachments
|
||||
: (structuredOutput?.attachments ?? []);
|
||||
const hasStructuredAttachments = structuredAttachments.length > 0;
|
||||
const markdownExtracted = extractMarkdownImageAttachments(outboundText);
|
||||
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
|
||||
const legacyImageTagAttachments = imageTagPathsToAttachments(
|
||||
imageTagExtracted.imagePaths,
|
||||
);
|
||||
|
||||
return {
|
||||
text: outboundText,
|
||||
cleanText: imageTagExtracted.cleanText,
|
||||
attachments: hasStructuredAttachments
|
||||
? structuredAttachments
|
||||
: [...markdownExtracted.attachments, ...legacyImageTagAttachments],
|
||||
attachmentSource: hasStructuredAttachments
|
||||
? 'structured'
|
||||
: markdownExtracted.attachments.length > 0
|
||||
? 'md-link'
|
||||
: legacyImageTagAttachments.length > 0
|
||||
? 'image-tag'
|
||||
: 'none',
|
||||
silent: false,
|
||||
};
|
||||
}
|
||||
171
src/channels/discord-structured-output.test.ts
Normal file
171
src/channels/discord-structured-output.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./registry.js', () => ({
|
||||
registerChannel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../env.js', () => ({
|
||||
readEnvFile: vi.fn(() => ({})),
|
||||
getEnv: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
CACHE_DIR: '/tmp/ejclaw-test-cache',
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../service-routing.js', () => ({
|
||||
hasReviewerLease: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('discord.js', () => {
|
||||
class MockClient {
|
||||
eventHandlers = new Map<string, Handler[]>();
|
||||
user: any = { id: '999888777', tag: 'Andy#1234' };
|
||||
private _ready = false;
|
||||
|
||||
constructor(_opts: any) {
|
||||
clientRef.current = this;
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler) {
|
||||
this.eventHandlers.set(event, [
|
||||
...(this.eventHandlers.get(event) ?? []),
|
||||
handler,
|
||||
]);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: Handler) {
|
||||
return this.on(event, handler);
|
||||
}
|
||||
|
||||
async login(_token: string) {
|
||||
this._ready = true;
|
||||
for (const handler of this.eventHandlers.get('ready') ?? []) {
|
||||
handler({ user: this.user });
|
||||
}
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
channels = {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Client: MockClient,
|
||||
Events: {
|
||||
MessageCreate: 'messageCreate',
|
||||
ClientReady: 'ready',
|
||||
Error: 'error',
|
||||
},
|
||||
GatewayIntentBits: {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 4,
|
||||
DirectMessages: 8,
|
||||
},
|
||||
MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
|
||||
TextChannel: class TextChannel {},
|
||||
};
|
||||
});
|
||||
|
||||
import { DiscordChannel, type DiscordChannelOpts } from './discord.js';
|
||||
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const file of tempFiles.splice(0)) {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createTestOpts(): DiscordChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
roomBindings: vi.fn(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DiscordChannel structured output', () => {
|
||||
it('normalizes raw EJClaw JSON and sends direct temp images as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: filePath,
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
|
||||
'"ejclaw"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
} from '../config.js';
|
||||
import { getEnv } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { extractImageTagPaths } from '../agent-protocol.js';
|
||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||
import { formatOutbound } from '../router.js';
|
||||
import { hasReviewerLease } from '../service-routing.js';
|
||||
import type { OutboundAttachment, SendMessageOptions } from '../types.js';
|
||||
import type { SendMessageOptions } from '../types.js';
|
||||
import { prepareDiscordOutbound } from './discord-outbound.js';
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||
@@ -33,45 +33,6 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter';
|
||||
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
|
||||
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
|
||||
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN';
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
||||
|
||||
function extractMarkdownImageAttachments(text: string): {
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
} {
|
||||
const attachments: OutboundAttachment[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
|
||||
const trimmed = rawPath.trim();
|
||||
if (IMAGE_EXTS.test(trimmed)) {
|
||||
if (!seen.has(trimmed)) {
|
||||
attachments.push({
|
||||
path: trimmed,
|
||||
name: path.basename(trimmed),
|
||||
});
|
||||
seen.add(trimmed);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
||||
const lineMatch = trimmed.match(/#L(\d+)/);
|
||||
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
|
||||
});
|
||||
|
||||
return { cleanText, attachments };
|
||||
}
|
||||
|
||||
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
|
||||
return paths
|
||||
.filter((filePath) => IMAGE_EXTS.test(filePath))
|
||||
.map((filePath) => ({
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a Discord attachment to local disk.
|
||||
@@ -466,26 +427,15 @@ export class DiscordChannel implements Channel {
|
||||
|
||||
const textChannel = channel as TextChannel;
|
||||
|
||||
const structuredAttachments = options.attachments ?? [];
|
||||
const hasStructuredAttachments = structuredAttachments.length > 0;
|
||||
const markdownExtracted = extractMarkdownImageAttachments(text);
|
||||
const imageTagExtracted = extractImageTagPaths(
|
||||
markdownExtracted.cleanText,
|
||||
);
|
||||
const legacyImageTagAttachments = imageTagPathsToAttachments(
|
||||
imageTagExtracted.imagePaths,
|
||||
);
|
||||
const outboundAttachments = hasStructuredAttachments
|
||||
? structuredAttachments
|
||||
: [...markdownExtracted.attachments, ...legacyImageTagAttachments];
|
||||
const attachmentSource = hasStructuredAttachments
|
||||
? 'structured'
|
||||
: markdownExtracted.attachments.length > 0
|
||||
? 'md-link'
|
||||
: legacyImageTagAttachments.length > 0
|
||||
? 'image-tag'
|
||||
: 'none';
|
||||
const validation = validateOutboundAttachments(outboundAttachments, {
|
||||
const outbound = prepareDiscordOutbound(text, options.attachments);
|
||||
if (outbound.silent) {
|
||||
logger.debug(
|
||||
{ jid, channelName: this.name },
|
||||
'Skipping silent structured Discord outbound message',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const validation = validateOutboundAttachments(outbound.attachments, {
|
||||
baseDirs: options.attachmentBaseDirs,
|
||||
});
|
||||
const files = validation.files;
|
||||
@@ -495,14 +445,14 @@ export class DiscordChannel implements Channel {
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
attachmentSource,
|
||||
attachmentSource: outbound.attachmentSource,
|
||||
rejected: validation.rejected,
|
||||
},
|
||||
'Rejected outbound Discord attachments',
|
||||
);
|
||||
}
|
||||
|
||||
let cleaned = imageTagExtracted.cleanText
|
||||
let cleaned = outbound.cleanText
|
||||
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
|
||||
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
|
||||
.trim();
|
||||
@@ -588,11 +538,11 @@ export class DiscordChannel implements Channel {
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
length: text.length,
|
||||
length: outbound.text.length,
|
||||
deliveryMode: 'send',
|
||||
chunkCount,
|
||||
attachmentCount: files.length,
|
||||
attachmentSource,
|
||||
attachmentSource: outbound.attachmentSource,
|
||||
messageId: sentMessageIds[0] ?? null,
|
||||
messageIds: sentMessageIds,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
|
||||
@@ -599,6 +599,50 @@ describe('IPC message authorization', () => {
|
||||
expect(result.outcome).toBe('sent');
|
||||
});
|
||||
|
||||
it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => {
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
|
||||
const result = await forwardAuthorizedIpcMessage(
|
||||
{
|
||||
type: 'message',
|
||||
chatJid: 'other@g.us',
|
||||
text: JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '첨부를 렌더링했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
groups,
|
||||
sendMessage,
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
'other@g.us',
|
||||
'첨부를 렌더링했습니다.',
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(result.outcome).toBe('sent');
|
||||
});
|
||||
|
||||
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const injectInboundMessage = vi.fn(async () => {});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
IpcMessageForwardResult,
|
||||
IpcMessagePayload,
|
||||
} from './ipc-types.js';
|
||||
import { normalizeEjclawStructuredOutput } from './agent-protocol.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
export async function forwardAuthorizedIpcMessage(
|
||||
@@ -80,13 +81,25 @@ export async function forwardAuthorizedIpcMessage(
|
||||
};
|
||||
}
|
||||
|
||||
await sendMessage(
|
||||
msg.chatJid,
|
||||
msg.text,
|
||||
msg.senderRole,
|
||||
msg.runId,
|
||||
msg.attachments,
|
||||
);
|
||||
const normalized = normalizeEjclawStructuredOutput(msg.text);
|
||||
if (normalized.output?.visibility === 'silent') {
|
||||
return {
|
||||
outcome: 'sent',
|
||||
chatJid: msg.chatJid,
|
||||
targetGroup: targetGroup?.folder ?? null,
|
||||
isMainOverride,
|
||||
senderRole: msg.senderRole ?? null,
|
||||
};
|
||||
}
|
||||
const structured =
|
||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||
const text = structured?.text ?? normalized.result ?? msg.text;
|
||||
const attachments =
|
||||
msg.attachments && msg.attachments.length > 0
|
||||
? msg.attachments
|
||||
: (structured?.attachments ?? undefined);
|
||||
|
||||
await sendMessage(msg.chatJid, text, msg.senderRole, msg.runId, attachments);
|
||||
return {
|
||||
outcome: 'sent',
|
||||
chatJid: msg.chatJid,
|
||||
|
||||
@@ -86,6 +86,31 @@ describe('validateOutboundAttachments', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts direct generated screenshot files under the temp directory', () => {
|
||||
const imagePath = path.join(
|
||||
os.tmpdir(),
|
||||
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(imagePath, ONE_PIXEL_PNG);
|
||||
cleanupFiles.push(imagePath);
|
||||
|
||||
const result = validateOutboundAttachments([
|
||||
{
|
||||
path: imagePath,
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.rejected).toEqual([]);
|
||||
expect(result.files).toEqual([
|
||||
{
|
||||
attachment: fs.realpathSync(imagePath),
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires workspace paths to be explicitly allowlisted', () => {
|
||||
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
||||
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
||||
|
||||
@@ -21,7 +21,6 @@ const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [
|
||||
'ejclaw-attachment-',
|
||||
'ejclaw-discord-image-',
|
||||
] as const;
|
||||
const DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES = ['ejclaw-'] as const;
|
||||
|
||||
function unique(values: Array<string | null | undefined>): string[] {
|
||||
return [
|
||||
@@ -44,8 +43,8 @@ export function getDefaultAttachmentBaseDirs(): string[] {
|
||||
process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null);
|
||||
// Keep defaults narrow. Runtime-specific workspaces must be passed via
|
||||
// attachmentBaseDirs so one room cannot attach another room's files by path.
|
||||
// Ad-hoc generated files under os.tmpdir()/ejclaw-attachment-* are handled
|
||||
// separately after resolving symlinks, without allowlisting all of /tmp.
|
||||
// Ad-hoc generated files under os.tmpdir() are handled separately after
|
||||
// resolving symlinks, without allowlisting all of /tmp recursively.
|
||||
return unique([
|
||||
path.join(DATA_DIR, 'attachments'),
|
||||
codexHome ? path.join(codexHome, 'generated_images') : null,
|
||||
@@ -84,7 +83,7 @@ function isWithinDefaultTempAttachmentDir(
|
||||
);
|
||||
}
|
||||
|
||||
function isDefaultTempAttachmentFile(
|
||||
function isDirectTempAttachmentFile(
|
||||
realPath: string,
|
||||
tempDir: string | null,
|
||||
): boolean {
|
||||
@@ -93,10 +92,10 @@ function isDefaultTempAttachmentFile(
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
if (relative.includes(path.sep)) return false;
|
||||
return DEFAULT_TEMP_ATTACHMENT_FILE_PREFIXES.some((prefix) =>
|
||||
relative.startsWith(prefix),
|
||||
);
|
||||
// Agents commonly write Playwright screenshots and generated images directly
|
||||
// under /tmp with task-specific names. Keep this limited to direct children;
|
||||
// nested temp directories still need an EJClaw prefix or explicit baseDir.
|
||||
return !relative.includes(path.sep);
|
||||
}
|
||||
|
||||
function detectImageMime(filePath: string): string | null {
|
||||
@@ -188,7 +187,7 @@ export function validateOutboundAttachments(
|
||||
if (
|
||||
!matchesAllowedBaseDir(realPath, baseDirs) &&
|
||||
!isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) &&
|
||||
!isDefaultTempAttachmentFile(realPath, defaultTempDir)
|
||||
!isDirectTempAttachmentFile(realPath, defaultTempDir)
|
||||
) {
|
||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||
continue;
|
||||
|
||||
46
src/web-dashboard-attachments.test.ts
Normal file
46
src/web-dashboard-attachments.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createWebDashboardHandler } from './web-dashboard-server.js';
|
||||
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const file of tempFiles.splice(0)) {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('web dashboard attachment previews', () => {
|
||||
it('serves validated direct temp image attachments', async () => {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||
tempFiles.push(filePath);
|
||||
const handler = createWebDashboardHandler({
|
||||
readStatusSnapshots: () => [],
|
||||
getTasks: () => [],
|
||||
getPairedTasks: () => [],
|
||||
});
|
||||
|
||||
const response = await handler(
|
||||
new Request(
|
||||
`http://localhost/api/attachments?path=${encodeURIComponent(filePath)}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(Buffer.from(await response.arrayBuffer())).toEqual(ONE_PIXEL_PNG);
|
||||
});
|
||||
});
|
||||
50
src/web-dashboard-attachments.ts
Normal file
50
src/web-dashboard-attachments.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { validateOutboundAttachments } from './outbound-attachments.js';
|
||||
|
||||
function attachmentJsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getAttachmentContentType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.bmp') return 'image/bmp';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function serveValidatedAttachment(url: URL): Response {
|
||||
const requestedPath = url.searchParams.get('path');
|
||||
if (!requestedPath) {
|
||||
return attachmentJsonResponse(
|
||||
{ error: 'Missing attachment path' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const validation = validateOutboundAttachments([{ path: requestedPath }]);
|
||||
const file = validation.files[0];
|
||||
if (!file) {
|
||||
return attachmentJsonResponse(
|
||||
{ error: 'Attachment not found or not allowed' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(fs.readFileSync(file.attachment), {
|
||||
headers: {
|
||||
'content-type': getAttachmentContentType(file.attachment),
|
||||
'cache-control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
155
src/web-dashboard-data-attachments.test.ts
Normal file
155
src/web-dashboard-data-attachments.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
||||
import { buildWebDashboardRoomActivity } from './web-dashboard-data.js';
|
||||
|
||||
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||
return {
|
||||
id: 'paired-1',
|
||||
chat_jid: 'dc:ops',
|
||||
group_folder: 'ops',
|
||||
owner_service_id: 'codex-main',
|
||||
reviewer_service_id: 'claude-reviewer',
|
||||
owner_agent_type: 'codex',
|
||||
reviewer_agent_type: 'claude-code',
|
||||
arbiter_agent_type: 'codex',
|
||||
title: 'Dashboard PR',
|
||||
source_ref: null,
|
||||
plan_notes: null,
|
||||
review_requested_at: null,
|
||||
round_trip_count: 0,
|
||||
owner_failure_count: 0,
|
||||
owner_step_done_streak: 0,
|
||||
finalize_step_done_count: 0,
|
||||
task_done_then_user_reopen_count: 0,
|
||||
empty_step_done_streak: 0,
|
||||
status: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-04-26T04:00:00.000Z',
|
||||
updated_at: '2026-04-26T04:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const roomEntry = {
|
||||
jid: 'dc:ops',
|
||||
name: '#ops',
|
||||
folder: 'ops',
|
||||
agentType: 'codex' as const,
|
||||
status: 'inactive' as const,
|
||||
elapsedMs: null,
|
||||
pendingMessages: false,
|
||||
pendingTasks: 0,
|
||||
};
|
||||
|
||||
describe('web dashboard attachment data', () => {
|
||||
it('normalizes structured EJClaw envelopes in room messages and outputs', () => {
|
||||
const task = makePairedTask({ id: 'paired-structured' });
|
||||
const structured = JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const output: PairedTurnOutput = {
|
||||
id: 1,
|
||||
task_id: task.id,
|
||||
turn_number: 1,
|
||||
role: 'owner',
|
||||
output_text: structured,
|
||||
verdict: 'done',
|
||||
created_at: '2026-04-26T05:30:00.000Z',
|
||||
};
|
||||
const message: NewMessage = {
|
||||
id: 'msg-structured',
|
||||
chat_jid: 'dc:ops',
|
||||
sender: 'bot-1',
|
||||
sender_name: 'owner',
|
||||
content: structured,
|
||||
timestamp: '2026-04-26T05:31:00.000Z',
|
||||
is_from_me: true,
|
||||
is_bot_message: true,
|
||||
message_source_kind: 'bot',
|
||||
};
|
||||
|
||||
const activity = buildWebDashboardRoomActivity({
|
||||
serviceId: 'codex-main',
|
||||
entry: roomEntry,
|
||||
pairedTask: task,
|
||||
turns: [],
|
||||
attempts: [],
|
||||
outputs: [output],
|
||||
messages: [message],
|
||||
});
|
||||
|
||||
expect(activity.messages[0]).toMatchObject({
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(activity.messages[0]?.content).not.toContain('"ejclaw"');
|
||||
expect(activity.pairedTask?.outputs[0]).toMatchObject({
|
||||
outputText: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(activity.pairedTask?.outputs[0]?.outputText).not.toContain(
|
||||
'"ejclaw"',
|
||||
);
|
||||
});
|
||||
|
||||
it('turns legacy Discord image placeholders into dashboard attachments', () => {
|
||||
const message: NewMessage = {
|
||||
id: 'msg-image',
|
||||
chat_jid: 'dc:ops',
|
||||
sender: 'bot-1',
|
||||
sender_name: 'owner',
|
||||
content:
|
||||
'라벨 좌측 클리핑 회귀 수정했습니다.\n[Image: screenshot.png → /tmp/bar-chart-label-fit-playwright.png]',
|
||||
timestamp: '2026-04-26T05:31:00.000Z',
|
||||
is_from_me: true,
|
||||
is_bot_message: true,
|
||||
message_source_kind: 'bot',
|
||||
};
|
||||
|
||||
const activity = buildWebDashboardRoomActivity({
|
||||
serviceId: 'codex-main',
|
||||
entry: roomEntry,
|
||||
pairedTask: null,
|
||||
turns: [],
|
||||
attempts: [],
|
||||
outputs: [],
|
||||
messages: [message],
|
||||
});
|
||||
|
||||
expect(activity.messages[0]).toMatchObject({
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { normalizeEjclawStructuredOutput } from './agent-protocol.js';
|
||||
import {
|
||||
extractImageTagPaths,
|
||||
normalizeEjclawStructuredOutput,
|
||||
} from './agent-protocol.js';
|
||||
import { isWatchCiTask } from './task-watch-status.js';
|
||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
||||
import type {
|
||||
NewMessage,
|
||||
OutboundAttachment,
|
||||
PairedTask,
|
||||
PairedTurnOutput,
|
||||
ScheduledTask,
|
||||
@@ -69,6 +73,7 @@ export interface WebDashboardRoomMessage {
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
attachments: OutboundAttachment[];
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
@@ -99,6 +104,7 @@ export interface WebDashboardRoomTurnOutput {
|
||||
verdict: PairedTurnOutput['verdict'] | null;
|
||||
createdAt: string;
|
||||
outputText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
}
|
||||
|
||||
export interface WebDashboardRoomActivity {
|
||||
@@ -196,22 +202,54 @@ function hasStructuredOutputHint(value: string): boolean {
|
||||
return value.includes('"ejclaw"') || /"ejclaw"/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeRoomMessageContent(value: string): string | null {
|
||||
if (!hasStructuredOutputHint(value)) return value;
|
||||
function imagePathsToAttachments(paths: string[]): OutboundAttachment[] {
|
||||
return paths.map((filePath) => ({
|
||||
path: filePath,
|
||||
name: filePath.split(/[\\/]/).at(-1) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function splitLegacyImageTags(
|
||||
text: string,
|
||||
attachments: OutboundAttachment[] = [],
|
||||
): { text: string; attachments: OutboundAttachment[] } {
|
||||
const extracted = extractImageTagPaths(text);
|
||||
if (extracted.imagePaths.length === 0) {
|
||||
return { text, attachments };
|
||||
}
|
||||
return {
|
||||
text: extracted.cleanText,
|
||||
attachments: [
|
||||
...attachments,
|
||||
...imagePathsToAttachments(extracted.imagePaths),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStructuredVisibleContent(value: string): {
|
||||
text: string | null;
|
||||
attachments: OutboundAttachment[];
|
||||
} {
|
||||
if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(value);
|
||||
|
||||
const candidates = [value, decodeCommonHtmlEntities(value)];
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeEjclawStructuredOutput(candidate);
|
||||
if (normalized.output?.visibility === 'silent') return null;
|
||||
if (normalized.output?.visibility === 'silent') {
|
||||
return { text: null, attachments: [] };
|
||||
}
|
||||
if (
|
||||
normalized.output?.visibility === 'public' &&
|
||||
normalized.output.text !== candidate
|
||||
) {
|
||||
return normalized.output.text;
|
||||
return splitLegacyImageTags(
|
||||
normalized.output.text,
|
||||
normalized.output.attachments ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
return splitLegacyImageTags(value);
|
||||
}
|
||||
|
||||
function stableHash(value: string): string {
|
||||
@@ -440,14 +478,15 @@ export function sanitizeScheduledTask(
|
||||
function sanitizeRoomMessage(
|
||||
message: NewMessage,
|
||||
): WebDashboardRoomMessage | null {
|
||||
const content = normalizeRoomMessageContent(message.content ?? '');
|
||||
if (content === null) return null;
|
||||
const normalized = normalizeStructuredVisibleContent(message.content ?? '');
|
||||
if (normalized.text === null) return null;
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
senderName: message.sender_name || message.sender,
|
||||
content: buildRoomPreview(content, ROOM_MESSAGE_PREVIEW_MAX_LENGTH),
|
||||
content: buildRoomPreview(normalized.text, ROOM_MESSAGE_PREVIEW_MAX_LENGTH),
|
||||
attachments: normalized.attachments,
|
||||
timestamp: message.timestamp,
|
||||
isFromMe: !!message.is_from_me,
|
||||
isBotMessage: !!message.is_bot_message,
|
||||
@@ -549,7 +588,10 @@ function sanitizeRoomTurn(
|
||||
|
||||
function sanitizeRoomTurnOutput(
|
||||
output: PairedTurnOutput,
|
||||
): WebDashboardRoomTurnOutput {
|
||||
): WebDashboardRoomTurnOutput | null {
|
||||
const normalized = normalizeStructuredVisibleContent(output.output_text);
|
||||
if (normalized.text === null) return null;
|
||||
|
||||
return {
|
||||
id: output.id,
|
||||
turnNumber: output.turn_number,
|
||||
@@ -557,9 +599,10 @@ function sanitizeRoomTurnOutput(
|
||||
verdict: output.verdict ?? null,
|
||||
createdAt: output.created_at,
|
||||
outputText: buildRoomPreview(
|
||||
output.output_text,
|
||||
normalized.text,
|
||||
ROOM_OUTPUT_PREVIEW_MAX_LENGTH,
|
||||
),
|
||||
attachments: normalized.attachments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -620,7 +663,12 @@ export function buildWebDashboardRoomActivity(args: {
|
||||
args.progressMessages ?? args.messages,
|
||||
)
|
||||
: null,
|
||||
outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput),
|
||||
outputs: args.outputs
|
||||
.slice(-outputLimit)
|
||||
.map(sanitizeRoomTurnOutput)
|
||||
.filter((output): output is WebDashboardRoomTurnOutput =>
|
||||
Boolean(output),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
buildWebDashboardOverview,
|
||||
sanitizeScheduledTask,
|
||||
} from './web-dashboard-data.js';
|
||||
import { serveValidatedAttachment } from './web-dashboard-attachments.js';
|
||||
import {
|
||||
addClaudeAccountFromToken,
|
||||
getActiveCodexSettingsIndex,
|
||||
@@ -1570,9 +1571,15 @@ export function createWebDashboardHandler(
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/health') {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
const simpleGetRoutes: Record<string, () => Response> = {
|
||||
'/api/health': () => jsonResponse({ ok: true }),
|
||||
'/api/status-snapshots': () =>
|
||||
jsonResponse(readSnapshots(statusMaxAgeMs)),
|
||||
'/api/tasks': () => jsonResponse(loadTasks().map(sanitizeScheduledTask)),
|
||||
'/api/attachments': () => serveValidatedAttachment(url),
|
||||
};
|
||||
const simpleGetRoute = simpleGetRoutes[url.pathname];
|
||||
if (simpleGetRoute) return simpleGetRoute();
|
||||
|
||||
if (url.pathname === '/api/overview') {
|
||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||
@@ -1593,14 +1600,6 @@ export function createWebDashboardHandler(
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/status-snapshots') {
|
||||
return jsonResponse(readSnapshots(statusMaxAgeMs));
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tasks') {
|
||||
return jsonResponse(loadTasks().map(sanitizeScheduledTask));
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings/accounts') {
|
||||
return jsonResponse({
|
||||
claude: listClaudeAccounts(),
|
||||
|
||||
Reference in New Issue
Block a user