refactor: centralize typing mode signals
This commit is contained in:
@@ -71,3 +71,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when
|
|||||||
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
||||||
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
||||||
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.clawdbot/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
||||||
|
- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned).
|
||||||
|
|||||||
@@ -39,10 +39,11 @@ Order of “how early it fires”:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
You can override the refresh cadence per session:
|
You can override mode or cadence per session:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
session: {
|
session: {
|
||||||
|
typingMode: "message",
|
||||||
typingIntervalSeconds: 4
|
typingIntervalSeconds: 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,8 +52,8 @@ You can override the refresh cadence per session:
|
|||||||
## Notes
|
## Notes
|
||||||
- `message` mode won’t show typing for silent-only replies (e.g. the `NO_REPLY`
|
- `message` mode won’t show typing for silent-only replies (e.g. the `NO_REPLY`
|
||||||
token used to suppress output).
|
token used to suppress output).
|
||||||
- `thinking` only fires if the run streams reasoning; if the model doesn’t emit
|
- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
|
||||||
reasoning deltas, typing won’t start.
|
If the model doesn’t emit reasoning deltas, typing won’t start.
|
||||||
- Heartbeats never show typing, regardless of mode.
|
- Heartbeats never show typing, regardless of mode.
|
||||||
- `typingIntervalSeconds` controls the **refresh cadence**, not the start time.
|
- `typingIntervalSeconds` controls the **refresh cadence**, not the start time.
|
||||||
The default is 6 seconds.
|
The default is 6 seconds.
|
||||||
|
|||||||
@@ -996,6 +996,7 @@ See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
|
|||||||
Typing indicators:
|
Typing indicators:
|
||||||
- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to
|
- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to
|
||||||
`instant` for direct chats / mentions and `message` for unmentioned group chats.
|
`instant` for direct chats / mentions and `message` for unmentioned group chats.
|
||||||
|
- `session.typingMode`: per-session override for the mode.
|
||||||
- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s).
|
- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s).
|
||||||
- `session.typingIntervalSeconds`: per-session override for the refresh interval.
|
- `session.typingIntervalSeconds`: per-session override for the refresh interval.
|
||||||
See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details.
|
See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details.
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ import {
|
|||||||
} from "./reply/session-updates.js";
|
} from "./reply/session-updates.js";
|
||||||
import { createTypingController } from "./reply/typing.js";
|
import { createTypingController } from "./reply/typing.js";
|
||||||
import {
|
import {
|
||||||
|
createTypingSignaler,
|
||||||
resolveTypingMode,
|
resolveTypingMode,
|
||||||
shouldStartTypingImmediately,
|
|
||||||
} from "./reply/typing-mode.js";
|
} from "./reply/typing-mode.js";
|
||||||
import type { MsgContext, TemplateContext } from "./templating.js";
|
import type { MsgContext, TemplateContext } from "./templating.js";
|
||||||
import {
|
import {
|
||||||
@@ -599,12 +599,16 @@ export async function getReplyFromConfig(
|
|||||||
const wasMentioned = ctx.WasMentioned === true;
|
const wasMentioned = ctx.WasMentioned === true;
|
||||||
const isHeartbeat = opts?.isHeartbeat === true;
|
const isHeartbeat = opts?.isHeartbeat === true;
|
||||||
const typingMode = resolveTypingMode({
|
const typingMode = resolveTypingMode({
|
||||||
configured: agentCfg?.typingMode,
|
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
wasMentioned,
|
wasMentioned,
|
||||||
isHeartbeat,
|
isHeartbeat,
|
||||||
});
|
});
|
||||||
const shouldEagerType = shouldStartTypingImmediately(typingMode);
|
const typingSignals = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: typingMode,
|
||||||
|
isHeartbeat,
|
||||||
|
});
|
||||||
const shouldInjectGroupIntro = Boolean(
|
const shouldInjectGroupIntro = Boolean(
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||||
@@ -798,8 +802,8 @@ export async function getReplyFromConfig(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shouldEagerType) {
|
if (typingSignals.shouldStartImmediately) {
|
||||||
await typing.startTypingLoop();
|
await typingSignals.signalRunStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
return runReplyAgent({
|
return runReplyAgent({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { TypingMode } from "../../config/types.js";
|
|||||||
import type { TemplateContext } from "../templating.js";
|
import type { TemplateContext } from "../templating.js";
|
||||||
import type { GetReplyOptions } from "../types.js";
|
import type { GetReplyOptions } from "../types.js";
|
||||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
const runEmbeddedPiAgentMock = vi.fn();
|
const runEmbeddedPiAgentMock = vi.fn();
|
||||||
|
|
||||||
@@ -46,18 +46,6 @@ vi.mock("./queue.js", async () => {
|
|||||||
|
|
||||||
import { runReplyAgent } from "./agent-runner.js";
|
import { runReplyAgent } from "./agent-runner.js";
|
||||||
|
|
||||||
function createTyping(): TypingController {
|
|
||||||
return {
|
|
||||||
onReplyStart: vi.fn(async () => {}),
|
|
||||||
startTypingLoop: vi.fn(async () => {}),
|
|
||||||
startTypingOnText: vi.fn(async () => {}),
|
|
||||||
refreshTypingTtl: vi.fn(),
|
|
||||||
markRunComplete: vi.fn(),
|
|
||||||
markDispatchIdle: vi.fn(),
|
|
||||||
cleanup: vi.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmbeddedPiAgentParams = {
|
type EmbeddedPiAgentParams = {
|
||||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
@@ -71,7 +59,7 @@ function createMinimalRun(params?: {
|
|||||||
storePath?: string;
|
storePath?: string;
|
||||||
typingMode?: TypingMode;
|
typingMode?: TypingMode;
|
||||||
}) {
|
}) {
|
||||||
const typing = createTyping();
|
const typing = createMockTypingController();
|
||||||
const opts = params?.opts;
|
const opts = params?.opts;
|
||||||
const sessionCtx = {
|
const sessionCtx = {
|
||||||
Provider: "whatsapp",
|
Provider: "whatsapp",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { extractReplyToTag } from "./reply-tags.js";
|
import { extractReplyToTag } from "./reply-tags.js";
|
||||||
import { incrementCompactionCount } from "./session-updates.js";
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
|
import { createTypingSignaler } from "./typing-mode.js";
|
||||||
|
|
||||||
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
||||||
|
|
||||||
@@ -107,26 +108,11 @@ export async function runReplyAgent(params: {
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const isHeartbeat = opts?.isHeartbeat === true;
|
const isHeartbeat = opts?.isHeartbeat === true;
|
||||||
const shouldStartTypingOnText =
|
const typingSignals = createTypingSignaler({
|
||||||
typingMode === "message" || typingMode === "instant";
|
typing,
|
||||||
const shouldStartTypingOnReasoning = typingMode === "thinking";
|
mode: typingMode,
|
||||||
|
isHeartbeat,
|
||||||
const signalTypingFromText = async (text?: string) => {
|
});
|
||||||
if (isHeartbeat || typingMode === "never") return;
|
|
||||||
if (shouldStartTypingOnText) {
|
|
||||||
await typing.startTypingOnText(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (shouldStartTypingOnReasoning) {
|
|
||||||
typing.refreshTypingTtl();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const signalTypingFromReasoning = async () => {
|
|
||||||
if (isHeartbeat || !shouldStartTypingOnReasoning) return;
|
|
||||||
await typing.startTypingLoop();
|
|
||||||
typing.refreshTypingTtl();
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldEmitToolResult = () => {
|
const shouldEmitToolResult = () => {
|
||||||
if (!sessionKey || !storePath) {
|
if (!sessionKey || !storePath) {
|
||||||
@@ -276,7 +262,7 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
await signalTypingFromText(text);
|
await typingSignals.signalTextDelta(text);
|
||||||
await opts.onPartialReply?.({
|
await opts.onPartialReply?.({
|
||||||
text,
|
text,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
@@ -284,9 +270,9 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onReasoningStream:
|
onReasoningStream:
|
||||||
shouldStartTypingOnReasoning || opts?.onReasoningStream
|
typingSignals.shouldStartOnReasoning || opts?.onReasoningStream
|
||||||
? async (payload) => {
|
? async (payload) => {
|
||||||
await signalTypingFromReasoning();
|
await typingSignals.signalReasoningDelta();
|
||||||
await opts?.onReasoningStream?.({
|
await opts?.onReasoningStream?.({
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
@@ -344,7 +330,7 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
pendingStreamedPayloadKeys.add(payloadKey);
|
pendingStreamedPayloadKeys.add(payloadKey);
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
await signalTypingFromText(cleaned);
|
await typingSignals.signalTextDelta(cleaned);
|
||||||
await opts.onBlockReply?.(blockPayload);
|
await opts.onBlockReply?.(blockPayload);
|
||||||
})()
|
})()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -389,7 +375,7 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
await signalTypingFromText(text);
|
await typingSignals.signalTextDelta(text);
|
||||||
await opts.onToolResult?.({
|
await opts.onToolResult?.({
|
||||||
text,
|
text,
|
||||||
mediaUrls: payload.mediaUrls,
|
mediaUrls: payload.mediaUrls,
|
||||||
@@ -544,8 +530,8 @@ export async function runReplyAgent(params: {
|
|||||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
if (shouldSignalTyping && typingMode === "instant" && !isHeartbeat) {
|
if (shouldSignalTyping) {
|
||||||
await typing.startTypingLoop();
|
await typingSignals.signalRunStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionStore && sessionKey) {
|
if (sessionStore && sessionKey) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
const runEmbeddedPiAgentMock = vi.fn();
|
const runEmbeddedPiAgentMock = vi.fn();
|
||||||
|
|
||||||
@@ -31,18 +31,6 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
|||||||
|
|
||||||
import { createFollowupRunner } from "./followup-runner.js";
|
import { createFollowupRunner } from "./followup-runner.js";
|
||||||
|
|
||||||
function createTyping(): TypingController {
|
|
||||||
return {
|
|
||||||
onReplyStart: vi.fn(async () => {}),
|
|
||||||
startTypingLoop: vi.fn(async () => {}),
|
|
||||||
startTypingOnText: vi.fn(async () => {}),
|
|
||||||
refreshTypingTtl: vi.fn(),
|
|
||||||
markRunComplete: vi.fn(),
|
|
||||||
markDispatchIdle: vi.fn(),
|
|
||||||
cleanup: vi.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("createFollowupRunner compaction", () => {
|
describe("createFollowupRunner compaction", () => {
|
||||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||||
const storePath = path.join(
|
const storePath = path.join(
|
||||||
@@ -75,7 +63,7 @@ describe("createFollowupRunner compaction", () => {
|
|||||||
|
|
||||||
const runner = createFollowupRunner({
|
const runner = createFollowupRunner({
|
||||||
opts: { onBlockReply },
|
opts: { onBlockReply },
|
||||||
typing: createTyping(),
|
typing: createMockTypingController(),
|
||||||
typingMode: "instant",
|
typingMode: "instant",
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { extractReplyToTag } from "./reply-tags.js";
|
|||||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||||
import { incrementCompactionCount } from "./session-updates.js";
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
|
import { createTypingSignaler } from "./typing-mode.js";
|
||||||
|
|
||||||
export function createFollowupRunner(params: {
|
export function createFollowupRunner(params: {
|
||||||
opts?: GetReplyOptions;
|
opts?: GetReplyOptions;
|
||||||
@@ -40,6 +41,11 @@ export function createFollowupRunner(params: {
|
|||||||
defaultModel,
|
defaultModel,
|
||||||
agentCfgContextTokens,
|
agentCfgContextTokens,
|
||||||
} = params;
|
} = params;
|
||||||
|
const typingSignals = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: typingMode,
|
||||||
|
isHeartbeat: opts?.isHeartbeat === true,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends followup payloads, routing to the originating channel if set.
|
* Sends followup payloads, routing to the originating channel if set.
|
||||||
@@ -74,13 +80,7 @@ export function createFollowupRunner(params: {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (typingMode !== "never") {
|
await typingSignals.signalTextDelta(payload.text);
|
||||||
if (typingMode === "message" || typingMode === "instant") {
|
|
||||||
await typing.startTypingOnText(payload.text);
|
|
||||||
} else if (typingMode === "thinking") {
|
|
||||||
typing.refreshTypingTtl();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route to originating channel if set, otherwise fall back to dispatcher.
|
// Route to originating channel if set, otherwise fall back to dispatcher.
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
@@ -108,6 +108,7 @@ export function createFollowupRunner(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return async (queued: FollowupRun) => {
|
return async (queued: FollowupRun) => {
|
||||||
|
await typingSignals.signalRunStart();
|
||||||
try {
|
try {
|
||||||
const runId = crypto.randomUUID();
|
const runId = crypto.randomUUID();
|
||||||
if (queued.run.sessionKey) {
|
if (queued.run.sessionKey) {
|
||||||
|
|||||||
15
src/auto-reply/reply/test-helpers.ts
Normal file
15
src/auto-reply/reply/test-helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import type { TypingController } from "./typing.js";
|
||||||
|
|
||||||
|
export function createMockTypingController(): TypingController {
|
||||||
|
return {
|
||||||
|
onReplyStart: vi.fn(async () => {}),
|
||||||
|
startTypingLoop: vi.fn(async () => {}),
|
||||||
|
startTypingOnText: vi.fn(async () => {}),
|
||||||
|
refreshTypingTtl: vi.fn(),
|
||||||
|
markRunComplete: vi.fn(),
|
||||||
|
markDispatchIdle: vi.fn(),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { resolveTypingMode } from "./typing-mode.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
|
||||||
|
|
||||||
describe("resolveTypingMode", () => {
|
describe("resolveTypingMode", () => {
|
||||||
it("defaults to instant for direct chats", () => {
|
it("defaults to instant for direct chats", () => {
|
||||||
@@ -66,3 +67,75 @@ describe("resolveTypingMode", () => {
|
|||||||
).toBe("never");
|
).toBe("never");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createTypingSignaler", () => {
|
||||||
|
it("signals immediately for instant mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "instant",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalRunStart();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signals on text for message mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "message",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalTextDelta("hello");
|
||||||
|
|
||||||
|
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signals on reasoning for thinking mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "thinking",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalReasoningDelta();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes ttl on text for thinking mode", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "thinking",
|
||||||
|
isHeartbeat: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalTextDelta("hi");
|
||||||
|
|
||||||
|
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses typing when disabled", async () => {
|
||||||
|
const typing = createMockTypingController();
|
||||||
|
const signaler = createTypingSignaler({
|
||||||
|
typing,
|
||||||
|
mode: "instant",
|
||||||
|
isHeartbeat: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await signaler.signalRunStart();
|
||||||
|
await signaler.signalTextDelta("hi");
|
||||||
|
await signaler.signalReasoningDelta();
|
||||||
|
|
||||||
|
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
|
import type { TypingController } from "./typing.js";
|
||||||
|
|
||||||
export type TypingModeContext = {
|
export type TypingModeContext = {
|
||||||
configured?: TypingMode;
|
configured?: TypingMode;
|
||||||
@@ -21,5 +22,56 @@ export function resolveTypingMode({
|
|||||||
return DEFAULT_GROUP_TYPING_MODE;
|
return DEFAULT_GROUP_TYPING_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shouldStartTypingImmediately = (mode: TypingMode) =>
|
export type TypingSignaler = {
|
||||||
mode === "instant";
|
mode: TypingMode;
|
||||||
|
shouldStartImmediately: boolean;
|
||||||
|
shouldStartOnText: boolean;
|
||||||
|
shouldStartOnReasoning: boolean;
|
||||||
|
signalRunStart: () => Promise<void>;
|
||||||
|
signalTextDelta: (text?: string) => Promise<void>;
|
||||||
|
signalReasoningDelta: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTypingSignaler(params: {
|
||||||
|
typing: TypingController;
|
||||||
|
mode: TypingMode;
|
||||||
|
isHeartbeat: boolean;
|
||||||
|
}): TypingSignaler {
|
||||||
|
const { typing, mode, isHeartbeat } = params;
|
||||||
|
const shouldStartImmediately = mode === "instant";
|
||||||
|
const shouldStartOnText = mode === "message" || mode === "instant";
|
||||||
|
const shouldStartOnReasoning = mode === "thinking";
|
||||||
|
const disabled = isHeartbeat || mode === "never";
|
||||||
|
|
||||||
|
const signalRunStart = async () => {
|
||||||
|
if (disabled || !shouldStartImmediately) return;
|
||||||
|
await typing.startTypingLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const signalTextDelta = async (text?: string) => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (shouldStartOnText) {
|
||||||
|
await typing.startTypingOnText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldStartOnReasoning) {
|
||||||
|
typing.refreshTypingTtl();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signalReasoningDelta = async () => {
|
||||||
|
if (disabled || !shouldStartOnReasoning) return;
|
||||||
|
await typing.startTypingLoop();
|
||||||
|
typing.refreshTypingTtl();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
shouldStartImmediately,
|
||||||
|
shouldStartOnText,
|
||||||
|
shouldStartOnReasoning,
|
||||||
|
signalRunStart,
|
||||||
|
signalTextDelta,
|
||||||
|
signalReasoningDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type SessionConfig = {
|
|||||||
heartbeatIdleMinutes?: number;
|
heartbeatIdleMinutes?: number;
|
||||||
store?: string;
|
store?: string;
|
||||||
typingIntervalSeconds?: number;
|
typingIntervalSeconds?: number;
|
||||||
|
typingMode?: TypingMode;
|
||||||
mainKey?: string;
|
mainKey?: string;
|
||||||
sendPolicy?: SessionSendPolicyConfig;
|
sendPolicy?: SessionSendPolicyConfig;
|
||||||
agentToAgent?: {
|
agentToAgent?: {
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ const SessionSchema = z
|
|||||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||||
store: z.string().optional(),
|
store: z.string().optional(),
|
||||||
typingIntervalSeconds: z.number().int().positive().optional(),
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
||||||
|
typingMode: z
|
||||||
|
.union([
|
||||||
|
z.literal("never"),
|
||||||
|
z.literal("instant"),
|
||||||
|
z.literal("thinking"),
|
||||||
|
z.literal("message"),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
mainKey: z.string().optional(),
|
mainKey: z.string().optional(),
|
||||||
sendPolicy: z
|
sendPolicy: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user