Add structured Discord attachments
This commit is contained in:
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
|
||||
import {
|
||||
extractImageTagPaths,
|
||||
normalizePublicTextOutput,
|
||||
normalizeEjclawStructuredOutput,
|
||||
type RunnerStructuredOutput,
|
||||
writeProtocolOutput,
|
||||
} from 'ejclaw-runners-shared';
|
||||
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
|
||||
result: string | null;
|
||||
output?: RunnerOutput['output'];
|
||||
} {
|
||||
return normalizePublicTextOutput(result);
|
||||
return normalizeEjclawStructuredOutput(result);
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: unknown): string | null {
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
writeProtocolOutput,
|
||||
type NormalizedRunnerOutput,
|
||||
type RunnerOutputPhase,
|
||||
type RunnerOutputAttachment,
|
||||
type RunnerOutputVerdict,
|
||||
type RunnerOutputVisibility,
|
||||
type RunnerStructuredOutput,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user