mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
1071 lines
30 KiB
TypeScript
1071 lines
30 KiB
TypeScript
import { computed, onBeforeUnmount, reactive, ref, type Ref } from "vue";
|
|
import axios from "axios";
|
|
|
|
export type TransportMode = "sse" | "websocket";
|
|
|
|
export interface MessagePart {
|
|
type: string;
|
|
text?: string;
|
|
think?: string;
|
|
message_id?: string | number;
|
|
selected_text?: string;
|
|
embedded_url?: string;
|
|
embedded_file?: { url?: string; filename?: string; attachment_id?: string };
|
|
attachment_id?: string;
|
|
filename?: string;
|
|
tool_calls?: ToolCall[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface ToolCall {
|
|
id?: string;
|
|
name?: string;
|
|
arguments?: unknown;
|
|
result?: unknown;
|
|
ts?: number;
|
|
finished_ts?: number;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface ChatContent {
|
|
type: "user" | "bot" | string;
|
|
message: MessagePart[];
|
|
reasoning?: string;
|
|
isLoading?: boolean;
|
|
agentStats?: any;
|
|
refs?: any;
|
|
}
|
|
|
|
export interface MessageDisplayBlock {
|
|
kind: "thinking" | "content";
|
|
parts: MessagePart[];
|
|
}
|
|
|
|
export interface ChatRecord {
|
|
id?: string | number;
|
|
content: ChatContent;
|
|
created_at?: string;
|
|
sender_id?: string;
|
|
sender_name?: string;
|
|
llm_checkpoint_id?: string | null;
|
|
threads?: ChatThread[];
|
|
}
|
|
|
|
export interface ChatThread {
|
|
thread_id: string;
|
|
parent_session_id: string;
|
|
parent_message_id: number;
|
|
base_checkpoint_id: string;
|
|
selected_text: string;
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
export interface ChatSessionProject {
|
|
project_id: string;
|
|
title: string;
|
|
emoji?: string;
|
|
}
|
|
|
|
interface ActiveConnection {
|
|
sessionId: string;
|
|
messageId: string;
|
|
transport: TransportMode;
|
|
abort?: AbortController;
|
|
ws?: WebSocket;
|
|
}
|
|
|
|
interface SendMessageStreamOptions {
|
|
sessionId: string;
|
|
messageId: string;
|
|
parts: MessagePart[];
|
|
transport: TransportMode;
|
|
enableStreaming?: boolean;
|
|
selectedProvider?: string;
|
|
selectedModel?: string;
|
|
userRecord?: ChatRecord;
|
|
botRecord: ChatRecord;
|
|
skipUserHistory?: boolean;
|
|
llmCheckpointId?: string | null;
|
|
}
|
|
|
|
interface ContinueEditedMessageOptions {
|
|
sessionId: string;
|
|
sourceRecord: ChatRecord;
|
|
enableStreaming?: boolean;
|
|
selectedProvider?: string;
|
|
selectedModel?: string;
|
|
}
|
|
|
|
interface CreateLocalExchangeOptions {
|
|
sessionId: string;
|
|
messageId: string;
|
|
parts: MessagePart[];
|
|
}
|
|
|
|
interface UseMessagesOptions {
|
|
currentSessionId: Ref<string>;
|
|
onSessionsChanged?: () => Promise<void> | void;
|
|
onStreamUpdate?: (sessionId: string) => void;
|
|
}
|
|
|
|
export function useMessages(options: UseMessagesOptions) {
|
|
const loadingMessages = ref(false);
|
|
const sending = ref(false);
|
|
const messagesBySession = reactive<Record<string, ChatRecord[]>>({});
|
|
const loadedSessions = reactive<Record<string, boolean>>({});
|
|
const activeConnections = reactive<Record<string, ActiveConnection>>({});
|
|
const attachmentBlobCache = new Map<string, Promise<string>>();
|
|
const sessionProjects = reactive<Record<string, ChatSessionProject | null>>(
|
|
{},
|
|
);
|
|
|
|
const activeMessages = computed(() =>
|
|
options.currentSessionId.value
|
|
? messagesBySession[options.currentSessionId.value] || []
|
|
: [],
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
cleanupConnections();
|
|
for (const promise of attachmentBlobCache.values()) {
|
|
promise.then((url) => URL.revokeObjectURL(url)).catch(() => {});
|
|
}
|
|
attachmentBlobCache.clear();
|
|
});
|
|
|
|
function isSessionRunning(sessionId: string) {
|
|
return Boolean(activeConnections[sessionId]);
|
|
}
|
|
|
|
function isUserMessage(msg: ChatRecord) {
|
|
return messageContent(msg).type === "user";
|
|
}
|
|
|
|
function messageContent(msg: ChatRecord): ChatContent {
|
|
return msg.content || { type: "bot", message: [] };
|
|
}
|
|
|
|
function messageParts(msg: ChatRecord): MessagePart[] {
|
|
const parts = messageContent(msg).message;
|
|
if (Array.isArray(parts)) return parts;
|
|
if (typeof parts === "string") return [{ type: "plain", text: parts }];
|
|
return [];
|
|
}
|
|
|
|
function isMessageStreaming(msg: ChatRecord, msgIndex: number) {
|
|
if (
|
|
!options.currentSessionId.value ||
|
|
!isSessionRunning(options.currentSessionId.value)
|
|
) {
|
|
return false;
|
|
}
|
|
return !isUserMessage(msg) && msgIndex === activeMessages.value.length - 1;
|
|
}
|
|
|
|
async function resolvePartMedia(part: MessagePart): Promise<void> {
|
|
if (part.embedded_url) return;
|
|
let url: string;
|
|
let cacheKey: string;
|
|
if (part.attachment_id) {
|
|
cacheKey = `att:${part.attachment_id}`;
|
|
url = `/api/chat/get_attachment?attachment_id=${encodeURIComponent(part.attachment_id)}`;
|
|
} else if (part.filename) {
|
|
cacheKey = `file:${part.filename}`;
|
|
url = `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
|
|
} else {
|
|
return;
|
|
}
|
|
let promise = attachmentBlobCache.get(cacheKey);
|
|
if (!promise) {
|
|
promise = axios
|
|
.get(url, { responseType: "blob" })
|
|
.then((resp) => URL.createObjectURL(resp.data));
|
|
attachmentBlobCache.set(cacheKey, promise);
|
|
}
|
|
try {
|
|
part.embedded_url = await promise;
|
|
} catch (e) {
|
|
attachmentBlobCache.delete(cacheKey);
|
|
console.error("Failed to resolve media:", cacheKey, e);
|
|
}
|
|
}
|
|
|
|
async function resolveRecordMedia(records: ChatRecord[]) {
|
|
const mediaTypes = ["image", "record", "video"];
|
|
const tasks: Promise<void>[] = [];
|
|
for (const record of records) {
|
|
for (const part of record.content?.message || []) {
|
|
if (mediaTypes.includes(part.type) && !part.embedded_url && (part.attachment_id || part.filename)) {
|
|
tasks.push(resolvePartMedia(part));
|
|
}
|
|
}
|
|
}
|
|
await Promise.all(tasks);
|
|
}
|
|
|
|
async function loadSessionMessages(sessionId: string) {
|
|
if (!sessionId) return;
|
|
loadingMessages.value = true;
|
|
try {
|
|
const response = await axios.get("/api/chat/get_session", {
|
|
params: { session_id: sessionId },
|
|
});
|
|
const payload = response.data?.data || {};
|
|
const history = payload.history || [];
|
|
const records = history.map(normalizeHistoryRecord);
|
|
attachThreads(records, payload.threads || []);
|
|
await resolveRecordMedia(records);
|
|
messagesBySession[sessionId] = records;
|
|
sessionProjects[sessionId] = normalizeSessionProject(payload.project);
|
|
loadedSessions[sessionId] = true;
|
|
} catch (error) {
|
|
console.error("Failed to load session messages:", error);
|
|
messagesBySession[sessionId] = messagesBySession[sessionId] || [];
|
|
} finally {
|
|
loadingMessages.value = false;
|
|
}
|
|
}
|
|
|
|
function createLocalExchange({
|
|
sessionId,
|
|
messageId,
|
|
parts,
|
|
}: CreateLocalExchangeOptions) {
|
|
loadedSessions[sessionId] = true;
|
|
messagesBySession[sessionId] = messagesBySession[sessionId] || [];
|
|
|
|
const userRecord: ChatRecord = {
|
|
id: `local-user-${messageId}`,
|
|
created_at: new Date().toISOString(),
|
|
content: {
|
|
type: "user",
|
|
message: parts.map(stripUploadOnlyFields),
|
|
},
|
|
};
|
|
|
|
const botRecord: ChatRecord = {
|
|
id: `local-bot-${messageId}`,
|
|
created_at: new Date().toISOString(),
|
|
content: {
|
|
type: "bot",
|
|
message: [],
|
|
reasoning: "",
|
|
isLoading: true,
|
|
},
|
|
};
|
|
|
|
messagesBySession[sessionId].push(userRecord, botRecord);
|
|
|
|
const sessionMessages = messagesBySession[sessionId];
|
|
return {
|
|
userRecord: sessionMessages[sessionMessages.length - 2],
|
|
botRecord: sessionMessages[sessionMessages.length - 1],
|
|
};
|
|
}
|
|
|
|
function sendMessageStream({
|
|
sessionId,
|
|
messageId,
|
|
parts,
|
|
transport,
|
|
enableStreaming = true,
|
|
selectedProvider = "",
|
|
selectedModel = "",
|
|
botRecord,
|
|
userRecord,
|
|
skipUserHistory = false,
|
|
llmCheckpointId = null,
|
|
}: SendMessageStreamOptions) {
|
|
if (transport === "websocket") {
|
|
startWebSocketStream(
|
|
sessionId,
|
|
messageId,
|
|
parts,
|
|
botRecord,
|
|
userRecord,
|
|
enableStreaming,
|
|
selectedProvider,
|
|
selectedModel,
|
|
);
|
|
return;
|
|
}
|
|
startSseStream(
|
|
sessionId,
|
|
messageId,
|
|
parts,
|
|
botRecord,
|
|
userRecord,
|
|
enableStreaming,
|
|
selectedProvider,
|
|
selectedModel,
|
|
skipUserHistory,
|
|
llmCheckpointId,
|
|
);
|
|
}
|
|
|
|
async function editMessage(
|
|
sessionId: string,
|
|
record: ChatRecord,
|
|
editedText: string,
|
|
) {
|
|
if (!sessionId || record.id == null) return { needsRegenerate: false };
|
|
const content = cloneContentWithEditedText(record, editedText);
|
|
const response = await axios.post("/api/chat/message/edit", {
|
|
session_id: sessionId,
|
|
message_id: record.id,
|
|
content,
|
|
});
|
|
const payload = response.data?.data || {};
|
|
const updated = payload.message ? normalizeHistoryRecord(payload.message) : null;
|
|
if (updated) {
|
|
Object.assign(record, updated);
|
|
await resolveRecordMedia([record]);
|
|
}
|
|
if (payload.truncated_after_message) {
|
|
truncateMessagesAfter(sessionId, record);
|
|
}
|
|
return {
|
|
needsRegenerate: Boolean(payload.needs_regenerate),
|
|
truncatedAfterMessage: Boolean(payload.truncated_after_message),
|
|
};
|
|
}
|
|
|
|
function truncateMessagesAfter(sessionId: string, record: ChatRecord) {
|
|
const records = messagesBySession[sessionId];
|
|
if (!records?.length || record.id == null) return;
|
|
const index = records.findIndex(
|
|
(message) => String(message.id) === String(record.id),
|
|
);
|
|
if (index < 0) return;
|
|
messagesBySession[sessionId] = records.slice(0, index + 1);
|
|
}
|
|
|
|
function continueEditedMessage({
|
|
sessionId,
|
|
sourceRecord,
|
|
enableStreaming = true,
|
|
selectedProvider = "",
|
|
selectedModel = "",
|
|
}: ContinueEditedMessageOptions) {
|
|
if (!sessionId) return;
|
|
const parts = messageParts(sourceRecord).map(stripUploadOnlyFields);
|
|
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
|
messagesBySession[sessionId] = messagesBySession[sessionId] || [];
|
|
|
|
const botRecord: ChatRecord = {
|
|
id: `local-edited-bot-${messageId}`,
|
|
created_at: new Date().toISOString(),
|
|
content: {
|
|
type: "bot",
|
|
message: [],
|
|
reasoning: "",
|
|
isLoading: true,
|
|
},
|
|
};
|
|
messagesBySession[sessionId].push(botRecord);
|
|
|
|
startSseStream(
|
|
sessionId,
|
|
messageId,
|
|
parts,
|
|
botRecord,
|
|
undefined,
|
|
enableStreaming,
|
|
selectedProvider,
|
|
selectedModel,
|
|
true,
|
|
sourceRecord.llm_checkpoint_id || null,
|
|
);
|
|
}
|
|
|
|
async function regenerateMessage(
|
|
sessionId: string,
|
|
botRecord: ChatRecord,
|
|
selectedProvider = "",
|
|
selectedModel = "",
|
|
) {
|
|
if (!sessionId || botRecord.id == null) return;
|
|
const targetMessageId = botRecord.id;
|
|
|
|
botRecord.id = `local-regenerate-${Date.now()}`;
|
|
botRecord.created_at = new Date().toISOString();
|
|
botRecord.content = {
|
|
type: "bot",
|
|
message: [],
|
|
reasoning: "",
|
|
isLoading: true,
|
|
};
|
|
|
|
const abort = new AbortController();
|
|
activeConnections[sessionId] = {
|
|
sessionId,
|
|
messageId: String(botRecord.id),
|
|
transport: "sse",
|
|
abort,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch("/api/chat/message/regenerate", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${localStorage.getItem("token") || ""}`,
|
|
},
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
message_id: targetMessageId,
|
|
selected_provider: selectedProvider,
|
|
selected_model: selectedModel,
|
|
}),
|
|
signal: abort.signal,
|
|
});
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Regenerate failed: ${response.status}`);
|
|
}
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!contentType.includes("text/event-stream")) {
|
|
const payload = await response.json().catch(() => null);
|
|
throw new Error(payload?.message || "Regenerate failed.");
|
|
}
|
|
await readSseStream(response.body, (payload) => {
|
|
processStreamPayload(botRecord, payload);
|
|
options.onStreamUpdate?.(sessionId);
|
|
});
|
|
} catch (error) {
|
|
if (!abort.signal.aborted) {
|
|
appendPlain(botRecord, `\n\n${String((error as Error)?.message || error)}`);
|
|
console.error("Regenerate failed:", error);
|
|
}
|
|
} finally {
|
|
delete activeConnections[sessionId];
|
|
await options.onSessionsChanged?.();
|
|
}
|
|
}
|
|
|
|
async function stopSession(sessionId: string) {
|
|
if (!sessionId) return;
|
|
await axios.post("/api/chat/stop", { session_id: sessionId });
|
|
}
|
|
|
|
function cleanupConnections() {
|
|
Object.values(activeConnections).forEach((connection) => {
|
|
connection.abort?.abort();
|
|
connection.ws?.close();
|
|
});
|
|
}
|
|
|
|
function normalizeHistoryRecord(record: any): ChatRecord {
|
|
const content = record.content || {};
|
|
const normalizedMessage = normalizeMessageParts(
|
|
content.message || [],
|
|
content.reasoning || "",
|
|
);
|
|
const normalizedContent: ChatContent = {
|
|
type: content.type || (record.sender_id === "bot" ? "bot" : "user"),
|
|
message: normalizedMessage,
|
|
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
|
|
agentStats: content.agentStats || content.agent_stats,
|
|
refs: content.refs,
|
|
};
|
|
|
|
return {
|
|
...record,
|
|
content: normalizedContent,
|
|
};
|
|
}
|
|
|
|
function attachThreads(records: ChatRecord[], threads: ChatThread[]) {
|
|
const threadsByMessage = new Map<string, ChatThread[]>();
|
|
for (const thread of threads) {
|
|
const key = String(thread.parent_message_id);
|
|
const list = threadsByMessage.get(key) || [];
|
|
list.push(thread);
|
|
threadsByMessage.set(key, list);
|
|
}
|
|
for (const record of records) {
|
|
const key = record.id == null ? "" : String(record.id);
|
|
record.threads = threadsByMessage.get(key) || [];
|
|
}
|
|
}
|
|
|
|
function startSseStream(
|
|
sessionId: string,
|
|
messageId: string,
|
|
parts: MessagePart[],
|
|
botRecord: ChatRecord,
|
|
userRecord: ChatRecord | undefined,
|
|
enableStreaming: boolean,
|
|
selectedProvider: string,
|
|
selectedModel: string,
|
|
skipUserHistory = false,
|
|
llmCheckpointId: string | null = null,
|
|
) {
|
|
const abort = new AbortController();
|
|
activeConnections[sessionId] = {
|
|
sessionId,
|
|
messageId,
|
|
transport: "sse",
|
|
abort,
|
|
};
|
|
|
|
fetch("/api/chat/send", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${localStorage.getItem("token") || ""}`,
|
|
},
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
message: parts.map(partToPayload),
|
|
enable_streaming: enableStreaming,
|
|
selected_provider: selectedProvider,
|
|
selected_model: selectedModel,
|
|
_skip_user_history: skipUserHistory,
|
|
_llm_checkpoint_id: llmCheckpointId || undefined,
|
|
}),
|
|
signal: abort.signal,
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`SSE connection failed: ${response.status}`);
|
|
}
|
|
await readSseStream(response.body, (payload) => {
|
|
processStreamPayload(botRecord, payload, userRecord);
|
|
options.onStreamUpdate?.(sessionId);
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (abort.signal.aborted) return;
|
|
appendPlain(botRecord, `\n\n${String(error?.message || error)}`);
|
|
console.error("SSE chat failed:", error);
|
|
})
|
|
.finally(async () => {
|
|
delete activeConnections[sessionId];
|
|
await options.onSessionsChanged?.();
|
|
});
|
|
}
|
|
|
|
function startWebSocketStream(
|
|
sessionId: string,
|
|
messageId: string,
|
|
parts: MessagePart[],
|
|
botRecord: ChatRecord,
|
|
userRecord: ChatRecord | undefined,
|
|
enableStreaming: boolean,
|
|
selectedProvider: string,
|
|
selectedModel: string,
|
|
) {
|
|
const token = encodeURIComponent(localStorage.getItem("token") || "");
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const ws = new WebSocket(
|
|
`${protocol}//${window.location.host}/api/unified_chat/ws?token=${token}`,
|
|
);
|
|
|
|
activeConnections[sessionId] = {
|
|
sessionId,
|
|
messageId,
|
|
transport: "websocket",
|
|
ws,
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
ct: "chat",
|
|
t: "send",
|
|
session_id: sessionId,
|
|
message_id: messageId,
|
|
message: parts.map(partToPayload),
|
|
enable_streaming: enableStreaming,
|
|
selected_provider: selectedProvider,
|
|
selected_model: selectedModel,
|
|
}),
|
|
);
|
|
};
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.data);
|
|
processStreamPayload(botRecord, payload, userRecord);
|
|
options.onStreamUpdate?.(sessionId);
|
|
if (payload.type === "end" || payload.t === "end") {
|
|
ws.close();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to parse WebSocket payload:", error);
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
appendPlain(botRecord, "\n\nWebSocket connection failed.");
|
|
};
|
|
ws.onclose = async () => {
|
|
delete activeConnections[sessionId];
|
|
await options.onSessionsChanged?.();
|
|
};
|
|
}
|
|
|
|
function processStreamPayload(
|
|
botRecord: ChatRecord,
|
|
payload: any,
|
|
userRecord?: ChatRecord,
|
|
) {
|
|
const normalized =
|
|
payload?.ct === "chat"
|
|
? { ...payload, type: payload.type || payload.t }
|
|
: payload;
|
|
const msgType = normalized?.type || normalized?.t;
|
|
const chainType = normalized?.chain_type;
|
|
const data = normalized?.data ?? "";
|
|
|
|
if (msgType === "session_id" || msgType === "session_bound") return;
|
|
if (msgType === "user_message_saved") {
|
|
if (userRecord) {
|
|
userRecord.id = data?.id || userRecord.id;
|
|
userRecord.created_at = data?.created_at || userRecord.created_at;
|
|
userRecord.llm_checkpoint_id =
|
|
data?.llm_checkpoint_id || userRecord.llm_checkpoint_id;
|
|
}
|
|
return;
|
|
}
|
|
if (msgType === "message_saved") {
|
|
markMessageStarted(botRecord);
|
|
botRecord.id = data?.id || botRecord.id;
|
|
botRecord.created_at = data?.created_at || botRecord.created_at;
|
|
botRecord.llm_checkpoint_id =
|
|
data?.llm_checkpoint_id || botRecord.llm_checkpoint_id;
|
|
if (data?.refs) {
|
|
messageContent(botRecord).refs = data.refs;
|
|
}
|
|
return;
|
|
}
|
|
if (msgType === "agent_stats" || chainType === "agent_stats") {
|
|
markMessageStarted(botRecord);
|
|
messageContent(botRecord).agentStats = data;
|
|
return;
|
|
}
|
|
if (msgType === "error") {
|
|
markMessageStarted(botRecord);
|
|
appendPlain(botRecord, `\n\n${String(data)}`);
|
|
return;
|
|
}
|
|
if (msgType === "complete" || msgType === "break") {
|
|
markMessageStarted(botRecord);
|
|
const finalText = payloadText(data);
|
|
if (finalText && !hasPlainText(botRecord)) {
|
|
appendPlain(botRecord, finalText, false);
|
|
}
|
|
return;
|
|
}
|
|
if (msgType === "end") {
|
|
markMessageStarted(botRecord);
|
|
return;
|
|
}
|
|
|
|
if (msgType === "plain") {
|
|
markMessageStarted(botRecord);
|
|
if (chainType === "reasoning") {
|
|
appendReasoningPart(botRecord, payloadText(data));
|
|
return;
|
|
}
|
|
if (chainType === "tool_call") {
|
|
upsertToolCall(botRecord, parseJsonSafe(data));
|
|
return;
|
|
}
|
|
if (chainType === "tool_call_result") {
|
|
finishToolCall(botRecord, parseJsonSafe(data));
|
|
return;
|
|
}
|
|
appendPlain(botRecord, payloadText(data), normalized.streaming !== false);
|
|
return;
|
|
}
|
|
|
|
if (["image", "record", "file", "video"].includes(msgType)) {
|
|
markMessageStarted(botRecord);
|
|
const filename = String(data)
|
|
.replace("[IMAGE]", "")
|
|
.replace("[RECORD]", "")
|
|
.replace("[FILE]", "")
|
|
.replace("[VIDEO]", "")
|
|
.split("|", 1)[0];
|
|
const mediaPart: MessagePart = { type: msgType, filename };
|
|
if (msgType !== "file") {
|
|
resolvePartMedia(mediaPart).then(() => {
|
|
messageContent(botRecord).message.push(mediaPart);
|
|
});
|
|
} else {
|
|
messageContent(botRecord).message.push(mediaPart);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
loadingMessages,
|
|
sending,
|
|
messagesBySession,
|
|
loadedSessions,
|
|
sessionProjects,
|
|
activeMessages,
|
|
isSessionRunning,
|
|
isUserMessage,
|
|
isMessageStreaming,
|
|
messageContent,
|
|
messageParts,
|
|
loadSessionMessages,
|
|
createLocalExchange,
|
|
sendMessageStream,
|
|
editMessage,
|
|
continueEditedMessage,
|
|
regenerateMessage,
|
|
stopSession,
|
|
cleanupConnections,
|
|
};
|
|
}
|
|
|
|
function cloneContentWithEditedText(
|
|
record: ChatRecord,
|
|
editedText: string,
|
|
): ChatContent {
|
|
const content = record.content || { type: "bot", message: [] };
|
|
const message = Array.isArray(content.message)
|
|
? content.message.map((part) => ({ ...part }))
|
|
: [];
|
|
let replaced = false;
|
|
for (const part of message) {
|
|
if (part.type === "plain") {
|
|
part.text = editedText;
|
|
replaced = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!replaced && editedText) {
|
|
message.push({ type: "plain", text: editedText });
|
|
}
|
|
return {
|
|
...content,
|
|
message,
|
|
};
|
|
}
|
|
|
|
function stripUploadOnlyFields(part: MessagePart): MessagePart {
|
|
const copied = { ...part };
|
|
delete copied.path;
|
|
return copied;
|
|
}
|
|
|
|
function normalizeSessionProject(value: unknown): ChatSessionProject | null {
|
|
if (!value || typeof value !== "object") return null;
|
|
const project = value as Record<string, unknown>;
|
|
if (
|
|
typeof project.project_id !== "string" ||
|
|
typeof project.title !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
project_id: project.project_id,
|
|
title: project.title,
|
|
emoji: typeof project.emoji === "string" ? project.emoji : undefined,
|
|
};
|
|
}
|
|
|
|
export function normalizeMessageParts(
|
|
parts: unknown,
|
|
legacyReasoning = "",
|
|
): MessagePart[] {
|
|
const normalizedParts = normalizePartsInternal(parts);
|
|
if (legacyReasoning && !normalizedParts.some((part) => part.type === "think")) {
|
|
normalizedParts.unshift({ type: "think", think: legacyReasoning });
|
|
}
|
|
return normalizedParts;
|
|
}
|
|
|
|
export function extractReasoningText(
|
|
parts: MessagePart[] | unknown,
|
|
legacyReasoning = "",
|
|
) {
|
|
const normalizedParts = Array.isArray(parts)
|
|
? parts
|
|
: normalizeMessageParts(parts, legacyReasoning);
|
|
const text = normalizedParts
|
|
.filter((part) => part.type === "think")
|
|
.map((part) => String(part.think || ""))
|
|
.join("");
|
|
return text || legacyReasoning;
|
|
}
|
|
|
|
export function reasoningActivityCounts(
|
|
parts: MessagePart[] | unknown,
|
|
legacyReasoning = "",
|
|
) {
|
|
const normalizedParts = Array.isArray(parts)
|
|
? parts
|
|
: normalizeMessageParts(parts, legacyReasoning);
|
|
let thinkCount = 0;
|
|
let toolCount = 0;
|
|
|
|
for (const part of normalizedParts) {
|
|
if (part.type === "think" && String(part.think || "").trim()) {
|
|
thinkCount += 1;
|
|
}
|
|
if (part.type === "tool_call" && Array.isArray(part.tool_calls)) {
|
|
toolCount += part.tool_calls.length;
|
|
}
|
|
}
|
|
|
|
return { thinkCount, toolCount };
|
|
}
|
|
|
|
export function reasoningActivityTitle(
|
|
counts: ReturnType<typeof reasoningActivityCounts>,
|
|
tm: (key: string, params?: Record<string, string | number>) => string,
|
|
) {
|
|
return [
|
|
counts.thinkCount > 0
|
|
? tm("reasoning.thinkSummary", { count: counts.thinkCount })
|
|
: "",
|
|
counts.toolCount > 0
|
|
? tm("reasoning.toolSummary", { count: counts.toolCount })
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(tm("reasoning.summarySeparator")) || tm("reasoning.thinking");
|
|
}
|
|
|
|
export function thinkingParts(content: ChatContent): MessagePart[] {
|
|
const firstThinkingBlock = messageBlocks(content).find(
|
|
(block) => block.kind === "thinking",
|
|
);
|
|
if (firstThinkingBlock) return firstThinkingBlock.parts;
|
|
|
|
const fallbackReasoning = String(content.reasoning || "");
|
|
return fallbackReasoning ? [{ type: "think", think: fallbackReasoning }] : [];
|
|
}
|
|
|
|
export function displayParts(content: ChatContent): MessagePart[] {
|
|
return messageBlocks(content)
|
|
.filter((block) => block.kind === "content")
|
|
.flatMap((block) => block.parts);
|
|
}
|
|
|
|
export function messageBlocks(content: ChatContent): MessageDisplayBlock[] {
|
|
const parts = Array.isArray(content.message)
|
|
? content.message
|
|
: normalizeMessageParts(content.message, content.reasoning || "");
|
|
|
|
const blocks: MessageDisplayBlock[] = [];
|
|
let currentKind: MessageDisplayBlock["kind"] | null = null;
|
|
let currentParts: MessagePart[] = [];
|
|
|
|
for (const part of parts) {
|
|
if (isEmptyPlainPart(part)) continue;
|
|
|
|
const nextKind: MessageDisplayBlock["kind"] = isThinkingPart(part)
|
|
? "thinking"
|
|
: "content";
|
|
|
|
if (currentKind !== nextKind) {
|
|
if (currentKind && currentParts.length) {
|
|
blocks.push({ kind: currentKind, parts: currentParts });
|
|
}
|
|
currentKind = nextKind;
|
|
currentParts = [{ ...part }];
|
|
continue;
|
|
}
|
|
|
|
currentParts.push({ ...part });
|
|
}
|
|
|
|
if (currentKind && currentParts.length) {
|
|
blocks.push({ kind: currentKind, parts: currentParts });
|
|
}
|
|
|
|
if (!blocks.length && content.reasoning) {
|
|
return [
|
|
{
|
|
kind: "thinking",
|
|
parts: [{ type: "think", think: String(content.reasoning) }],
|
|
},
|
|
];
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
function partToPayload(part: MessagePart) {
|
|
if (part.type === "plain") return { type: "plain", text: part.text || "" };
|
|
if (part.type === "reply") {
|
|
return {
|
|
type: "reply",
|
|
message_id: part.message_id,
|
|
selected_text: part.selected_text || "",
|
|
};
|
|
}
|
|
return {
|
|
type: part.type,
|
|
attachment_id: part.attachment_id,
|
|
filename: part.filename,
|
|
};
|
|
}
|
|
|
|
async function readSseStream(
|
|
body: ReadableStream<Uint8Array>,
|
|
onPayload: (payload: any) => void,
|
|
) {
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const events = buffer.split("\n\n");
|
|
buffer = events.pop() || "";
|
|
|
|
for (const event of events) {
|
|
const data = event
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("data:"))
|
|
.map((line) => line.slice(5).trimStart())
|
|
.join("\n");
|
|
if (!data) continue;
|
|
try {
|
|
onPayload(JSON.parse(data));
|
|
} catch (error) {
|
|
console.error("Failed to parse SSE payload:", error, data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizePartsInternal(parts: unknown): MessagePart[] {
|
|
if (typeof parts === "string") {
|
|
return parts ? [{ type: "plain", text: parts }] : [];
|
|
}
|
|
if (!Array.isArray(parts)) return [];
|
|
return parts.map((part: any) => {
|
|
if (!part || typeof part !== "object") {
|
|
return { type: "plain", text: String(part ?? "") };
|
|
}
|
|
if (part.type === "reasoning") {
|
|
return {
|
|
...part,
|
|
type: "think",
|
|
think: String(part.think ?? part.text ?? ""),
|
|
};
|
|
}
|
|
return { ...part };
|
|
});
|
|
}
|
|
|
|
function isEmptyPlainPart(part: MessagePart) {
|
|
return part.type === "plain" && !String(part.text || "");
|
|
}
|
|
|
|
function isThinkingPart(part: MessagePart) {
|
|
return part.type === "think" || part.type === "tool_call";
|
|
}
|
|
|
|
function firstNonEmptyPartIndex(parts: MessagePart[]) {
|
|
return parts.findIndex((part) => !isEmptyPlainPart(part));
|
|
}
|
|
|
|
export function appendPlain(record: ChatRecord, text: string, append = true) {
|
|
markMessageStarted(record);
|
|
const content = record.content;
|
|
let last = content.message[content.message.length - 1];
|
|
if (!last || last.type !== "plain") {
|
|
last = { type: "plain", text: "" };
|
|
content.message.push(last);
|
|
}
|
|
last.text = append ? `${last.text || ""}${text}` : text;
|
|
}
|
|
|
|
export function appendReasoningPart(record: ChatRecord, text: string) {
|
|
markMessageStarted(record);
|
|
if (!text) return;
|
|
const content = record.content;
|
|
const last = content.message[content.message.length - 1];
|
|
if (last?.type === "think") {
|
|
last.think = `${String(last.think || "")}${text}`;
|
|
} else {
|
|
content.message.push({ type: "think", think: text });
|
|
}
|
|
content.reasoning = extractReasoningText(content.message);
|
|
}
|
|
|
|
export function upsertToolCall(record: ChatRecord, toolCall: any) {
|
|
markMessageStarted(record);
|
|
if (!toolCall || typeof toolCall !== "object") return;
|
|
const targetId = toolCall.id;
|
|
if (targetId != null) {
|
|
for (const part of record.content.message) {
|
|
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) continue;
|
|
const matched = part.tool_calls.find((item) => item.id === targetId);
|
|
if (matched) {
|
|
Object.assign(matched, toolCall);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
record.content.message.push({ type: "tool_call", tool_calls: [{ ...toolCall }] });
|
|
}
|
|
|
|
export function finishToolCall(record: ChatRecord, result: any) {
|
|
markMessageStarted(record);
|
|
if (!result || typeof result !== "object") return;
|
|
const targetId = result.id;
|
|
for (const part of record.content.message) {
|
|
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) continue;
|
|
const tool = part.tool_calls.find((item) => item.id === targetId);
|
|
if (tool) {
|
|
tool.result = result.result;
|
|
tool.finished_ts = result.ts || Date.now() / 1000;
|
|
return;
|
|
}
|
|
}
|
|
record.content.message.push({
|
|
type: "tool_call",
|
|
tool_calls: [
|
|
{
|
|
id: targetId,
|
|
result: result.result,
|
|
finished_ts: result.ts || Date.now() / 1000,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
export function markMessageStarted(record: ChatRecord) {
|
|
record.content.isLoading = false;
|
|
}
|
|
|
|
export function hasPlainText(record: ChatRecord) {
|
|
return record.content.message.some(
|
|
(part) =>
|
|
part.type === "plain" && typeof part.text === "string" && part.text,
|
|
);
|
|
}
|
|
|
|
export function payloadText(value: unknown) {
|
|
if (typeof value === "string") return value;
|
|
if (value == null) return "";
|
|
if (typeof value === "object") {
|
|
const payload = value as Record<string, unknown>;
|
|
if (typeof payload.text === "string") return payload.text;
|
|
if (typeof payload.content === "string") return payload.content;
|
|
if (typeof payload.message === "string") return payload.message;
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
export function parseJsonSafe(value: unknown) {
|
|
if (typeof value !== "string") return value;
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|