first commit
This commit is contained in:
36
ui/lib/message/constant.ts
Normal file
36
ui/lib/message/constant.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Jinja2 variable utilities for prompt messages.
|
||||
*
|
||||
* Extracts `{{ variable_name }}` patterns from message content and provides
|
||||
* substitution at execution time. Supports basic Jinja2 variable syntax:
|
||||
* {{ name }}
|
||||
* {{ user_name }}
|
||||
* {{ some.nested }} (treated as a flat key "some.nested")
|
||||
*
|
||||
* Filters ({{ x | upper }}) and expressions are NOT evaluated — only
|
||||
* simple variable references are extracted and replaced.
|
||||
*/
|
||||
|
||||
/** Matches {{ variable_name }} with optional whitespace inside braces */
|
||||
export const JINJA_VAR_REGEX = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* Highlight patterns for Jinja2 variables in rich textareas
|
||||
*/
|
||||
export const JINJA_VAR_HIGHLIGHT_PATTERNS = [
|
||||
{
|
||||
pattern: /\{\{\s*[a-zA-Z_][a-zA-Z0-9_.]*\s*\}\}/g,
|
||||
className: "outline-content-brand-light text-sm cursor-pointer bg-green-500/20",
|
||||
validate: (part: string) => {
|
||||
return (
|
||||
part.startsWith?.("{{") &&
|
||||
part.endsWith?.("}}") &&
|
||||
!part.slice(2, -2).includes("{") &&
|
||||
!part.slice(2, -2).includes("}") &&
|
||||
!part.slice(2, -2).includes("'") &&
|
||||
!part.slice(2, -2).includes('"')
|
||||
);
|
||||
},
|
||||
enableVariableClickEdit: true,
|
||||
},
|
||||
];
|
||||
27
ui/lib/message/index.ts
Normal file
27
ui/lib/message/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export { Message } from "./message";
|
||||
export {
|
||||
MessageRole,
|
||||
MessageType,
|
||||
type APIMessage,
|
||||
type CompletionRequest,
|
||||
type CompletionResult,
|
||||
type CompletionResultChoice,
|
||||
type CompletionUsage,
|
||||
type MessageContent,
|
||||
type MessageError,
|
||||
type MessageFile,
|
||||
type MessageImageURL,
|
||||
type MessageInputAudio,
|
||||
type SerializedMessage,
|
||||
type ToolCall,
|
||||
type ToolCallFunction,
|
||||
type ToolResult,
|
||||
} from "./types";
|
||||
export {
|
||||
extractVariablesFromText,
|
||||
extractVariablesFromMessages,
|
||||
replaceVariablesInText,
|
||||
replaceVariablesInMessages,
|
||||
mergeVariables,
|
||||
type VariableMap,
|
||||
} from "./variables";
|
||||
440
ui/lib/message/message.ts
Normal file
440
ui/lib/message/message.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
107
ui/lib/message/types.ts
Normal file
107
ui/lib/message/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export enum MessageType {
|
||||
CompletionRequest = "completion_request",
|
||||
CompletionResult = "completion_result",
|
||||
CompletionError = "error",
|
||||
ToolResult = "tool_result",
|
||||
}
|
||||
|
||||
export enum MessageRole {
|
||||
ASSISTANT = "assistant",
|
||||
USER = "user",
|
||||
SYSTEM = "system",
|
||||
TOOL = "tool",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export type ToolCallFunction = {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
type: "function";
|
||||
id: string;
|
||||
function: ToolCallFunction;
|
||||
};
|
||||
|
||||
export type MessageContent = {
|
||||
type: "text" | "image_url" | "input_audio" | "file";
|
||||
text?: string;
|
||||
image_url?: MessageImageURL;
|
||||
input_audio?: MessageInputAudio;
|
||||
file?: MessageFile;
|
||||
};
|
||||
|
||||
export type MessageImageURL = {
|
||||
url: string;
|
||||
detail?: "auto" | "low" | "high";
|
||||
};
|
||||
|
||||
export type MessageInputAudio = {
|
||||
data: string;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export type MessageFile = {
|
||||
file_data?: string;
|
||||
file_id?: string;
|
||||
filename?: string;
|
||||
file_type?: string;
|
||||
};
|
||||
|
||||
export type CompletionRequest = {
|
||||
role: MessageRole;
|
||||
content: string | MessageContent[] | null;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
};
|
||||
|
||||
export type CompletionResultChoice = {
|
||||
index: number;
|
||||
message: {
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
tool_calls?: ToolCall[];
|
||||
};
|
||||
finish_reason?: string;
|
||||
};
|
||||
|
||||
export type CompletionUsage = {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
export type CompletionResult = {
|
||||
id: string;
|
||||
choices: CompletionResultChoice[];
|
||||
usage?: CompletionUsage;
|
||||
};
|
||||
|
||||
export type ToolResult = {
|
||||
role: MessageRole.TOOL;
|
||||
content: string;
|
||||
tool_call_id: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type MessageError = {
|
||||
code: string | number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SerializedMessage = {
|
||||
id: string;
|
||||
index: number;
|
||||
originalType: MessageType;
|
||||
currentType: MessageType;
|
||||
payload?: CompletionRequest | CompletionResult | ToolResult;
|
||||
error?: MessageError;
|
||||
};
|
||||
|
||||
export type APIMessage = {
|
||||
role: MessageRole;
|
||||
content: string | MessageContent[] | null;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
};
|
||||
363
ui/lib/message/variables.test.ts
Normal file
363
ui/lib/message/variables.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Message } from "./message";
|
||||
import {
|
||||
extractVariablesFromMessages,
|
||||
extractVariablesFromText,
|
||||
mergeVariables,
|
||||
replaceVariablesInMessages,
|
||||
replaceVariablesInText,
|
||||
} from "./variables";
|
||||
|
||||
// =============================================================================
|
||||
// extractVariablesFromText
|
||||
// =============================================================================
|
||||
|
||||
describe("extractVariablesFromText", () => {
|
||||
it("extracts a single variable", () => {
|
||||
expect(extractVariablesFromText("Hello {{ name }}")).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("extracts multiple distinct variables", () => {
|
||||
expect(extractVariablesFromText("{{ greeting }}, {{ name }}!")).toEqual(["greeting", "name"]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated variables", () => {
|
||||
expect(extractVariablesFromText("{{ x }} and {{ x }}")).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no variables exist", () => {
|
||||
expect(extractVariablesFromText("Hello world")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty string", () => {
|
||||
expect(extractVariablesFromText("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles variable with no spaces inside braces", () => {
|
||||
expect(extractVariablesFromText("{{name}}")).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("handles variable with extra whitespace inside braces", () => {
|
||||
expect(extractVariablesFromText("{{ name }}")).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("handles underscored variable names", () => {
|
||||
expect(extractVariablesFromText("{{ user_name }}")).toEqual(["user_name"]);
|
||||
});
|
||||
|
||||
it("handles dot-notation variable names", () => {
|
||||
expect(extractVariablesFromText("{{ user.name }}")).toEqual(["user.name"]);
|
||||
});
|
||||
|
||||
it("handles variables with numbers in name", () => {
|
||||
expect(extractVariablesFromText("{{ item1 }} {{ item2 }}")).toEqual(["item1", "item2"]);
|
||||
});
|
||||
|
||||
it("handles variable starting with underscore", () => {
|
||||
expect(extractVariablesFromText("{{ _private }}")).toEqual(["_private"]);
|
||||
});
|
||||
|
||||
it("does not extract variables starting with a number", () => {
|
||||
expect(extractVariablesFromText("{{ 1abc }}")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not extract variables with special characters", () => {
|
||||
expect(extractVariablesFromText("{{ na-me }}")).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles multiline text with variables", () => {
|
||||
const text = `Line one {{ first }}
|
||||
Line two {{ second }}
|
||||
Line three`;
|
||||
expect(extractVariablesFromText(text)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("ignores jinja2 block tags", () => {
|
||||
expect(extractVariablesFromText("{% if condition %}yes{% endif %}")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores jinja2 comments", () => {
|
||||
expect(extractVariablesFromText("{# this is a comment #}")).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts variables adjacent to jinja2 block tags", () => {
|
||||
const text = "{% if show %}{{ name }}{% endif %}";
|
||||
expect(extractVariablesFromText(text)).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("handles triple braces (not valid jinja2) gracefully", () => {
|
||||
// {{{ name }}} — the regex should still find "name" from the inner {{ }}
|
||||
const result = extractVariablesFromText("{{{ name }}}");
|
||||
expect(result).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("handles variables embedded in longer text", () => {
|
||||
const text = "Dear {{ title }} {{ last_name }}, your order #{{ order_id }} is ready.";
|
||||
expect(extractVariablesFromText(text)).toEqual(["title", "last_name", "order_id"]);
|
||||
});
|
||||
|
||||
// Regression: calling extractVariablesFromText consecutively should work
|
||||
// (ensures regex lastIndex is properly reset)
|
||||
it("works correctly when called multiple times in succession", () => {
|
||||
expect(extractVariablesFromText("{{ a }}")).toEqual(["a"]);
|
||||
expect(extractVariablesFromText("{{ b }}")).toEqual(["b"]);
|
||||
expect(extractVariablesFromText("{{ a }} {{ c }}")).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// extractVariablesFromMessages
|
||||
// =============================================================================
|
||||
|
||||
describe("extractVariablesFromMessages", () => {
|
||||
it("extracts variables from a system message", () => {
|
||||
const messages = [Message.system("You are {{ role }}")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual(["role"]);
|
||||
});
|
||||
|
||||
it("extracts variables from a user message", () => {
|
||||
const messages = [Message.request("Tell me about {{ topic }}")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual(["topic"]);
|
||||
});
|
||||
|
||||
it("extracts variables from an assistant message", () => {
|
||||
const messages = [Message.response("Hello {{ name }}")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts variables across multiple messages", () => {
|
||||
const messages = [
|
||||
Message.system("You are {{ role }}"),
|
||||
Message.request("Tell me about {{ topic }}"),
|
||||
Message.response("The {{ topic }} is interesting"),
|
||||
];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual(["role", "topic"]);
|
||||
});
|
||||
|
||||
it("deduplicates across messages", () => {
|
||||
const messages = [Message.system("{{ name }}"), Message.request("{{ name }}")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no messages have variables", () => {
|
||||
const messages = [Message.system("You are a helpful assistant"), Message.request("Hello")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty messages array", () => {
|
||||
expect(extractVariablesFromMessages([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles messages with empty content", () => {
|
||||
const messages = [Message.system("")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles error messages gracefully", () => {
|
||||
const messages = [Message.error("Something went wrong")];
|
||||
expect(extractVariablesFromMessages(messages)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// replaceVariablesInText
|
||||
// =============================================================================
|
||||
|
||||
describe("replaceVariablesInText", () => {
|
||||
it("replaces a single variable", () => {
|
||||
expect(replaceVariablesInText("Hello {{ name }}", { name: "World" })).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("replaces multiple variables", () => {
|
||||
const result = replaceVariablesInText("{{ greeting }}, {{ name }}!", {
|
||||
greeting: "Hi",
|
||||
name: "Alice",
|
||||
});
|
||||
expect(result).toBe("Hi, Alice!");
|
||||
});
|
||||
|
||||
it("replaces all occurrences of the same variable", () => {
|
||||
expect(replaceVariablesInText("{{ x }} and {{ x }}", { x: "yes" })).toBe("yes and yes");
|
||||
});
|
||||
|
||||
it("preserves leading curly braces that are not part of variables", () => {
|
||||
expect(replaceVariablesInText("{{{ x }} and {{ x }}", { x: "yes" })).toBe("{yes and yes");
|
||||
});
|
||||
|
||||
it("preserves trailing curly braces that are not part of variables", () => {
|
||||
expect(replaceVariablesInText("{{ x }} and {{ x }}}}", { x: "yes" })).toBe("yes and yes}}");
|
||||
});
|
||||
|
||||
it("leaves variable untouched when not in map", () => {
|
||||
expect(replaceVariablesInText("{{ unknown }}", {})).toBe("{{ unknown }}");
|
||||
});
|
||||
|
||||
it("leaves variable untouched when value is empty string", () => {
|
||||
expect(replaceVariablesInText("{{ name }}", { name: "" })).toBe("{{ name }}");
|
||||
});
|
||||
|
||||
it("returns original text when no variables present", () => {
|
||||
expect(replaceVariablesInText("Hello world", { name: "test" })).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(replaceVariablesInText("", { name: "test" })).toBe("");
|
||||
});
|
||||
|
||||
it("handles replacement value containing special regex characters", () => {
|
||||
expect(replaceVariablesInText("{{ val }}", { val: "$100.00" })).toBe("$100.00");
|
||||
});
|
||||
|
||||
it("handles replacement value containing curly braces", () => {
|
||||
expect(replaceVariablesInText("{{ val }}", { val: "{{ nested }}" })).toBe("{{ nested }}");
|
||||
});
|
||||
|
||||
it("handles variable with no spaces in braces", () => {
|
||||
expect(replaceVariablesInText("{{name}}", { name: "Bob" })).toBe("Bob");
|
||||
});
|
||||
|
||||
it("handles variable with extra whitespace in braces", () => {
|
||||
expect(replaceVariablesInText("{{ name }}", { name: "Bob" })).toBe("Bob");
|
||||
});
|
||||
|
||||
it("replaces only known variables and leaves others", () => {
|
||||
const result = replaceVariablesInText("{{ known }} and {{ unknown }}", { known: "yes" });
|
||||
expect(result).toBe("yes and {{ unknown }}");
|
||||
});
|
||||
|
||||
it("handles multiline text replacement", () => {
|
||||
const text = `Hello {{ name }},
|
||||
Your order {{ order_id }} is ready.`;
|
||||
const result = replaceVariablesInText(text, { name: "Alice", order_id: "12345" });
|
||||
expect(result).toBe(`Hello Alice,
|
||||
Your order 12345 is ready.`);
|
||||
});
|
||||
|
||||
it("handles dot-notation variables", () => {
|
||||
expect(replaceVariablesInText("{{ user.name }}", { "user.name": "Alice" })).toBe("Alice");
|
||||
});
|
||||
|
||||
// Regression: consecutive calls should work (lastIndex reset)
|
||||
it("works correctly when called multiple times in succession", () => {
|
||||
expect(replaceVariablesInText("{{ a }}", { a: "1" })).toBe("1");
|
||||
expect(replaceVariablesInText("{{ b }}", { b: "2" })).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// replaceVariablesInMessages
|
||||
// =============================================================================
|
||||
|
||||
describe("replaceVariablesInMessages", () => {
|
||||
it("replaces variables in message content", () => {
|
||||
const messages = [Message.system("You are {{ role }}")];
|
||||
const result = replaceVariablesInMessages(messages, { role: "a pirate" });
|
||||
expect(result[0].content).toBe("You are a pirate");
|
||||
});
|
||||
|
||||
it("does not mutate original messages", () => {
|
||||
const messages = [Message.system("You are {{ role }}")];
|
||||
replaceVariablesInMessages(messages, { role: "a pirate" });
|
||||
expect(messages[0].content).toBe("You are {{ role }}");
|
||||
});
|
||||
|
||||
it("returns original messages array when all variable values are empty", () => {
|
||||
const messages = [Message.system("You are {{ role }}")];
|
||||
const result = replaceVariablesInMessages(messages, { role: "" });
|
||||
expect(result).toBe(messages); // same reference — fast path
|
||||
});
|
||||
|
||||
it("returns original messages array when variables map is empty", () => {
|
||||
const messages = [Message.system("You are {{ role }}")];
|
||||
const result = replaceVariablesInMessages(messages, {});
|
||||
expect(result).toBe(messages);
|
||||
});
|
||||
|
||||
it("replaces variables across multiple messages", () => {
|
||||
const messages = [Message.system("You are {{ role }}"), Message.request("Tell me about {{ topic }}")];
|
||||
const result = replaceVariablesInMessages(messages, { role: "a teacher", topic: "math" });
|
||||
expect(result[0].content).toBe("You are a teacher");
|
||||
expect(result[1].content).toBe("Tell me about math");
|
||||
});
|
||||
|
||||
it("preserves messages without variables unchanged", () => {
|
||||
const messages = [Message.system("Hello"), Message.request("{{ name }}")];
|
||||
const result = replaceVariablesInMessages(messages, { name: "Alice" });
|
||||
expect(result[0].content).toBe("Hello");
|
||||
expect(result[1].content).toBe("Alice");
|
||||
});
|
||||
|
||||
it("preserves message count", () => {
|
||||
const messages = [Message.system("{{ a }}"), Message.request("{{ b }}"), Message.response("{{ c }}")];
|
||||
const result = replaceVariablesInMessages(messages, { a: "1", b: "2", c: "3" });
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("handles empty messages array", () => {
|
||||
const result = replaceVariablesInMessages([], { name: "Alice" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles messages with empty content", () => {
|
||||
const messages = [Message.system("")];
|
||||
const result = replaceVariablesInMessages(messages, { name: "Alice" });
|
||||
expect(result[0].content).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// mergeVariables
|
||||
// =============================================================================
|
||||
|
||||
describe("mergeVariables", () => {
|
||||
it("creates entries for new variable names with empty values", () => {
|
||||
expect(mergeVariables({}, ["name", "topic"])).toEqual({
|
||||
name: "",
|
||||
topic: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing values for variables that still exist", () => {
|
||||
const current = { name: "Alice", topic: "math" };
|
||||
const result = mergeVariables(current, ["name", "topic"]);
|
||||
expect(result).toEqual({ name: "Alice", topic: "math" });
|
||||
});
|
||||
|
||||
it("drops variables no longer in the new names list", () => {
|
||||
const current = { name: "Alice", old_var: "value" };
|
||||
const result = mergeVariables(current, ["name"]);
|
||||
expect(result).toEqual({ name: "Alice" });
|
||||
expect(result).not.toHaveProperty("old_var");
|
||||
});
|
||||
|
||||
it("adds new variables while preserving existing ones", () => {
|
||||
const current = { name: "Alice" };
|
||||
const result = mergeVariables(current, ["name", "topic"]);
|
||||
expect(result).toEqual({ name: "Alice", topic: "" });
|
||||
});
|
||||
|
||||
it("handles empty current variables", () => {
|
||||
expect(mergeVariables({}, ["a", "b"])).toEqual({ a: "", b: "" });
|
||||
});
|
||||
|
||||
it("handles empty new names (returns empty map)", () => {
|
||||
const current = { name: "Alice", topic: "math" };
|
||||
expect(mergeVariables(current, [])).toEqual({});
|
||||
});
|
||||
|
||||
it("handles both empty", () => {
|
||||
expect(mergeVariables({}, [])).toEqual({});
|
||||
});
|
||||
|
||||
it("does not mutate the original variables map", () => {
|
||||
const current = { name: "Alice" };
|
||||
mergeVariables(current, ["name", "topic"]);
|
||||
expect(current).toEqual({ name: "Alice" });
|
||||
});
|
||||
|
||||
it("preserves empty string values for existing variables", () => {
|
||||
const current = { name: "" };
|
||||
const result = mergeVariables(current, ["name"]);
|
||||
expect(result).toEqual({ name: "" });
|
||||
});
|
||||
});
|
||||
87
ui/lib/message/variables.ts
Normal file
87
ui/lib/message/variables.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { JINJA_VAR_REGEX } from "./constant";
|
||||
import type { Message } from "./message";
|
||||
import { MessageRole } from "./types";
|
||||
|
||||
/** A map of variable name → user-supplied value */
|
||||
export type VariableMap = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Extract all unique Jinja2 variable names from a single string.
|
||||
*/
|
||||
export function extractVariablesFromText(text: string): string[] {
|
||||
const vars = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
// Reset lastIndex to be safe
|
||||
JINJA_VAR_REGEX.lastIndex = 0;
|
||||
while ((match = JINJA_VAR_REGEX.exec(text)) !== null) {
|
||||
vars.add(match[1]);
|
||||
}
|
||||
return Array.from(vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all unique Jinja2 variable names from an array of Messages.
|
||||
* Scans content of every message (system, user, assistant, tool).
|
||||
*/
|
||||
export function extractVariablesFromMessages(messages: Message[]): string[] {
|
||||
const vars = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.role === MessageRole.ASSISTANT || msg.role === MessageRole.TOOL) continue;
|
||||
const content = msg.content;
|
||||
if (!content) continue;
|
||||
for (const v of extractVariablesFromText(content)) {
|
||||
vars.add(v);
|
||||
}
|
||||
}
|
||||
return Array.from(vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace Jinja2 variables in a string with values from the provided map.
|
||||
* Variables without a value in the map are left untouched.
|
||||
*/
|
||||
export function replaceVariablesInText(text: string, variables: VariableMap): string {
|
||||
return text.replace(JINJA_VAR_REGEX, (fullMatch, varName: string) => {
|
||||
if (varName in variables && variables[varName] !== "") {
|
||||
return variables[varName];
|
||||
}
|
||||
return fullMatch;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create clones of messages with all Jinja2 variables replaced.
|
||||
* Original messages are NOT mutated.
|
||||
*/
|
||||
export function replaceVariablesInMessages(messages: Message[], variables: VariableMap): Message[] {
|
||||
// Fast path: nothing to replace
|
||||
const hasVars = Object.values(variables).some((v) => v !== "");
|
||||
if (!hasVars) return messages;
|
||||
|
||||
return messages.map((msg) => {
|
||||
const content = msg.content;
|
||||
if (!content || !JINJA_VAR_REGEX.test(content)) {
|
||||
// Reset lastIndex after test
|
||||
JINJA_VAR_REGEX.lastIndex = 0;
|
||||
return msg;
|
||||
}
|
||||
JINJA_VAR_REGEX.lastIndex = 0;
|
||||
const clone = msg.clone();
|
||||
clone.content = replaceVariablesInText(content, variables);
|
||||
return clone;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge existing variable values with a new set of variable names.
|
||||
* - Keeps values for variables that still exist
|
||||
* - Adds empty values for new variables
|
||||
* - Drops variables that no longer exist in messages
|
||||
*/
|
||||
export function mergeVariables(currentVars: VariableMap, newVarNames: string[]): VariableMap {
|
||||
const merged: VariableMap = {};
|
||||
for (const name of newVarNames) {
|
||||
merged[name] = currentVars[name] ?? "";
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
Reference in New Issue
Block a user