mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 02:08:27 +00:00
feat(slack): add configurable stream modes
This commit is contained in:
@@ -11,6 +11,11 @@ import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||
import { removeSlackReaction } from "../../actions.js";
|
||||
import { createSlackDraftStream } from "../../draft-stream.js";
|
||||
import {
|
||||
applyAppendOnlyStreamUpdate,
|
||||
buildStatusFinalPreviewText,
|
||||
resolveSlackStreamMode,
|
||||
} from "../../stream-mode.js";
|
||||
import { resolveSlackThreadTargets } from "../../threading.js";
|
||||
import { createSlackReplyDeliveryPlan, deliverReplies } from "../replies.js";
|
||||
|
||||
@@ -112,6 +117,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
const draftChannelId = draftStream?.channelId();
|
||||
const finalText = payload.text;
|
||||
const canFinalizeViaPreviewEdit =
|
||||
streamMode !== "status_final" &&
|
||||
mediaCount === 0 &&
|
||||
!payload.isError &&
|
||||
typeof finalText === "string" &&
|
||||
@@ -134,6 +140,21 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
`slack: preview final edit failed; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
}
|
||||
} else if (streamMode === "status_final" && hasStreamedMessage) {
|
||||
try {
|
||||
const statusChannelId = draftStream?.channelId();
|
||||
const statusMessageId = draftStream?.messageId();
|
||||
if (statusChannelId && statusMessageId) {
|
||||
await ctx.app.client.chat.update({
|
||||
token: ctx.botToken,
|
||||
channel: statusChannelId,
|
||||
ts: statusMessageId,
|
||||
text: "Status: complete. Final answer posted below.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`slack: status_final completion update failed (${String(err)})`);
|
||||
}
|
||||
} else if (mediaCount > 0) {
|
||||
await draftStream?.clear();
|
||||
hasStreamedMessage = false;
|
||||
@@ -170,11 +191,42 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
warn: logVerbose,
|
||||
});
|
||||
let hasStreamedMessage = false;
|
||||
const streamMode = resolveSlackStreamMode(account.config.streamMode);
|
||||
let appendRenderedText = "";
|
||||
let appendSourceText = "";
|
||||
let statusUpdateCount = 0;
|
||||
const updateDraftFromPartial = (text?: string) => {
|
||||
const trimmed = text?.trimEnd();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamMode === "append") {
|
||||
const next = applyAppendOnlyStreamUpdate({
|
||||
incoming: trimmed,
|
||||
rendered: appendRenderedText,
|
||||
source: appendSourceText,
|
||||
});
|
||||
appendRenderedText = next.rendered;
|
||||
appendSourceText = next.source;
|
||||
if (!next.changed) {
|
||||
return;
|
||||
}
|
||||
draftStream.update(next.rendered);
|
||||
hasStreamedMessage = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamMode === "status_final") {
|
||||
statusUpdateCount += 1;
|
||||
if (statusUpdateCount > 1 && statusUpdateCount % 4 !== 0) {
|
||||
return;
|
||||
}
|
||||
draftStream.update(buildStatusFinalPreviewText(statusUpdateCount));
|
||||
hasStreamedMessage = true;
|
||||
return;
|
||||
}
|
||||
|
||||
draftStream.update(trimmed);
|
||||
hasStreamedMessage = true;
|
||||
};
|
||||
@@ -199,12 +251,18 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
if (hasStreamedMessage) {
|
||||
draftStream.forceNewMessage();
|
||||
hasStreamedMessage = false;
|
||||
appendRenderedText = "";
|
||||
appendSourceText = "";
|
||||
statusUpdateCount = 0;
|
||||
}
|
||||
},
|
||||
onReasoningEnd: async () => {
|
||||
if (hasStreamedMessage) {
|
||||
draftStream.forceNewMessage();
|
||||
hasStreamedMessage = false;
|
||||
appendRenderedText = "";
|
||||
appendSourceText = "";
|
||||
statusUpdateCount = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
78
src/slack/stream-mode.test.ts
Normal file
78
src/slack/stream-mode.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyAppendOnlyStreamUpdate,
|
||||
buildStatusFinalPreviewText,
|
||||
resolveSlackStreamMode,
|
||||
} from "./stream-mode.js";
|
||||
|
||||
describe("resolveSlackStreamMode", () => {
|
||||
it("defaults to replace", () => {
|
||||
expect(resolveSlackStreamMode(undefined)).toBe("replace");
|
||||
expect(resolveSlackStreamMode("")).toBe("replace");
|
||||
expect(resolveSlackStreamMode("unknown")).toBe("replace");
|
||||
});
|
||||
|
||||
it("accepts valid modes", () => {
|
||||
expect(resolveSlackStreamMode("replace")).toBe("replace");
|
||||
expect(resolveSlackStreamMode("status_final")).toBe("status_final");
|
||||
expect(resolveSlackStreamMode("append")).toBe("append");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAppendOnlyStreamUpdate", () => {
|
||||
it("starts with first incoming text", () => {
|
||||
const next = applyAppendOnlyStreamUpdate({
|
||||
incoming: "hello",
|
||||
rendered: "",
|
||||
source: "",
|
||||
});
|
||||
expect(next).toEqual({ rendered: "hello", source: "hello", changed: true });
|
||||
});
|
||||
|
||||
it("uses cumulative incoming text when it extends prior source", () => {
|
||||
const next = applyAppendOnlyStreamUpdate({
|
||||
incoming: "hello world",
|
||||
rendered: "hello",
|
||||
source: "hello",
|
||||
});
|
||||
expect(next).toEqual({
|
||||
rendered: "hello world",
|
||||
source: "hello world",
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores regressive shorter incoming text", () => {
|
||||
const next = applyAppendOnlyStreamUpdate({
|
||||
incoming: "hello",
|
||||
rendered: "hello world",
|
||||
source: "hello world",
|
||||
});
|
||||
expect(next).toEqual({
|
||||
rendered: "hello world",
|
||||
source: "hello world",
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("appends non-prefix incoming chunks", () => {
|
||||
const next = applyAppendOnlyStreamUpdate({
|
||||
incoming: "next chunk",
|
||||
rendered: "hello world",
|
||||
source: "hello world",
|
||||
});
|
||||
expect(next).toEqual({
|
||||
rendered: "hello world\nnext chunk",
|
||||
source: "next chunk",
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStatusFinalPreviewText", () => {
|
||||
it("cycles status dots", () => {
|
||||
expect(buildStatusFinalPreviewText(1)).toBe("Status: thinking..");
|
||||
expect(buildStatusFinalPreviewText(2)).toBe("Status: thinking...");
|
||||
expect(buildStatusFinalPreviewText(3)).toBe("Status: thinking.");
|
||||
});
|
||||
});
|
||||
53
src/slack/stream-mode.ts
Normal file
53
src/slack/stream-mode.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type SlackStreamMode = "replace" | "status_final" | "append";
|
||||
|
||||
const DEFAULT_STREAM_MODE: SlackStreamMode = "replace";
|
||||
|
||||
export function resolveSlackStreamMode(raw: unknown): SlackStreamMode {
|
||||
if (typeof raw !== "string") {
|
||||
return DEFAULT_STREAM_MODE;
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "replace" || normalized === "status_final" || normalized === "append") {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_STREAM_MODE;
|
||||
}
|
||||
|
||||
export function applyAppendOnlyStreamUpdate(params: {
|
||||
incoming: string;
|
||||
rendered: string;
|
||||
source: string;
|
||||
}): { rendered: string; source: string; changed: boolean } {
|
||||
const incoming = params.incoming.trimEnd();
|
||||
if (!incoming) {
|
||||
return { rendered: params.rendered, source: params.source, changed: false };
|
||||
}
|
||||
if (!params.rendered) {
|
||||
return { rendered: incoming, source: incoming, changed: true };
|
||||
}
|
||||
if (incoming === params.source) {
|
||||
return { rendered: params.rendered, source: params.source, changed: false };
|
||||
}
|
||||
|
||||
// Typical model partials are cumulative prefixes.
|
||||
if (incoming.startsWith(params.source) || incoming.startsWith(params.rendered)) {
|
||||
return { rendered: incoming, source: incoming, changed: incoming !== params.rendered };
|
||||
}
|
||||
|
||||
// Ignore regressive shorter variants of the same stream.
|
||||
if (params.source.startsWith(incoming)) {
|
||||
return { rendered: params.rendered, source: params.source, changed: false };
|
||||
}
|
||||
|
||||
const separator = params.rendered.endsWith("\n") ? "" : "\n";
|
||||
return {
|
||||
rendered: `${params.rendered}${separator}${incoming}`,
|
||||
source: incoming,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStatusFinalPreviewText(updateCount: number): string {
|
||||
const dots = ".".repeat((Math.max(1, updateCount) % 3) + 1);
|
||||
return `Status: thinking${dots}`;
|
||||
}
|
||||
Reference in New Issue
Block a user