Add structured Discord attachments

This commit is contained in:
ejclaw
2026-04-25 09:20:28 +09:00
parent bdaecb2552
commit 83b7aef494
25 changed files with 892 additions and 53 deletions

View File

@@ -1,7 +1,8 @@
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
export const IMAGE_TAG_RE =
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
export const IPC_POLL_MS = 500;
export const IPC_INPUT_SUBDIR = 'input';
@@ -21,11 +22,18 @@ export type RunnerOutputVerdict =
export type RunnerOutputVisibility = 'public' | 'silent';
export interface RunnerOutputAttachment {
path: string;
name?: string;
mime?: string;
}
export type RunnerStructuredOutput =
| {
visibility: 'public';
text: string;
verdict?: Exclude<RunnerOutputVerdict, 'silent'>;
attachments?: RunnerOutputAttachment[];
}
| {
visibility: 'silent';
@@ -89,6 +97,33 @@ function isVisibleVerdict(
);
}
function normalizeAttachments(value: unknown): RunnerOutputAttachment[] {
if (!Array.isArray(value)) return [];
const attachments: RunnerOutputAttachment[] = [];
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const candidate = item as {
path?: unknown;
name?: unknown;
mime?: unknown;
};
if (typeof candidate.path !== 'string' || candidate.path.length === 0) {
continue;
}
attachments.push({
path: candidate.path,
...(typeof candidate.name === 'string' && candidate.name.length > 0
? { name: candidate.name }
: {}),
...(typeof candidate.mime === 'string' && candidate.mime.length > 0
? { mime: candidate.mime }
: {}),
});
}
return attachments;
}
export function normalizeEjclawStructuredOutput(
result: string | null,
): NormalizedRunnerOutput {
@@ -99,7 +134,12 @@ export function normalizeEjclawStructuredOutput(
const trimmed = result.trim();
try {
const parsed = JSON.parse(trimmed) as {
ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
ejclaw?: {
visibility?: unknown;
text?: unknown;
verdict?: unknown;
attachments?: unknown;
};
};
const envelope = parsed?.ejclaw;
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
@@ -128,6 +168,7 @@ export function normalizeEjclawStructuredOutput(
) {
return normalizePublicTextOutput(result);
}
const attachments = normalizeAttachments(envelope.attachments);
return {
result: envelope.text,
output: {
@@ -136,6 +177,7 @@ export function normalizeEjclawStructuredOutput(
verdict: isVisibleVerdict(envelope.verdict)
? envelope.verdict
: undefined,
...(attachments.length > 0 ? { attachments } : {}),
},
};
}

View File

@@ -15,6 +15,7 @@ export {
writeProtocolOutput,
type NormalizedRunnerOutput,
type RunnerOutputPhase,
type RunnerOutputAttachment,
type RunnerOutputVerdict,
type RunnerOutputVisibility,
type RunnerStructuredOutput,

View File

@@ -13,6 +13,12 @@ describe('shared agent protocol helpers', () => {
cleanText: 'hello',
imagePaths: ['/tmp/a.png'],
});
expect(
extractImageTagPaths('hello [Image: screenshot.png → /tmp/a.png]'),
).toEqual({
cleanText: 'hello',
imagePaths: ['/tmp/a.png'],
});
expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({
cleanText: 'second',
imagePaths: ['/tmp/b.png'],
@@ -45,6 +51,41 @@ describe('shared agent protocol helpers', () => {
});
});
it('parses public ejclaw attachments', () => {
expect(
normalizeEjclawStructuredOutput(
JSON.stringify({
ejclaw: {
visibility: 'public',
text: '이미지를 생성했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/image.png',
name: 'image.png',
mime: 'image/png',
},
],
},
}),
),
).toEqual({
result: '이미지를 생성했습니다.',
output: {
visibility: 'public',
text: '이미지를 생성했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/image.png',
name: 'image.png',
mime: 'image/png',
},
],
},
});
});
it('falls back to visible raw text on invalid public verdicts', () => {
const raw = JSON.stringify({
ejclaw: {