mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 14:20:45 +08:00
feat: add native image delivery across channels
This commit is contained in:
parent
dbc0a4b730
commit
3ec5f1eca7
31 changed files with 2754 additions and 846 deletions
170
src/channels/shared/semantic/artifact-delivery.mjs
Normal file
170
src/channels/shared/semantic/artifact-delivery.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue