first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View 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
View 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
View 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
View 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;
};

View 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: "" });
});
});

View 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;
}