Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

324 lines
7.8 KiB
TypeScript

/**
* Streams an in-progress agent reply to Discord by editing a single message as
* draft chunks arrive, using the draft-chunking break logic.
*/
import type {
ActionRowBuilder,
Message as DiscordMessage,
MessageActionRowComponentBuilder,
TextChannel,
} from "discord.js";
import {
DEFAULT_DRAFT_CHUNK_CONFIG,
type DraftChunkConfig,
findBreakPoint,
} from "./draft-chunking";
type DraftReplyToMode = "off" | "first" | "all";
export interface DraftStreamOptions {
throttleMs?: number;
minInitialChars?: number;
maxChars?: number;
chunkConfig?: Partial<DraftChunkConfig>;
log?: (msg: string) => void;
warn?: (msg: string) => void;
}
export interface DraftStreamController {
start: (
channel: TextChannel,
replyToMessageId?: string,
replyToMode?: DraftReplyToMode,
) => Promise<DiscordMessage | null>;
update: (text: string) => void;
finalize: (
text: string,
components?: ActionRowBuilder<MessageActionRowComponentBuilder>[],
) => Promise<DiscordMessage[]>;
abort: (reason?: string) => Promise<void>;
messageId: () => string | undefined;
isStarted: () => boolean;
isDone: () => boolean;
}
const DEFAULT_THROTTLE_MS = 1_200;
const DEFAULT_MIN_INITIAL_CHARS = 40;
const DISCORD_MAX_CHARS = 2_000;
export function createDraftStreamController(
options: DraftStreamOptions = {},
): DraftStreamController {
const throttleMs = Math.max(250, options.throttleMs ?? DEFAULT_THROTTLE_MS);
const minInitialChars = options.minInitialChars ?? DEFAULT_MIN_INITIAL_CHARS;
const maxChars = Math.min(options.maxChars ?? 1900, DISCORD_MAX_CHARS);
const log = options.log ?? (() => {});
const warn = options.warn ?? (() => {});
let channel: TextChannel | null = null;
let draftReplyToMessageId: string | undefined;
let draftReplyToMode: DraftReplyToMode = "first";
let lastSentText = "";
let lastSentMessage: DiscordMessage | null = null;
const sentMessages: DiscordMessage[] = [];
let pendingText: string | null = null;
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let started = false;
let done = false;
const clearThrottle = () => {
if (throttleTimer) {
clearTimeout(throttleTimer);
throttleTimer = null;
}
};
const sendSnapshot = async (
text: string,
components?: ActionRowBuilder<MessageActionRowComponentBuilder>[],
): Promise<boolean> => {
if (done || !channel) {
return false;
}
const trimmed = text.trimEnd();
if (!trimmed) {
return false;
}
const displayText =
trimmed.length > maxChars
? `${trimmed.slice(0, maxChars - 3)}...`
: trimmed;
if (displayText === lastSentText) {
if (components && components.length > 0 && lastSentMessage) {
try {
const edited = await lastSentMessage.edit({
content: displayText,
components,
});
lastSentMessage = edited;
const lastIndex = sentMessages.length - 1;
if (lastIndex >= 0) {
sentMessages[lastIndex] = edited;
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
warn(`draft-stream: final component edit failed: ${errorMessage}`);
try {
const sent = await channel.send({
content: displayText,
components,
...(draftReplyToMessageId && draftReplyToMode !== "off"
? {
reply: { messageReference: draftReplyToMessageId },
}
: {}),
});
lastSentMessage = sent;
sentMessages.push(sent);
} catch (sendError) {
const sendErrorMessage =
sendError instanceof Error
? sendError.message
: String(sendError);
warn(
`draft-stream: final component resend failed: ${sendErrorMessage}`,
);
return false;
}
}
}
return true;
}
try {
const sent = await channel.send({
content: displayText,
...(components && components.length > 0 ? { components } : {}),
...(draftReplyToMessageId && draftReplyToMode !== "off"
? {
reply: { messageReference: draftReplyToMessageId },
}
: {}),
});
lastSentMessage = sent;
sentMessages.push(sent);
lastSentText = displayText;
return true;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
warn(`draft-stream: send failed: ${errorMessage}`);
return false;
}
};
const flush = async (): Promise<void> => {
clearThrottle();
if (pendingText !== null) {
const text = pendingText;
pendingText = null;
await sendSnapshot(text);
}
};
const scheduleUpdate = (text: string) => {
pendingText = text;
if (!throttleTimer) {
throttleTimer = setTimeout(async () => {
throttleTimer = null;
await flush();
}, throttleMs);
}
};
const start = async (
nextChannel: TextChannel,
replyToMessageId?: string,
replyToMode: DraftReplyToMode = "first",
): Promise<DiscordMessage | null> => {
if (started) {
warn("draft-stream: start() called twice, ignoring");
return lastSentMessage;
}
started = true;
channel = nextChannel;
draftReplyToMessageId = replyToMessageId;
draftReplyToMode = replyToMode;
log(`draft-stream: started (append-only, throttle=${throttleMs}ms)`);
return null;
};
const update = (text: string) => {
if (done || !started) {
return;
}
if (!lastSentMessage && text.length < minInitialChars) {
return;
}
scheduleUpdate(text);
};
const finalize = async (
text: string,
components?: ActionRowBuilder<MessageActionRowComponentBuilder>[],
): Promise<DiscordMessage[]> => {
if (done) {
return sentMessages;
}
clearThrottle();
pendingText = null;
if (!started) {
warn("draft-stream: finalize called before start");
done = true;
return [];
}
const trimmed = text.trimEnd();
if (!trimmed) {
done = true;
return [];
}
if (trimmed.length <= maxChars) {
await sendSnapshot(trimmed, components);
done = true;
log("draft-stream: finalized (single message)");
return sentMessages;
}
const chunkConfig = {
...DEFAULT_DRAFT_CHUNK_CONFIG,
...options.chunkConfig,
};
const breakPoint = findBreakPoint(
trimmed,
maxChars,
chunkConfig.breakPreference,
);
const firstChunk = trimmed.slice(0, breakPoint).trimEnd();
let remaining = trimmed.slice(breakPoint).trimStart();
await sendSnapshot(firstChunk);
while (remaining.length > 0 && channel) {
const nextBreak = findBreakPoint(
remaining,
maxChars,
chunkConfig.breakPreference,
);
const chunk = remaining.slice(0, nextBreak).trimEnd();
remaining = remaining.slice(nextBreak).trimStart();
if (!chunk) {
continue;
}
try {
const isLastChunk = remaining.length === 0;
const overflowMessage = await channel.send({
content: chunk,
...(isLastChunk && components && components.length > 0
? { components }
: {}),
...(draftReplyToMessageId && draftReplyToMode === "all"
? {
reply: { messageReference: draftReplyToMessageId },
}
: {}),
});
lastSentMessage = overflowMessage;
sentMessages.push(overflowMessage);
} catch (error) {
warn(
`draft-stream: overflow send failed: ${error instanceof Error ? error.message : String(error)}`,
);
break;
}
}
done = true;
log("draft-stream: finalized (multi-message)");
return sentMessages;
};
const abort = async (reason?: string): Promise<void> => {
if (done) {
return;
}
done = true;
clearThrottle();
pendingText = null;
if (!channel) {
return;
}
const errorText = reason
? `⚠️ ${reason}`
: "⚠️ Response generation was interrupted.";
try {
await channel.send({ content: errorText });
} catch (error) {
warn(
`draft-stream: abort send failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
log("draft-stream: aborted");
};
return {
start,
update,
finalize,
abort,
messageId: () => lastSentMessage?.id,
isStarted: () => started,
isDone: () => done,
};
}