feat: add native image delivery across channels

This commit is contained in:
xmanrui 2026-08-24 03:25:38 +08:00
parent dbc0a4b730
commit 3ec5f1eca7
31 changed files with 2754 additions and 846 deletions

View file

@ -0,0 +1,170 @@
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from './artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from './delivery.mjs';
function unavailableError() {
const error = new Error('Native file delivery is unavailable');
error.code = 'artifact-provider-unavailable';
return error;
}
function isAbort(error, signal) {
return signal?.aborted
|| error?.name === 'AbortError'
|| error?.code === 'ABORT_ERR';
}
function providerIds(value) {
if (Array.isArray(value)) {
return [...new Set(value
.filter((candidate) => (
(typeof candidate === 'string' && candidate.trim())
|| Number.isSafeInteger(candidate)
))
.map(String))];
}
return providerMessageIdsFor(value);
}
async function sendMaterializedArtifact(file, {
sendFile,
sendImage,
signal,
}) {
if (file.mediaType?.startsWith('image/') && typeof sendImage === 'function') {
try {
return {
presentation: 'image',
result: await sendImage(file),
};
} catch (error) {
if (isAbort(error, signal) || error?.code === 'artifact-delivery-uncertain') {
throw error;
}
}
}
signal?.throwIfAborted();
if (typeof sendFile !== 'function') throw unavailableError();
return {
presentation: 'file',
result: await sendFile(file),
};
}
/**
* Deliver registered artifacts with one shared image-first policy while keeping
* provider protocol details inside the channel-supplied send closures.
*/
export async function deliverOutboundArtifacts({
artifacts = [],
baseReceipt = null,
deliveryId,
aggregatePresentation,
alwaysMerge = false,
channelKey,
signal,
sendFile,
sendImage,
sendFailureNotice,
logger,
}) {
const receipts = baseReceipt ? [baseReceipt] : [];
let userVisible = Boolean(baseReceipt);
let failureNoticeVisible = false;
let artifactsSent = 0;
let artifactSendErrors = 0;
let artifactIndex = 0;
try {
while (artifactIndex < artifacts.length) {
const artifact = artifacts[artifactIndex];
artifactIndex += 1;
try {
signal?.throwIfAborted();
const file = await materializeOutboundArtifact(artifact, { signal });
signal?.throwIfAborted();
const sent = await sendMaterializedArtifact(file, {
sendFile,
sendImage,
signal,
});
signal?.throwIfAborted();
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: `${channelKey}-${sent.presentation}`,
providerMessageIds: providerIds(sent.result),
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
artifactsSent += 1;
userVisible = true;
} catch (error) {
if (isAbort(error, signal)) throw error;
artifactSendErrors += 1;
logger?.warn?.(
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
);
let messageIds = [];
if (typeof sendFailureNotice === 'function') {
try {
signal?.throwIfAborted();
const notice = await sendFailureNotice(artifact, error);
signal?.throwIfAborted();
messageIds = providerIds(notice);
failureNoticeVisible = true;
} catch (noticeError) {
if (isAbort(noticeError, signal)) throw noticeError;
logger?.warn?.(
`[dsh-im:${channelKey}] unable to send the safe artifact failure notice`,
);
}
}
const failureReceipt = createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds: messageIds,
});
receipts.push(failureReceipt);
if (failureNoticeVisible || failureReceipt.artifacts[0]?.outcome === 'unknown') {
userVisible = true;
}
} finally {
releaseOutboundArtifact(artifact);
}
}
} finally {
while (artifactIndex < artifacts.length) {
releaseOutboundArtifact(artifacts[artifactIndex]);
artifactIndex += 1;
}
}
let receipt = null;
if (receipts.length === 1 && !alwaysMerge) {
[receipt] = receipts;
} else if (receipts.length > 0) {
receipt = mergeDeliveryReceipts({
deliveryId: deliveryId
?? baseReceipt?.deliveryId
?? artifacts[0]?.deliveryKey,
presentation: aggregatePresentation
?? `${channelKey}-${baseReceipt ? 'text-and-files' : 'files'}`,
receipts,
});
}
return {
receipt,
userVisible,
failureNoticeVisible,
artifactsSent,
artifactSendErrors,
};
}

View file

@ -576,7 +576,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
};
const definition = Object.freeze({
name: OUTBOUND_ARTIFACT_TOOL,
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
parameters: {
type: 'object',
additionalProperties: false,
@ -654,7 +654,7 @@ export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRe
ctx.systemPrompt.section({
name: 'dsh-im:return-file',
order: 115,
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
});
return true;
}

View file

@ -34,6 +34,8 @@ export function providerMessageIdsFor(value) {
value.message?.ts,
value.key?.id,
value.data?.message_id,
value.body?.msgid,
value.body?.message_id,
];
const id = candidates.find((candidate) => (
(typeof candidate === 'string' && candidate.trim())

View file

@ -32,14 +32,9 @@ import {
harnessQuestionText,
validHarnessQuestion,
} from './harness-question.mjs';
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from './semantic/artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from './semantic/delivery.mjs';
@ -339,73 +334,29 @@ export class TextHarnessBridge {
}
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
const receipts = baseReceipt ? [baseReceipt] : [];
let userVisible = Boolean(baseReceipt);
for (const artifact of artifacts) {
this.#signal?.throwIfAborted();
try {
if (typeof this.#bot.sendFile !== 'function') {
const unavailable = new Error('Native file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
const file = await materializeOutboundArtifact(artifact, {
signal: this.#signal,
});
this.#signal?.throwIfAborted();
const result = await this.#bot.sendFile(target, file);
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: `${this.#descriptor.key}-file`,
providerMessageIds: providerMessageIdsFor(result),
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
userVisible = true;
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
} catch (error) {
if (this.#signal?.aborted) throw error;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] result file delivery failed (${error?.code ?? 'unknown'})`,
);
let providerMessageIds = [];
let noticeSent = false;
try {
const notice = await this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
);
providerMessageIds = providerMessageIdsFor(notice);
noticeSent = true;
} catch {
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] unable to send the safe result-file failure notice`,
);
}
const failureReceipt = createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds,
});
receipts.push(failureReceipt);
if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
} finally {
releaseOutboundArtifact(artifact);
}
}
const receipt = receipts.length === 0
? null
: receipts.length === 1
? receipts[0]
: mergeDeliveryReceipts({
deliveryId: replyTo,
presentation: baseReceipt
? `${this.#descriptor.key}-text-and-files`
: `${this.#descriptor.key}-files`,
receipts,
});
return { receipt, userVisible };
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
channelKey: this.#descriptor.key,
signal: this.#signal,
sendImage: typeof this.#bot.sendImage === 'function'
? (file) => this.#bot.sendImage(target, file)
: undefined,
sendFile: typeof this.#bot.sendFile === 'function'
? (file) => this.#bot.sendFile(target, file)
: undefined,
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
}
async #process(message, messageId, senderId, conversationKey, {