Files
bifrost/ui/lib/message/message.ts
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

440 lines
13 KiB
TypeScript

import { v4 as uuidv4 } from "uuid";
import {
APIMessage,
CompletionRequest,
CompletionResult,
CompletionUsage,
MessageContent,
MessageError,
MessageRole,
MessageType,
SerializedMessage,
ToolCall,
ToolResult,
} from "./types";
export class Message {
readonly id: string;
readonly index: number;
private readonly originalType: MessageType;
private currentType: MessageType;
private _payload?: CompletionRequest | CompletionResult | ToolResult;
readonly error?: MessageError;
constructor(
id: string,
index: number,
type: MessageType,
payload?: CompletionRequest | CompletionResult | ToolResult,
error?: MessageError,
) {
this.id = id;
this.index = index;
this.originalType = type;
this.currentType = type;
this._payload = payload;
this.error = error;
}
// Convenience factory methods
static system(content: string, index = 0): Message {
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role: MessageRole.SYSTEM,
content,
} as CompletionRequest);
}
static request(content: string, index = 0, attachments?: MessageContent[]): Message {
if (attachments && attachments.length > 0) {
const parts: MessageContent[] = [{ type: "text", text: content }, ...attachments];
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role: MessageRole.USER,
content: parts,
} as CompletionRequest);
}
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role: MessageRole.USER,
content,
} as CompletionRequest);
}
static response(content: string, index = 0, usage?: CompletionUsage): Message {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [{ index: 0, message: { role: MessageRole.ASSISTANT, content } }],
usage,
} as CompletionResult);
}
static toolCallResponse(content: string, toolCalls: ToolCall[], index = 0, usage?: CompletionUsage): Message {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [{ index: 0, message: { role: MessageRole.ASSISTANT, content, tool_calls: toolCalls }, finish_reason: "tool_calls" }],
usage,
} as CompletionResult);
}
static error(content: string, index = 0): Message {
return new Message(uuidv4(), index, MessageType.CompletionError, undefined, {
code: "error",
message: content,
});
}
public get payload() {
return this._payload;
}
public get type(): MessageType {
return this.currentType;
}
// Serialization
public get serialized(): SerializedMessage {
const s: SerializedMessage = {
id: this.id,
index: this.index,
originalType: this.originalType,
currentType: this.currentType,
payload: this._payload ? JSON.parse(JSON.stringify(this._payload)) : undefined,
};
if (this.error) {
s.error = this.error;
}
return s;
}
public static deserialize(serialized: SerializedMessage): Message {
const message = new Message(
serialized.id,
serialized.index,
serialized.originalType,
serialized.payload ? JSON.parse(JSON.stringify(serialized.payload)) : undefined,
serialized.error,
);
message.currentType = serialized.currentType;
return message;
}
public withIndex(index: number): Message {
if (this.index === index) return this;
const m = new Message(this.id, index, this.originalType, this._payload, this.error);
m.currentType = this.currentType;
return m;
}
public clone(): Message {
return Message.deserialize(this.serialized);
}
// Role
public get role(): MessageRole | undefined {
switch (this.originalType) {
case MessageType.CompletionRequest:
return (this._payload as CompletionRequest)?.role;
case MessageType.CompletionResult:
return (this._payload as CompletionResult)?.choices?.[0]?.message?.role;
case MessageType.CompletionError:
return MessageRole.ASSISTANT;
case MessageType.ToolResult:
return (this._payload as ToolResult)?.role ?? MessageRole.TOOL;
default:
return undefined;
}
}
public set role(role: MessageRole) {
switch (this.originalType) {
case MessageType.CompletionRequest:
(this._payload as CompletionRequest).role = role;
break;
case MessageType.CompletionResult:
(this._payload as CompletionResult).choices?.forEach((choice) => {
choice.message.role = role;
});
break;
case MessageType.ToolResult:
(this._payload as any).role = role;
break;
}
switch (role) {
case MessageRole.ASSISTANT:
this.currentType = MessageType.CompletionResult;
break;
case MessageRole.USER:
case MessageRole.SYSTEM:
case MessageRole.DEVELOPER:
this.currentType = MessageType.CompletionRequest;
break;
case MessageRole.TOOL:
this.currentType = MessageType.ToolResult;
break;
default:
this.currentType = MessageType.CompletionResult;
break;
}
}
// Content
public get content(): string {
switch (this.originalType) {
case MessageType.CompletionRequest: {
const payload = this._payload as CompletionRequest;
if (!payload?.content) return "";
if (typeof payload.content === "string") return payload.content;
const textPart = payload.content.find((c) => c.type === "text");
return textPart?.text || "";
}
case MessageType.CompletionResult:
return (this._payload as CompletionResult)?.choices?.[0]?.message?.content ?? "";
case MessageType.ToolResult:
return (this._payload as ToolResult)?.content ?? "";
default:
return this.error?.message || "";
}
}
public set content(content: string) {
switch (this.originalType) {
case MessageType.CompletionRequest: {
const payload = this._payload as CompletionRequest;
if (typeof payload.content === "string" || payload.content === null) {
this._payload = { ...payload, content } as CompletionRequest;
} else if (Array.isArray(payload.content)) {
const updated = JSON.parse(JSON.stringify(payload.content));
if (updated.length > 0 && updated[0].type === "text") {
updated[0].text = content;
} else {
updated.unshift({ type: "text", text: content });
}
this._payload = { ...payload, content: updated } as CompletionRequest;
}
break;
}
case MessageType.ToolResult:
this._payload = { ...this._payload, content } as ToolResult;
break;
case MessageType.CompletionResult: {
const result = this._payload as CompletionResult;
const choices = result?.choices?.map((c) => ({ ...c })) ?? [];
if (choices[0]) {
choices[0].message = { ...choices[0].message, content };
}
this._payload = { ...result, choices } as CompletionResult;
break;
}
}
}
// Attachments (non-text content parts)
public get attachments(): MessageContent[] {
if (this.originalType !== MessageType.CompletionRequest) return [];
const payload = this._payload as CompletionRequest;
if (!Array.isArray(payload?.content)) return [];
return payload.content.filter((c) => c.type !== "text");
}
public set attachments(parts: MessageContent[]) {
if (this.originalType !== MessageType.CompletionRequest) return;
const payload = this._payload as CompletionRequest;
const text = this.content;
if (parts.length === 0) {
this._payload = { ...payload, content: text } as CompletionRequest;
} else {
this._payload = {
...payload,
content: [{ type: "text", text } as MessageContent, ...parts],
} as CompletionRequest;
}
}
// Tool calls
public get toolCalls(): ToolCall[] | undefined {
if (this.originalType === MessageType.CompletionResult) {
const calls = (this._payload as CompletionResult)?.choices?.map((c) => c.message.tool_calls ?? []).flat();
return calls && calls.length > 0 ? calls : undefined;
}
if (this.originalType === MessageType.CompletionRequest) {
const calls = (this._payload as CompletionRequest)?.tool_calls;
return calls && calls.length > 0 ? calls : undefined;
}
return undefined;
}
public get toolCallId(): string | undefined {
if (this.originalType === MessageType.ToolResult || this.currentType === MessageType.ToolResult) {
return (this._payload as ToolResult)?.tool_call_id;
}
if (this.originalType === MessageType.CompletionRequest) {
return (this._payload as CompletionRequest)?.tool_call_id;
}
return undefined;
}
public set toolCallId(id: string) {
if (this.originalType === MessageType.ToolResult) {
(this._payload as ToolResult).tool_call_id = id;
} else if (this.originalType === MessageType.CompletionRequest) {
(this._payload as CompletionRequest).tool_call_id = id;
}
}
public get isToolCallError(): boolean | undefined {
if (this.originalType === MessageType.ToolResult || this.currentType === MessageType.ToolResult) {
return !!(this._payload as ToolResult)?.isError;
}
return undefined;
}
public setToolCallError(isError: boolean | undefined): void {
if (this.originalType === MessageType.ToolResult || this.currentType === MessageType.ToolResult) {
this._payload = { ...this._payload, isError } as ToolResult;
}
}
public get finishReasons(): string | undefined {
if (this.originalType === MessageType.CompletionResult) {
return (this._payload as CompletionResult)?.choices?.find((c) => c.finish_reason)?.finish_reason;
}
return undefined;
}
// Usage
public get usage(): CompletionUsage | undefined {
if (this.originalType === MessageType.CompletionResult) {
return (this._payload as CompletionResult)?.usage;
}
return undefined;
}
public set usage(usage: CompletionUsage | undefined) {
if (this.originalType === MessageType.CompletionResult) {
(this._payload as CompletionResult).usage = usage;
}
}
// Batch helpers
static serializeAll(messages: Message[]): SerializedMessage[] {
return messages.map((m) => m.serialized);
}
static deserializeAll(data: SerializedMessage[]): Message[] {
return data.map((d) => Message.deserialize(d));
}
/**
* Convert to OpenAI-compatible API format for chat completions.
* Excludes error messages.
*/
static toAPIMessages(messages: Message[]): APIMessage[] {
return messages
.filter((m) => m.type !== MessageType.CompletionError)
.map((m): APIMessage => {
// When role has been changed, currentType differs from originalType —
// fall back to a generic conversion using the public getters.
if (m.currentType !== m.originalType) {
const msg: APIMessage = { role: m.role ?? MessageRole.ASSISTANT, content: m.content };
if (m.toolCalls && m.toolCalls.length > 0) msg.tool_calls = m.toolCalls;
if (m.toolCallId) msg.tool_call_id = m.toolCallId;
return msg;
}
switch (m.originalType) {
case MessageType.CompletionRequest: {
const p = m._payload as CompletionRequest;
const msg: APIMessage = { role: p.role, content: p.content };
if (p.tool_calls && p.tool_calls.length > 0) msg.tool_calls = p.tool_calls;
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
return msg;
}
case MessageType.CompletionResult: {
const choice = (m._payload as CompletionResult)?.choices?.[0]?.message;
const msg: APIMessage = {
role: choice?.role ?? MessageRole.ASSISTANT,
content: choice?.content ?? "",
};
if (choice?.tool_calls && choice.tool_calls.length > 0) {
msg.tool_calls = choice.tool_calls;
}
return msg;
}
case MessageType.ToolResult: {
const p = m._payload as ToolResult;
return {
role: MessageRole.TOOL,
content: p.content,
tool_call_id: p.tool_call_id,
};
}
default:
return { role: MessageRole.ASSISTANT, content: m.content };
}
});
}
/**
* Backward-compatible deserialization for old { role, content } format.
* Detects whether data is the new serialized format or old flat format.
*/
static fromLegacy(
data: SerializedMessage | { role: string; content: string | null; tool_calls?: ToolCall[]; tool_call_id?: string },
index: number,
): Message {
// New format has originalType
if ("originalType" in data && data.originalType) {
return Message.deserialize(data as SerializedMessage);
}
// Legacy { role, content } format
const legacy = data as { role: string; content: string | null; tool_calls?: ToolCall[]; tool_call_id?: string };
if (legacy.tool_calls && legacy.tool_calls.length > 0) {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [
{
index: 0,
message: {
role: MessageRole.ASSISTANT,
content: (legacy.content as string) ?? "",
tool_calls: legacy.tool_calls,
},
},
],
});
}
if (legacy.role === "tool" && legacy.tool_call_id) {
return new Message(uuidv4(), index, MessageType.ToolResult, {
role: MessageRole.TOOL,
content: (legacy.content as string) ?? "",
tool_call_id: legacy.tool_call_id,
});
}
const role = (legacy.role as MessageRole) ?? MessageRole.USER;
if (role === MessageRole.ASSISTANT) {
return new Message(uuidv4(), index, MessageType.CompletionResult, {
id: uuidv4(),
choices: [{ index: 0, message: { role: MessageRole.ASSISTANT, content: (legacy.content as string) ?? "" } }],
});
}
return new Message(uuidv4(), index, MessageType.CompletionRequest, {
role,
content: legacy.content,
} as CompletionRequest);
}
static fromLegacyAll(data: any[]): Message[] {
return data.map((d, i) => Message.fromLegacy(d, i));
}
}