mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
f6444f5d35
Bring the Slack adapter up to the current native streaming API surface
(chat.startStream/appendStream/stopStream, GA Oct 2025) and remove the
type-erasure workarounds.
- Remove `as unknown as Parameters<...>` casts in favor of the SDK's typed
args (ChatStartStream/AppendStream/StopStream/PostMessage/UpdateArguments).
- Stream a whole turn into ONE message: drop the per-message continuation
splitting (no documented cumulative cap; matches vercel/chat), keeping the
12k per-append chunking.
- Surface tool progress as native in-message `task_update` chunks
(task_display_mode "timeline"), degrading to `🔧` rows where
structured chunks are unavailable.
- Add opt-in AI feedback buttons via `slack({ feedback })` — a typed
context_actions/feedback_buttons row attached at stopStream, with clicks
routed adapter-locally (bypassing the engine's interaction dispatch).
- Scope recipient_user_id/recipient_team_id to channel targets only.
- Lower the native flush floor to ~600ms (appendStream Tier-4), legacy stays
800ms.
Engine: add an optional, backward-compatible RunRenderer.finish() hook called
after runAgentLoop so a turn-scoped renderer can finalize its stream.
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import type { BotNode } from "@copilotkit/bot-ui";
|
|
import type { ContextActionsBlock, KnownBlock } from "@slack/types";
|
|
import { markdownToMrkdwn } from "../markdown-to-mrkdwn.js";
|
|
import { SLACK_LIMITS, clampArray, truncateText } from "./budget.js";
|
|
|
|
/**
|
|
* Stable `action_id` of the native AI feedback row's `feedback_buttons`
|
|
* element. The adapter intercepts clicks on this id (routing them to the
|
|
* app's feedback callback) before they reach the engine's interaction
|
|
* dispatch — see `adapter.ts`.
|
|
*/
|
|
export const FEEDBACK_ACTION_ID = "ck-fb";
|
|
|
|
/**
|
|
* Build the native AI-feedback row (`context_actions` + `feedback_buttons`)
|
|
* attached to a finalized streamed reply via `chat.stopStream`'s `blocks`.
|
|
* The clicked button's `value` ("positive" / "negative") carries the sentiment.
|
|
*/
|
|
export function buildFeedbackBlocks(opts?: {
|
|
positiveLabel?: string;
|
|
negativeLabel?: string;
|
|
}): KnownBlock[] {
|
|
const block: ContextActionsBlock = {
|
|
type: "context_actions",
|
|
elements: [
|
|
{
|
|
type: "feedback_buttons",
|
|
action_id: FEEDBACK_ACTION_ID,
|
|
positive_button: {
|
|
text: {
|
|
type: "plain_text",
|
|
text: truncateText(
|
|
opts?.positiveLabel ?? "Good response",
|
|
SLACK_LIMITS.buttonText,
|
|
),
|
|
},
|
|
value: "positive",
|
|
},
|
|
negative_button: {
|
|
text: {
|
|
type: "plain_text",
|
|
text: truncateText(
|
|
opts?.negativeLabel ?? "Bad response",
|
|
SLACK_LIMITS.buttonText,
|
|
),
|
|
},
|
|
value: "negative",
|
|
},
|
|
},
|
|
],
|
|
};
|
|
return [block];
|
|
}
|
|
|
|
/**
|
|
* Render a cross-platform component IR tree (already expanded by `renderToIR`
|
|
* and pre-bound by the action registry, so event props are `{ id }`) into a
|
|
* Slack Block Kit `KnownBlock[]`.
|
|
*
|
|
* The renderer is total: unknown intrinsic types are skipped rather than
|
|
* throwing. Per-element Slack limits are applied via {@link truncateText} and
|
|
* {@link clampArray}; nothing is silently dropped — overflowing collections
|
|
* clamp and, at the top level, append an explicit overflow signal block.
|
|
*/
|
|
export function renderBlockKit(ir: BotNode[]): KnownBlock[] {
|
|
const blocks: KnownBlock[] = [];
|
|
for (const node of ir) {
|
|
renderNode(node, blocks);
|
|
}
|
|
|
|
// Top-level budget: clamp to the per-message block ceiling, leaving room for
|
|
// an overflow-signal context block when we had to drop anything.
|
|
const { items, overflow } = clampArray(blocks, SLACK_LIMITS.blocksPerMessage);
|
|
if (overflow <= 0) return items;
|
|
|
|
// Drop the last kept block to make room for the signal so we land at exactly
|
|
// the ceiling (49 kept + 1 signal = 50) instead of exceeding it.
|
|
const kept = items.slice(0, SLACK_LIMITS.blocksPerMessage - 1);
|
|
const dropped = overflow + 1;
|
|
kept.push(overflowSignal(dropped));
|
|
return kept;
|
|
}
|
|
|
|
/** Render IR to Slack blocks, extracting a top-level <Message accent="#hex"> color for an attachment wrapper. */
|
|
export function renderSlackMessage(ir: BotNode[]): {
|
|
blocks: KnownBlock[];
|
|
accent?: string;
|
|
} {
|
|
const blocks = renderBlockKit(ir);
|
|
// Top-level single <Message accent="..."> → use its accent as the attachment color.
|
|
if (ir.length === 1 && ir[0] && ir[0].type === "message") {
|
|
const accent = (ir[0].props as { accent?: unknown }).accent;
|
|
if (typeof accent === "string" && accent.length > 0)
|
|
return { blocks, accent };
|
|
}
|
|
return { blocks };
|
|
}
|
|
|
|
function overflowSignal(count: number): KnownBlock {
|
|
return {
|
|
type: "context",
|
|
elements: [{ type: "mrkdwn", text: `_…+${count} more blocks truncated_` }],
|
|
} as KnownBlock;
|
|
}
|
|
|
|
/** Render a single IR node, pushing zero or more blocks onto `out`. */
|
|
function renderNode(node: BotNode, out: KnownBlock[]): void {
|
|
if (typeof node.type !== "string") return; // non-intrinsic — already expanded away
|
|
const props = node.props ?? {};
|
|
switch (node.type) {
|
|
case "message": {
|
|
// The message container is not a block; flatten its children.
|
|
for (const child of childNodes(node)) renderNode(child, out);
|
|
return;
|
|
}
|
|
case "header": {
|
|
out.push({
|
|
type: "header",
|
|
text: {
|
|
type: "plain_text",
|
|
text: truncateText(collectText(node), SLACK_LIMITS.headerText),
|
|
},
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "section":
|
|
case "markdown": {
|
|
out.push({
|
|
type: "section",
|
|
text: {
|
|
type: "mrkdwn",
|
|
text: truncateText(
|
|
markdownToMrkdwn(collectText(node)),
|
|
SLACK_LIMITS.sectionText,
|
|
),
|
|
},
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "fields": {
|
|
const fieldChildren = childNodes(node).filter((c) => c.type === "field");
|
|
const { items } = clampArray(
|
|
fieldChildren,
|
|
SLACK_LIMITS.fieldsPerSection,
|
|
);
|
|
out.push({
|
|
type: "section",
|
|
fields: items.map((f) => ({
|
|
type: "mrkdwn",
|
|
text: truncateText(
|
|
markdownToMrkdwn(collectText(f)),
|
|
SLACK_LIMITS.fieldText,
|
|
),
|
|
})),
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "field": {
|
|
// Standalone field (rare) → single-field section.
|
|
out.push({
|
|
type: "section",
|
|
fields: [
|
|
{
|
|
type: "mrkdwn",
|
|
text: truncateText(
|
|
markdownToMrkdwn(collectText(node)),
|
|
SLACK_LIMITS.fieldText,
|
|
),
|
|
},
|
|
],
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "context": {
|
|
const { items } = clampArray(
|
|
childNodes(node),
|
|
SLACK_LIMITS.contextElements,
|
|
);
|
|
out.push({
|
|
type: "context",
|
|
elements: items.map((c) => ({
|
|
type: "mrkdwn",
|
|
text: markdownToMrkdwn(collectText(c)),
|
|
})),
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "actions": {
|
|
const { items } = clampArray(
|
|
childNodes(node),
|
|
SLACK_LIMITS.actionsElements,
|
|
);
|
|
const elements = items
|
|
.map(renderActionElement)
|
|
.filter((e): e is object => e !== null);
|
|
out.push({ type: "actions", elements } as KnownBlock);
|
|
return;
|
|
}
|
|
case "image": {
|
|
const url = (props.url ?? props.image_url) as string | undefined;
|
|
out.push({
|
|
type: "image",
|
|
image_url: url ?? "",
|
|
alt_text: (props.alt ?? props.altText ?? "") as string,
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "divider": {
|
|
out.push({ type: "divider" } as KnownBlock);
|
|
return;
|
|
}
|
|
case "input": {
|
|
out.push({
|
|
type: "input",
|
|
dispatch_action: true,
|
|
element: {
|
|
type: "plain_text_input",
|
|
action_id: truncateText(
|
|
idFromHandler(props.onSubmit) ?? "input",
|
|
SLACK_LIMITS.actionId,
|
|
),
|
|
multiline: !!props.multiline,
|
|
},
|
|
label: {
|
|
type: "plain_text",
|
|
text: truncateText(String(props.placeholder ?? " "), 150),
|
|
},
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "text": {
|
|
// Bare top-level text → a mrkdwn section.
|
|
const value = String(props.value ?? "");
|
|
out.push({
|
|
type: "section",
|
|
text: {
|
|
type: "mrkdwn",
|
|
text: truncateText(markdownToMrkdwn(value), SLACK_LIMITS.sectionText),
|
|
},
|
|
} as KnownBlock);
|
|
return;
|
|
}
|
|
case "table": {
|
|
// Native Slack Table block: rows of `{ type: "raw_text", text }` cells.
|
|
// Header row from `columns`, data rows from `row`/`cell` children.
|
|
// Not yet in `@slack/types`, so the block is built plain and cast.
|
|
const cellOf = (text: string): { type: "raw_text"; text: string } => ({
|
|
type: "raw_text",
|
|
text: truncateText(text, SLACK_LIMITS.cellText),
|
|
});
|
|
|
|
const columnsProp = props.columns as
|
|
| { header: string; align?: "left" | "center" | "right" }[]
|
|
| undefined;
|
|
const columns = columnsProp
|
|
? clampArray(columnsProp, SLACK_LIMITS.tableColumns).items
|
|
: undefined;
|
|
|
|
const rows: { type: "raw_text"; text: string }[][] = [];
|
|
if (columns && columns.length > 0) {
|
|
rows.push(columns.map((c) => cellOf(c.header)));
|
|
}
|
|
|
|
const rowNodes = childNodes(node).filter((c) => c.type === "row");
|
|
const { items: dataRows } = clampArray(rowNodes, SLACK_LIMITS.tableRows);
|
|
for (const rowNode of dataRows) {
|
|
const cells = childNodes(rowNode).filter((c) => c.type === "cell");
|
|
rows.push(cells.map((cell) => cellOf(collectText(cell))));
|
|
}
|
|
|
|
const block: Record<string, unknown> = { type: "table", rows };
|
|
if (columns) {
|
|
block.column_settings = columns.map((c) => ({
|
|
align: c.align ?? "left",
|
|
}));
|
|
}
|
|
out.push(block as unknown as KnownBlock);
|
|
return;
|
|
}
|
|
case "raw": {
|
|
const value = props.value;
|
|
const native = Array.isArray(value) ? value : [value];
|
|
for (const b of native) {
|
|
if (b != null) out.push(b as KnownBlock);
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
// Unknown intrinsic — skip silently (total renderer).
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render one interactive element inside an `actions` block. Returns `null` for
|
|
* children that aren't renderable as action elements (so callers can filter).
|
|
*/
|
|
function renderActionElement(node: BotNode): object | null {
|
|
if (typeof node.type !== "string") return null;
|
|
const props = node.props ?? {};
|
|
switch (node.type) {
|
|
case "button": {
|
|
const action_id = truncateText(
|
|
buttonActionId(props),
|
|
SLACK_LIMITS.actionId,
|
|
);
|
|
const el: Record<string, unknown> = {
|
|
type: "button",
|
|
action_id,
|
|
text: {
|
|
type: "plain_text",
|
|
text: truncateText(collectText(node), SLACK_LIMITS.buttonText),
|
|
},
|
|
};
|
|
if (props.value !== undefined) {
|
|
el.value = truncateText(
|
|
JSON.stringify(props.value),
|
|
SLACK_LIMITS.buttonValue,
|
|
);
|
|
}
|
|
if (props.style === "primary" || props.style === "danger") {
|
|
el.style = props.style;
|
|
}
|
|
return el;
|
|
}
|
|
case "select": {
|
|
const action_id = truncateText(
|
|
idFromHandler(props.onSelect) ?? "select",
|
|
SLACK_LIMITS.actionId,
|
|
);
|
|
const options =
|
|
(props.options as { label: string; value: unknown }[] | undefined) ??
|
|
[];
|
|
const { items } = clampArray(options, SLACK_LIMITS.selectOptions);
|
|
const el: Record<string, unknown> = {
|
|
type: "static_select",
|
|
action_id,
|
|
placeholder: {
|
|
type: "plain_text",
|
|
text: String(props.placeholder ?? " "),
|
|
},
|
|
options: items.map((o) => ({
|
|
text: { type: "plain_text", text: truncateText(o.label, 75) },
|
|
value: truncateText(String(o.value), 150),
|
|
})),
|
|
};
|
|
return el;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Derive a button's `action_id`: prefer the registry-stamped id, else a stable fallback. */
|
|
function buttonActionId(props: Record<string, unknown>): string {
|
|
const fromHandler = idFromHandler(props.onClick);
|
|
if (fromHandler) return fromHandler;
|
|
return props.value !== undefined ? JSON.stringify(props.value) : "action";
|
|
}
|
|
|
|
/** Extract `{ id }` stamped onto an event prop by the action registry, if present. */
|
|
function idFromHandler(handler: unknown): string | undefined {
|
|
if (handler && typeof handler === "object" && "id" in handler) {
|
|
const id = (handler as { id?: unknown }).id;
|
|
if (typeof id === "string") return id;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** The expanded `children` of an IR node as an `BotNode[]` (empty if none). */
|
|
function childNodes(node: BotNode): BotNode[] {
|
|
const children = node.props?.children;
|
|
if (Array.isArray(children)) return children as BotNode[];
|
|
if (
|
|
children &&
|
|
typeof children === "object" &&
|
|
"type" in (children as object)
|
|
) {
|
|
return [children as BotNode];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** Concatenate the `value` of all descendant `text` nodes (depth-first). */
|
|
function collectText(node: BotNode): string {
|
|
if (typeof node.type === "string" && node.type === "text") {
|
|
return String(node.props?.value ?? "");
|
|
}
|
|
let acc = "";
|
|
for (const child of childNodes(node)) {
|
|
acc += collectText(child);
|
|
}
|
|
return acc;
|
|
}
|