212 lines
5.5 KiB
TypeScript
212 lines
5.5 KiB
TypeScript
import type { GatewayBrowserClient } from "../gateway.ts";
|
|
import type { ChatAttachment } from "../ui-types.ts";
|
|
import { extractText } from "../chat/message-extract.ts";
|
|
import { generateUUID } from "../uuid.ts";
|
|
|
|
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;
|
|
};
|
|
|
|
export async function loadChatHistory(state: ChatState) {
|
|
if (!state.client || !state.connected) {
|
|
return;
|
|
}
|
|
state.chatLoading = true;
|
|
state.lastError = null;
|
|
try {
|
|
const res = await state.client.request<{ messages?: Array<unknown>; thinkingLevel?: string }>(
|
|
"chat.history",
|
|
{
|
|
sessionKey: state.sessionKey,
|
|
limit: 200,
|
|
},
|
|
);
|
|
state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
|
|
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
|
} catch (err) {
|
|
state.lastError = String(err);
|
|
} finally {
|
|
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] };
|
|
}
|
|
|
|
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;
|
|
|
|
// Convert attachments to API format
|
|
const apiAttachments = 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;
|
|
|
|
try {
|
|
await state.client.request("chat.send", {
|
|
sessionKey: state.sessionKey,
|
|
message: msg,
|
|
deliver: false,
|
|
idempotencyKey: runId,
|
|
attachments: apiAttachments,
|
|
});
|
|
return runId;
|
|
} catch (err) {
|
|
const error = String(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 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 = String(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") {
|
|
return "final";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (payload.state === "delta") {
|
|
const next = extractText(payload.message);
|
|
if (typeof next === "string") {
|
|
const current = state.chatStream ?? "";
|
|
if (!current || next.length >= current.length) {
|
|
state.chatStream = next;
|
|
}
|
|
}
|
|
} else if (payload.state === "final") {
|
|
state.chatStream = null;
|
|
state.chatRunId = null;
|
|
state.chatStreamStartedAt = null;
|
|
} else if (payload.state === "aborted") {
|
|
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;
|
|
}
|