|
|
|
|
Quelle chat.ts
Sprache: JAVA
|
|
Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { resetToolStream } from "../app-tool-stream.ts";
import { extractText } from "../chat/message-extract.ts";
import { formatConnectError } from "../connect-error.ts";
import { GatewayRequestError, type GatewayBrowserClient } from "../gateway.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type { ChatAttachment } from "../ui-types.ts";
import { generateUUID } from "../uuid.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "./scope-errors.ts";
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
"[openclaw] missing tool result in session history; inserted synthetic error result for tran script repair.";
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500;
const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000;
const chatHistoryRequestVersions = new WeakMap<object, number>();
function beginChatHistoryRequest(state: ChatState): number {
const key = state as object;
const nextVersion = (chatHistoryRequestVersions.get(key) ?? 0) + 1;
chatHistoryRequestVersions.set(key, nextVersion);
return nextVersion;
}
function isLatestChatHistoryRequest(state: ChatState, version: number): boolean {
return chatHistoryRequestVersions.get(state as object) === version;
}
function shouldApplyChatHistoryResult(
state: ChatState,
version: number,
sessionKey: string,
): boolean {
return isLatestChatHistoryRequest(state, version) && state.sessionKey === sessionKey;
}
function isSilentReplyStream(text: string): boolean {
return SILENT_REPLY_PATTERN.test(text);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripHeartbeatTokenForDisplay(
raw: string,
maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
): { shouldSkip: boolean } {
let text = raw.trim();
if (!text) {
return { shouldSkip: true };
}
const strippedMarkup = text
.replace(/<[^>]*>/g, " ")
.replace(/ /gi, " ")
.replace(/^[*`~_]+/, "")
.replace(/[*`~_]+$/, "");
if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: false };
}
const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
let changed = true;
let didStrip = false;
text = strippedMarkup.trim();
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(HEARTBEAT_TOKEN)) {
text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
didStrip = true;
changed = true;
continue;
}
if (tokenAtEnd.test(next)) {
const index = next.lastIndexOf(HEARTBEAT_TOKEN);
const before = next.slice(0, index).trimEnd();
const after = next.slice(index + HEARTBEAT_TOKEN.length).trimStart();
text = before ? `${before}${after}`.trimEnd() : "";
didStrip = true;
changed = true;
}
}
if (!didStrip) {
return { shouldSkip: false };
}
return { shouldSkip: !text || text.length <= maxAckChars };
}
function isHeartbeatOkResponse(message: { role: string; content?: unknown }): boolean {
if (message.role !== "assistant") {
return false;
}
const { text, hasNonTextContent } = resolveMessageText(message.content);
if (hasNonTextContent) {
return false;
}
return stripHeartbeatTokenForDisplay(text).shouldSkip;
}
function resolveMessageText(content: unknown): { text: string; hasNonTextContent: boolean } {
if (typeof content === "string") {
return { text: content, hasNonTextContent: false };
}
if (!Array.isArray(content)) {
return { text: "", hasNonTextContent: content != null };
}
let hasNonTextContent = false;
const text = content
.filter((block): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object" || !("type" in block)) {
hasNonTextContent = true;
return false;
}
if ((block as { type?: unknown }).type !== "text") {
hasNonTextContent = true;
return false;
}
if (typeof (block as { text?: unknown }).text !== "string") {
hasNonTextContent = true;
return false;
}
return true;
})
.map((block) => block.text)
.join("");
return { text, hasNonTextContent };
}
/** Client-side defense-in-depth: detect assistant messages whose text is purely NO_REPLY. */
function isAssistantSilentReply(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as Record<string, unknown>;
const role = normalizeLowercaseStringOrEmpty(entry.role);
if (role !== "assistant") {
return false;
}
// entry.text takes precedence — matches gateway extractAssistantTextForSilentCheck
if (typeof entry.text === "string") {
return isSilentReplyStream(entry.text);
}
const text = extractText(message);
return typeof text === "string" && isSilentReplyStream(text);
}
function isSyntheticTranscriptRepairToolResult(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as Record<string, unknown>;
const role = normalizeLowercaseStringOrEmpty(entry.role);
if (role !== "toolresult") {
return false;
}
const text = extractText(message);
return typeof text === "string" && text.trim() === SYNTHETIC_TRANSCRIPT_REPAIR_RESULT;
}
function isTextOnlyContent(content: unknown): boolean {
if (typeof content === "string") {
return true;
}
if (!Array.isArray(content)) {
return false;
}
if (content.length === 0) {
return true;
}
let sawText = false;
for (const block of content) {
if (!block || typeof block !== "object") {
return false;
}
const entry = block as { type?: unknown; text?: unknown };
if (entry.type !== "text") {
return false;
}
sawText = true;
if (typeof entry.text !== "string") {
return false;
}
}
return sawText;
}
function isEmptyUserTextOnlyMessage(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as Record<string, unknown>;
if (normalizeLowercaseStringOrEmpty(entry.role) !== "user") {
return false;
}
if (!isTextOnlyContent(entry.content ?? entry.text)) {
return false;
}
return (extractText(message)?.trim() ?? "") === "";
}
function isAssistantHeartbeatAck(message: unknown): boolean {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as Record<string, unknown>;
const role = normalizeLowercaseStringOrEmpty(entry.role);
if (role !== "assistant") {
return false;
}
const content = entry.content ?? entry.text;
return isHeartbeatOkResponse({ role, content });
}
function shouldHideHistoryMessage(message: unknown): boolean {
return (
isAssistantSilentReply(message) ||
isAssistantHeartbeatAck(message) ||
isSyntheticTranscriptRepairToolResult(message) ||
isEmptyUserTextOnlyMessage(message)
);
}
function hasTranscriptMeta(message: unknown): boolean {
return Boolean(
message &&
typeof message === "object" &&
(message as { __openclaw?: unknown }).__openclaw &&
typeof (message as { __openclaw?: unknown }).__openclaw === "object",
);
}
function isLocallyOptimisticHistoryMessage(message: unknown): boolean {
if (!message || typeof message !== "object" || hasTranscriptMeta(message)) {
return false;
}
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
return role === "user" || role === "assistant";
}
function messageDisplaySignature(message: unknown): string | null {
if (!message || typeof message !== "object") {
return null;
}
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
if (!role) {
return null;
}
const text = extractText(message)?.trim();
if (text) {
return `${role}:text:${text}`;
}
try {
const content = JSON.stringify((message as { content?: unknown }).content ?? null);
return `${role}:content:${content}`;
} catch {
return null;
}
}
function preserveOptimisticTailMessages(
historyMessages: unknown[],
previousMessages: unknown[],
): unknown[] {
if (historyMessages.length === 0 || previousMessages.length === 0) {
return historyMessages;
}
const historySignatures = new Set(
historyMessages
.map((message) => messageDisplaySignature(message))
.filter((signature): signature is string => Boolean(signature)),
);
let sharedPreviousIndex = -1;
for (let index = previousMessages.length - 1; index >= 0; index--) {
const signature = messageDisplaySignature(previousMessages[index]);
if (signature && historySignatures.has(signature)) {
sharedPreviousIndex = index;
break;
}
}
if (sharedPreviousIndex < 0) {
return historyMessages;
}
const optimisticTail: unknown[] = [];
for (const message of previousMessages.slice(sharedPreviousIndex + 1)) {
if (!isLocallyOptimisticHistoryMessage(message) || shouldHideHistoryMessage(message)) {
return historyMessages;
}
const signature = messageDisplaySignature(message);
if (!signature || historySignatures.has(signature)) {
return historyMessages;
}
optimisticTail.push(message);
}
return optimisticTail.length > 0 ? [...historyMessages, ...optimisticTail] : historyMessages;
}
function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError {
if (!(err instanceof GatewayRequestError)) {
return false;
}
if (err.gatewayCode !== "UNAVAILABLE" || !err.retryable) {
return false;
}
const details = err.details;
if (!details || typeof details !== "object") {
return true;
}
const detailMethod = (details as { method?: unknown }).method;
return typeof detailMethod !== "string" || detailMethod === method;
}
function resolveStartupRetryDelayMs(err: GatewayRequestError): number {
const retryAfterMs =
typeof err.retryAfterMs === "number" ? err.retryAfterMs : STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS;
return Math.min(Math.max(retryAfterMs, 100), STARTUP_CHAT_HISTORY_MAX_RETRY_MS);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export type ChatState = {
client: GatewayBrowserClient | null;
connected: boolean;
sessionKey: string;
chatLoading: boolean;
chatMessages: unknown[];
chatThinkingLevel: string | null;
chatSending: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatRunId: string | null;
chatStream: string | null;
chatStreamStartedAt: number | null;
lastError: string | null;
};
export type ChatEventPayload = {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
message?: unknown;
errorMessage?: string;
};
function maybeResetToolStream(state: ChatState) {
const toolHost = state as ChatState & Partial<Parameters<typeof resetToolStream>[0]>;
if (
toolHost.toolStreamById instanceof Map &&
Array.isArray(toolHost.toolStreamOrder) &&
Array.isArray(toolHost.chatToolMessages) &&
Array.isArray(toolHost.chatStreamSegments)
) {
resetToolStream(toolHost as Parameters<typeof resetToolStream>[0]);
}
}
export async function loadChatHistory(state: ChatState) {
if (!state.client || !state.connected) {
return;
}
const sessionKey = state.sessionKey;
const requestVersion = beginChatHistoryRequest(state);
const startedAt = Date.now();
const previousMessages = state.chatMessages;
state.chatLoading = true;
state.lastError = null;
try {
let res: { messages?: Array<unknown>; thinkingLevel?: string };
for (;;) {
try {
res = await state.client.request<{ messages?: Array<unknown>; thinkingLevel?: string }>(
"chat.history",
{
sessionKey,
limit: 200,
},
);
break;
} catch (err) {
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) {
return;
}
const withinStartupRetryWindow =
Date.now() - startedAt < STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS;
if (withinStartupRetryWindow && isRetryableStartupUnavailable(err, "chat.history")) {
await sleep(resolveStartupRetryDelayMs(err));
if (!state.client || !state.connected) {
return;
}
continue;
}
throw err;
}
}
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) {
return;
}
const messages = Array.isArray(res.messages) ? res.messages : [];
const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
state.chatMessages = preserveOptimisticTailMessages(visibleMessages, previousMessages);
state.chatThinkingLevel = res.thinkingLevel ?? null;
// Clear all streaming state — history includes tool results and text
// inline, so keeping streaming artifacts would cause duplicates.
maybeResetToolStream(state);
state.chatStream = null;
state.chatStreamStartedAt = null;
} catch (err) {
if (!shouldApplyChatHistoryResult(state, requestVersion, sessionKey)) {
return;
}
if (isMissingOperatorReadScopeError(err)) {
state.chatMessages = [];
state.chatThinkingLevel = null;
state.lastError = formatMissingOperatorReadScopeMessage("existing chat history");
} else {
state.lastError = String(err);
}
} finally {
if (isLatestChatHistoryRequest(state, requestVersion)) {
state.chatLoading = false;
}
}
}
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) {
return null;
}
return { mimeType: match[1], content: match[2] };
}
function buildApiAttachments(attachments?: ChatAttachment[]) {
const hasAttachments = attachments && attachments.length > 0;
return hasAttachments
? attachments
.map((att) => {
const parsed = dataUrlToBase64(att.dataUrl);
if (!parsed) {
return null;
}
return {
type: "image",
mimeType: parsed.mimeType,
content: parsed.content,
};
})
.filter((a): a is NonNullable<typeof a> => a !== null)
: undefined;
}
async function requestChatSend(
state: ChatState,
params: { message: string; attachments?: ChatAttachment[]; runId: string },
) {
await state.client!.request("chat.send", {
sessionKey: state.sessionKey,
message: params.message,
deliver: false,
idempotencyKey: params.runId,
attachments: buildApiAttachments(params.attachments),
});
}
type AssistantMessageNormalizationOptions = {
roleRequirement: "required" | "optional";
roleCaseSensitive?: boolean;
requireContentArray?: boolean;
allowTextField?: boolean;
};
function normalizeAssistantMessage(
message: unknown,
options: AssistantMessageNormalizationOptions,
): Record<string, unknown> | null {
if (!message || typeof message !== "object") {
return null;
}
const candidate = message as Record<string, unknown>;
const roleValue = candidate.role;
if (typeof roleValue === "string") {
const role = options.roleCaseSensitive ? roleValue : normalizeLowercaseStringOrEmpty(roleValue);
if (role !== "assistant") {
return null;
}
} else if (options.roleRequirement === "required") {
return null;
}
if (options.requireContentArray) {
return Array.isArray(candidate.content) ? candidate : null;
}
if (!("content" in candidate) && !(options.allowTextField && "text" in candidate)) {
return null;
}
return candidate;
}
function normalizeAbortedAssistantMessage(message: unknown): Record<string, unknown> | null {
return normalizeAssistantMessage(message, {
roleRequirement: "required",
roleCaseSensitive: true,
requireContentArray: true,
});
}
function normalizeFinalAssistantMessage(message: unknown): Record<string, unknown> | null {
return normalizeAssistantMessage(message, {
roleRequirement: "optional",
allowTextField: true,
});
}
export async function sendChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<string | null> {
if (!state.client || !state.connected) {
return null;
}
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) {
return null;
}
const now = Date.now();
// Build user message content blocks
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
if (msg) {
contentBlocks.push({ type: "text", text: msg });
}
// Add image previews to the message for display
if (hasAttachments) {
for (const att of attachments) {
contentBlocks.push({
type: "image",
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
});
}
}
state.chatMessages = [
...state.chatMessages,
{
role: "user",
content: contentBlocks,
timestamp: now,
},
];
state.chatSending = true;
state.lastError = null;
const runId = generateUUID();
state.chatRunId = runId;
state.chatStream = "";
state.chatStreamStartedAt = now;
try {
await requestChatSend(state, { message: msg, attachments, runId });
return runId;
} catch (err) {
const error = formatConnectError(err);
state.chatRunId = null;
state.chatStream = null;
state.chatStreamStartedAt = null;
state.lastError = error;
state.chatMessages = [
...state.chatMessages,
{
role: "assistant",
content: [{ type: "text", text: "Error: " + error }],
timestamp: Date.now(),
},
];
return null;
} finally {
state.chatSending = false;
}
}
export async function sendDetachedChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<string | null> {
if (!state.client || !state.connected) {
return null;
}
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) {
return null;
}
state.lastError = null;
const runId = generateUUID();
try {
await requestChatSend(state, { message: msg, attachments, runId });
return runId;
} catch (err) {
state.lastError = formatConnectError(err);
return null;
}
}
export async function sendSteerChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<string | null> {
if (!state.client || !state.connected) {
return null;
}
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) {
return null;
}
state.lastError = null;
const runId = generateUUID();
try {
await requestChatSend(state, { message: msg, attachments, runId });
return runId;
} catch (err) {
state.lastError = formatConnectError(err);
return null;
}
}
export async function abortChatRun(state: ChatState): Promise<boolean> {
if (!state.client || !state.connected) {
return false;
}
const runId = state.chatRunId;
try {
await state.client.request(
"chat.abort",
runId ? { sessionKey: state.sessionKey, runId } : { sessionKey: state.sessionKey },
);
return true;
} catch (err) {
state.lastError = formatConnectError(err);
return false;
}
}
export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
if (!payload) {
return null;
}
if (payload.sessionKey !== state.sessionKey) {
return null;
}
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/openclaw/openclaw/issues/1909
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
if (payload.state === "final") {
const finalMessage = normalizeFinalAssistantMessage(payload.message);
if (finalMessage && !isAssistantSilentReply(finalMessage)) {
state.chatMessages = [...state.chatMessages, finalMessage];
return null;
}
return "final";
}
return null;
}
if (payload.state === "delta") {
const next = extractText(payload.message);
if (typeof next === "string" && !isSilentReplyStream(next)) {
state.chatStream = next;
}
} else if (payload.state === "final") {
const finalMessage = normalizeFinalAssistantMessage(payload.message);
if (finalMessage && !isAssistantSilentReply(finalMessage)) {
state.chatMessages = [...state.chatMessages, finalMessage];
} else if (state.chatStream?.trim() && !isSilentReplyStream(state.chatStream)) {
state.chatMessages = [
...state.chatMessages,
{
role: "assistant",
content: [{ type: "text", text: state.chatStream }],
timestamp: Date.now(),
},
];
}
state.chatStream = null;
state.chatRunId = null;
state.chatStreamStartedAt = null;
} else if (payload.state === "aborted") {
const normalizedMessage = normalizeAbortedAssistantMessage(payload.message);
if (normalizedMessage && !isAssistantSilentReply(normalizedMessage)) {
state.chatMessages = [...state.chatMessages, normalizedMessage];
} else {
const streamedText = state.chatStream ?? "";
if (streamedText.trim() && !isSilentReplyStream(streamedText)) {
state.chatMessages = [
...state.chatMessages,
{
role: "assistant",
content: [{ type: "text", text: streamedText }],
timestamp: Date.now(),
},
];
}
}
state.chatStream = null;
state.chatRunId = null;
state.chatStreamStartedAt = null;
} else if (payload.state === "error") {
state.chatStream = null;
state.chatRunId = null;
state.chatStreamStartedAt = null;
state.lastError = payload.errorMessage ?? "chat error";
}
return payload.state;
}
¤ Dauer der Verarbeitung: 0.21 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|