fix(chat): reduce system spam and cap history
This commit is contained in:
@@ -161,7 +161,7 @@ struct ChatTypingIndicatorBubble: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private struct TypingDots: View {
|
private struct TypingDots: View {
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@State private var phase: Double = 0
|
@State private var animate = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
@@ -169,35 +169,20 @@ private struct TypingDots: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(Color.secondary.opacity(0.55))
|
.fill(Color.secondary.opacity(0.55))
|
||||||
.frame(width: 7, height: 7)
|
.frame(width: 7, height: 7)
|
||||||
.scaleEffect(self.dotScale(idx))
|
.scaleEffect(self.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70))
|
||||||
.opacity(self.dotOpacity(idx))
|
.opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30))
|
||||||
|
.animation(
|
||||||
|
self.reduceMotion ? nil : .easeInOut(duration: 0.55)
|
||||||
|
.repeatForever(autoreverses: true)
|
||||||
|
.delay(Double(idx) * 0.16),
|
||||||
|
value: self.animate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.reduceMotion else { return }
|
guard !self.reduceMotion else { return }
|
||||||
phase = 0
|
self.animate = true
|
||||||
withAnimation(.linear(duration: 1.05).repeatForever(autoreverses: false)) {
|
|
||||||
self.phase = .pi * 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dotScale(_ idx: Int) -> CGFloat {
|
|
||||||
if self.reduceMotion { return 0.85 }
|
|
||||||
let wave = self.dotWave(idx)
|
|
||||||
return CGFloat(0.72 + (wave * 0.52))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dotOpacity(_ idx: Int) -> Double {
|
|
||||||
if self.reduceMotion { return 0.55 }
|
|
||||||
let wave = self.dotWave(idx)
|
|
||||||
return 0.35 + (wave * 0.65)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dotWave(_ idx: Int) -> Double {
|
|
||||||
let offset = (Double(idx) * (2 * Double.pi / 3))
|
|
||||||
return (sin(self.phase + offset) + 1) / 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -676,23 +676,24 @@ export async function getReplyFromConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepend queued system events and (for new main sessions) a provider snapshot.
|
// For new main sessions, prepend a provider snapshot.
|
||||||
|
// Note: We intentionally do NOT prepend queued system events to the user prompt,
|
||||||
|
// since that bloats session logs (token cost) and clutters chat history.
|
||||||
const isGroupSession =
|
const isGroupSession =
|
||||||
typeof ctx.From === "string" &&
|
typeof ctx.From === "string" &&
|
||||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
||||||
const isMainSession =
|
const isMainSession =
|
||||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||||
if (isMainSession) {
|
if (isMainSession) {
|
||||||
const systemLines: string[] = [];
|
// Drain (discard) queued system events so they remain ephemeral.
|
||||||
const queued = drainSystemEvents();
|
// They are still available via presence/health in the gateway UI.
|
||||||
systemLines.push(...queued);
|
drainSystemEvents();
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
const summary = await buildProviderSummary(cfg);
|
const summary = await buildProviderSummary(cfg);
|
||||||
if (summary.length > 0) systemLines.unshift(...summary);
|
if (summary.length > 0) {
|
||||||
}
|
const block = summary.map((l) => `System: ${l}`).join("\n");
|
||||||
if (systemLines.length > 0) {
|
prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`;
|
||||||
const block = systemLines.map((l) => `System: ${l}`).join("\n");
|
}
|
||||||
prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1832,14 +1832,10 @@ describe("gateway server", () => {
|
|||||||
expect(cappedMsgs.length).toBe(200);
|
expect(cappedMsgs.length).toBe(200);
|
||||||
expect(firstContentText(cappedMsgs[0])).toBe("b1300");
|
expect(firstContentText(cappedMsgs[0])).toBe("b1300");
|
||||||
|
|
||||||
const maxRes = await rpcReq<{ messages?: unknown[] }>(
|
const maxRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||||
ws,
|
sessionKey: "main",
|
||||||
"chat.history",
|
limit: 1000,
|
||||||
{
|
});
|
||||||
sessionKey: "main",
|
|
||||||
limit: 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(maxRes.ok).toBe(true);
|
expect(maxRes.ok).toBe(true);
|
||||||
const maxMsgs = maxRes.payload?.messages ?? [];
|
const maxMsgs = maxRes.payload?.messages ?? [];
|
||||||
expect(maxMsgs.length).toBe(1000);
|
expect(maxMsgs.length).toBe(1000);
|
||||||
@@ -1849,6 +1845,97 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("chat.history strips injected System blocks and caps payload bytes", async () => {
|
||||||
|
const firstContentText = (msg: unknown): string | undefined => {
|
||||||
|
if (!msg || typeof msg !== "object") return undefined;
|
||||||
|
const content = (msg as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content) || content.length === 0) return undefined;
|
||||||
|
const first = content[0];
|
||||||
|
if (!first || typeof first !== "object") return undefined;
|
||||||
|
const text = (first as { text?: unknown }).text;
|
||||||
|
return typeof text === "string" ? text : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||||
|
testSessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
await fs.writeFile(
|
||||||
|
testSessionStorePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
main: {
|
||||||
|
sessionId: "sess-main",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const injected =
|
||||||
|
"System: Node: Peter’s Mac · app 2.0.0 · last input 0s ago · mode local · reason periodic\n" +
|
||||||
|
"System: WhatsApp gateway connected.\n\n" +
|
||||||
|
"Hello from user";
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "sess-main.jsonl"),
|
||||||
|
JSON.stringify({
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: injected }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const scrubbedRes = await rpcReq<{ messages?: unknown[] }>(
|
||||||
|
ws,
|
||||||
|
"chat.history",
|
||||||
|
{ sessionKey: "main", limit: 5 },
|
||||||
|
);
|
||||||
|
expect(scrubbedRes.ok).toBe(true);
|
||||||
|
const scrubbedMsgs = scrubbedRes.payload?.messages ?? [];
|
||||||
|
expect(scrubbedMsgs.length).toBe(1);
|
||||||
|
expect(firstContentText(scrubbedMsgs[0])).toBe("Hello from user");
|
||||||
|
|
||||||
|
const bigText = "x".repeat(300_000);
|
||||||
|
const largeLines: string[] = [];
|
||||||
|
for (let i = 0; i < 60; i += 1) {
|
||||||
|
largeLines.push(
|
||||||
|
JSON.stringify({
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: `${i}:${bigText}` }],
|
||||||
|
timestamp: Date.now() + i,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "sess-main.jsonl"),
|
||||||
|
largeLines.join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
|
||||||
|
ws,
|
||||||
|
"chat.history",
|
||||||
|
{ sessionKey: "main", limit: 1000 },
|
||||||
|
);
|
||||||
|
expect(cappedRes.ok).toBe(true);
|
||||||
|
const cappedMsgs = cappedRes.payload?.messages ?? [];
|
||||||
|
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
||||||
|
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
||||||
|
expect(cappedMsgs.length).toBeLessThan(60);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("chat.send does not overwrite last delivery route", async () => {
|
test("chat.send does not overwrite last delivery route", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||||
testSessionStorePath = path.join(dir, "sessions.json");
|
testSessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ function buildSnapshot(): Snapshot {
|
|||||||
|
|
||||||
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||||
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
||||||
|
const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||||
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||||
const TICK_INTERVAL_MS = 30_000;
|
const TICK_INTERVAL_MS = 30_000;
|
||||||
const HEALTH_REFRESH_INTERVAL_MS = 60_000;
|
const HEALTH_REFRESH_INTERVAL_MS = 60_000;
|
||||||
@@ -350,6 +351,66 @@ function readSessionMessages(
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripInjectedSystemBlock(text: string): string {
|
||||||
|
if (!text.startsWith("System: ")) return text;
|
||||||
|
const sep = text.indexOf("\n\n");
|
||||||
|
if (sep <= 0) return text;
|
||||||
|
const head = text.slice(0, sep);
|
||||||
|
const lines = head.split("\n");
|
||||||
|
if (lines.length === 0) return text;
|
||||||
|
if (!lines.every((l) => l.startsWith("System: "))) return text;
|
||||||
|
return text.slice(sep + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrubInjectedSystemBlocks(messages: unknown[]): unknown[] {
|
||||||
|
let changed = false;
|
||||||
|
const out = messages.map((msg) => {
|
||||||
|
if (!msg || typeof msg !== "object") return msg;
|
||||||
|
const obj = msg as Record<string, unknown>;
|
||||||
|
if (obj.role !== "user") return msg;
|
||||||
|
const content = obj.content;
|
||||||
|
if (!Array.isArray(content) || content.length === 0) return msg;
|
||||||
|
const first = content[0];
|
||||||
|
if (!first || typeof first !== "object") return msg;
|
||||||
|
const firstObj = first as Record<string, unknown>;
|
||||||
|
if (firstObj.type !== "text") return msg;
|
||||||
|
const text = firstObj.text;
|
||||||
|
if (typeof text !== "string") return msg;
|
||||||
|
const stripped = stripInjectedSystemBlock(text);
|
||||||
|
if (stripped === text) return msg;
|
||||||
|
changed = true;
|
||||||
|
const nextFirst = { ...firstObj, text: stripped };
|
||||||
|
const nextContent = [...content];
|
||||||
|
nextContent[0] = nextFirst;
|
||||||
|
return { ...obj, content: nextContent };
|
||||||
|
});
|
||||||
|
return changed ? out : messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonUtf8Bytes(value: unknown): number {
|
||||||
|
try {
|
||||||
|
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||||
|
} catch {
|
||||||
|
return Buffer.byteLength(String(value), "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capArrayByJsonBytes<T>(
|
||||||
|
items: T[],
|
||||||
|
maxBytes: number,
|
||||||
|
): { items: T[]; bytes: number } {
|
||||||
|
if (items.length === 0) return { items, bytes: 2 };
|
||||||
|
const parts = items.map((item) => jsonUtf8Bytes(item));
|
||||||
|
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1); // [] + commas
|
||||||
|
let start = 0;
|
||||||
|
while (bytes > maxBytes && start < items.length - 1) {
|
||||||
|
bytes -= parts[start] + 1; // item + comma
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
const next = start > 0 ? items.slice(start) : items;
|
||||||
|
return { items: next, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
function loadSessionEntry(sessionKey: string) {
|
function loadSessionEntry(sessionKey: string) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const sessionCfg = cfg.inbound?.reply?.session;
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
@@ -853,8 +914,13 @@ export async function startGatewayServer(
|
|||||||
? readSessionMessages(sessionId, storePath)
|
? readSessionMessages(sessionId, storePath)
|
||||||
: [];
|
: [];
|
||||||
const max = typeof limit === "number" ? limit : 200;
|
const max = typeof limit === "number" ? limit : 200;
|
||||||
const messages =
|
const sliced =
|
||||||
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
|
const scrubbed = scrubInjectedSystemBlocks(sliced);
|
||||||
|
const capped = capArrayByJsonBytes(
|
||||||
|
scrubbed,
|
||||||
|
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
||||||
|
).items;
|
||||||
const thinkingLevel =
|
const thinkingLevel =
|
||||||
entry?.thinkingLevel ??
|
entry?.thinkingLevel ??
|
||||||
loadConfig().inbound?.reply?.thinkingDefault ??
|
loadConfig().inbound?.reply?.thinkingDefault ??
|
||||||
@@ -864,7 +930,7 @@ export async function startGatewayServer(
|
|||||||
payloadJSON: JSON.stringify({
|
payloadJSON: JSON.stringify({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionId,
|
sessionId,
|
||||||
messages,
|
messages: capped,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1827,13 +1893,23 @@ export async function startGatewayServer(
|
|||||||
const defaultLimit = 200;
|
const defaultLimit = 200;
|
||||||
const requested = typeof limit === "number" ? limit : defaultLimit;
|
const requested = typeof limit === "number" ? limit : defaultLimit;
|
||||||
const max = Math.min(hardMax, requested);
|
const max = Math.min(hardMax, requested);
|
||||||
const messages =
|
const sliced =
|
||||||
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
|
const scrubbed = scrubInjectedSystemBlocks(sliced);
|
||||||
|
const capped = capArrayByJsonBytes(
|
||||||
|
scrubbed,
|
||||||
|
MAX_CHAT_HISTORY_MESSAGES_BYTES,
|
||||||
|
).items;
|
||||||
const thinkingLevel =
|
const thinkingLevel =
|
||||||
entry?.thinkingLevel ??
|
entry?.thinkingLevel ??
|
||||||
loadConfig().inbound?.reply?.thinkingDefault ??
|
loadConfig().inbound?.reply?.thinkingDefault ??
|
||||||
"off";
|
"off";
|
||||||
respond(true, { sessionKey, sessionId, messages, thinkingLevel });
|
respond(true, {
|
||||||
|
sessionKey,
|
||||||
|
sessionId,
|
||||||
|
messages: capped,
|
||||||
|
thinkingLevel,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chat.send": {
|
case "chat.send": {
|
||||||
@@ -2336,7 +2412,14 @@ export async function startGatewayServer(
|
|||||||
reason,
|
reason,
|
||||||
tags,
|
tags,
|
||||||
});
|
});
|
||||||
enqueueSystemEvent(text);
|
const normalizedReason = (reason ?? "").toLowerCase();
|
||||||
|
const looksPeriodic =
|
||||||
|
normalizedReason.startsWith("periodic") ||
|
||||||
|
normalizedReason === "heartbeat";
|
||||||
|
const isNodePresenceLine = text.startsWith("Node:");
|
||||||
|
if (!(isNodePresenceLine && looksPeriodic)) {
|
||||||
|
enqueueSystemEvent(text);
|
||||||
|
}
|
||||||
presenceVersion += 1;
|
presenceVersion += 1;
|
||||||
broadcast(
|
broadcast(
|
||||||
"presence",
|
"presence",
|
||||||
|
|||||||
Reference in New Issue
Block a user