Studio: prompt variables into prompt editor (#6434)

* add custom and system variable feature in system prompt

* missing function use

* feat: prompt variables editor ux

Co-authored-by: CodeMan62 <175127021+CodeMan62@users.noreply.github.com>

* fix: guard prompt variable defaults

* refine prompt variables editor layout

* fix: harden prompt variable substitution

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refine prompt variables editor copy and built-in token labels

---------

Co-authored-by: CodeMan62 <sharmahimanshu15082007@gmail.com>
Co-authored-by: CodeMan62 <175127021+CodeMan62@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Leo Borcherding <borchborchmail@gmail.com>
This commit is contained in:
Lee Jackson 2026-06-25 18:09:16 +01:00 committed by GitHub
commit c873ef052d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 322 additions and 16 deletions

View file

@ -150,6 +150,7 @@ class ChatInferenceSettings(BaseModel):
maxSeqLength: Optional[float] = None
maxTokens: Optional[float] = None
systemPrompt: Optional[str] = None
systemVariables: Optional[str] = None
trustRemoteCode: Optional[bool] = None
fastMode: Optional[bool] = None

View file

@ -185,6 +185,124 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseSystemVariablesMap(raw: string): Record<string, unknown> {
if (!raw.trim()) {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Invalid JSON: keep unresolved placeholders in output prompt.
}
return {};
}
function hasOwn(object: object, key: string): boolean {
return Object.prototype.hasOwnProperty.call(object, key);
}
function getNestedValue(
values: Record<string, unknown>,
path: string,
): unknown | undefined {
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return undefined;
}
let current: unknown = values;
for (const part of parts) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
if (!hasOwn(current, part)) {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
function padDatePart(value: number): string {
return String(value).padStart(2, "0");
}
function formatLocalDate(now: Date): string {
return [
now.getFullYear(),
padDatePart(now.getMonth() + 1),
padDatePart(now.getDate()),
].join("-");
}
function formatLocalTime(now: Date): string {
return [
padDatePart(now.getHours()),
padDatePart(now.getMinutes()),
padDatePart(now.getSeconds()),
].join(":");
}
function formatTimezoneOffset(now: Date): string {
const offsetMinutes = -now.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `${sign}${padDatePart(hours)}:${padDatePart(minutes)}`;
}
function stringifyTemplateValue(value: unknown): string {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function resolveSystemPromptVariables(
prompt: string,
customVariablesRaw: string,
): string {
if (!prompt) {
return prompt;
}
const now = new Date();
const localDate = formatLocalDate(now);
const localTime = formatLocalTime(now);
const systemVariables: Record<string, string> = {
$date: localDate,
$time: localTime,
$now: `${localDate}T${localTime}${formatTimezoneOffset(now)}`,
};
const customVariables = parseSystemVariablesMap(customVariablesRaw);
return prompt.replaceAll(
/{{\s*([a-zA-Z_$][a-zA-Z0-9_$.-]*)\s*}}/g,
(full, keyRaw) => {
const key = String(keyRaw).trim();
if (hasOwn(systemVariables, key)) {
return systemVariables[key] ?? full;
}
const resolved = getNestedValue(customVariables, key);
if (resolved === undefined) {
return full;
}
return stringifyTemplateValue(resolved);
},
);
}
export const ThreadAutosaveHandle: ThreadAutosaveHandle = {
registerFirstSave(threadId, promise) {
const trackedPromise = promise.catch(() => {});
@ -1778,7 +1896,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
const safeSystemPrompt =
typeof params.systemPrompt === "string" ? params.systemPrompt : "";
typeof params.systemPrompt === "string"
? resolveSystemPromptVariables(
params.systemPrompt,
typeof params.systemVariables === "string"
? params.systemVariables
: "",
)
: "";
const projectInstructions =
await resolveProjectInstructions(resolvedThreadId);
const combinedSystemPrompt = [

View file

@ -74,7 +74,7 @@ import {
} from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ExternalLink } from "lucide-react";
import { Braces, ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Fragment, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -116,10 +116,32 @@ import type { InferenceParams } from "./types/runtime";
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
export type { InferenceParams } from "./types/runtime";
const PROMPT_VARIABLE_PATTERN = /{{\s*[a-zA-Z_$][a-zA-Z0-9_$.-]*\s*}}/;
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
function getPromptVariablesError(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return null;
}
} catch {
return "Use valid JSON, for example { \"env\": \"staging\" }.";
}
return "Variables must be a JSON object.";
}
function hasPromptVariableSyntax(prompt: string): boolean {
return PROMPT_VARIABLE_PATTERN.test(prompt);
}
/**
* Editable numeric value display, shared by every slider value and the Context
* Length input. An <input> that looks like text (shows `displayValue ?? value`,
@ -655,6 +677,8 @@ export function ChatSettingsPanel({
const [presetNameInput, setPresetNameInput] = useState(activePreset);
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
const [systemPromptDraft, setSystemPromptDraft] = useState("");
const [systemVariablesDraft, setSystemVariablesDraft] = useState("");
const [systemVariablesOpen, setSystemVariablesOpen] = useState(false);
// When the prompt overflows the inline box, clicking opens the popup editor.
const systemPromptBoxRef = useRef<HTMLTextAreaElement>(null);
const [systemPromptOverflows, setSystemPromptOverflows] = useState(false);
@ -697,7 +721,12 @@ export function ChatSettingsPanel({
}),
[activePreset, hasUnsavedPresetChanges, presetNameInput, presets],
);
const systemPromptEditorDirty = systemPromptDraft !== params.systemPrompt;
const systemVariablesError = getPromptVariablesError(systemVariablesDraft);
const currentSystemPrompt = params.systemPrompt ?? "";
const currentSystemVariables = params.systemVariables ?? "";
const systemPromptEditorDirty =
systemPromptDraft !== currentSystemPrompt ||
systemVariablesDraft !== currentSystemVariables;
const showPromptCacheTtlControl = Boolean(
activeExternalProvider &&
supportsProviderPromptCacheTtl(activeExternalProvider.providerType),
@ -808,12 +837,32 @@ export function ChatSettingsPanel({
}
function openSystemPromptEditor() {
setSystemPromptDraft(params.systemPrompt);
setSystemPromptDraft(currentSystemPrompt);
setSystemVariablesDraft(currentSystemVariables);
setSystemVariablesOpen(
currentSystemVariables.trim().length > 0 ||
hasPromptVariableSyntax(currentSystemPrompt),
);
setSystemPromptEditorOpen(true);
}
function saveSystemPromptEditor() {
set("systemPrompt")(systemPromptDraft);
if (systemVariablesError) {
toast.error("Fix prompt variables before saving", {
description: systemVariablesError,
});
return;
}
const nextParams = {
...params,
systemPrompt: systemPromptDraft,
systemVariables: systemVariablesDraft.trim(),
};
const nextSource = isSamePresetConfig(activePresetBaseline, nextParams)
? getPresetSource(activePreset)
: "modified";
setActivePresetSource(nextSource);
onParamsChange(nextParams);
setSystemPromptEditorOpen(false);
}
@ -861,12 +910,12 @@ export function ChatSettingsPanel({
useEffect(() => {
const el = systemPromptBoxRef.current;
setSystemPromptOverflows(
params.systemPrompt.length > 0 &&
currentSystemPrompt.length > 0 &&
el != null &&
el.clientHeight > 0 &&
el.scrollHeight > el.clientHeight + 1,
);
}, [params.systemPrompt, open]);
}, [currentSystemPrompt, open]);
const settingsScrollRef = useRef<HTMLDivElement>(null);
@ -1538,7 +1587,7 @@ export function ChatSettingsPanel({
>
<textarea
ref={systemPromptBoxRef}
value={params.systemPrompt}
value={currentSystemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
onMouseDown={(e) => {
// Overflowing prompt: click opens the popup editor instead.
@ -1561,6 +1610,7 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection label="Sampling" defaultOpen={true}>
<div className="flex flex-col gap-5 pt-1">
{showTemperature ? (
@ -1712,20 +1762,96 @@ export function ChatSettingsPanel({
the preset.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<div className="space-y-3">
<div className="space-y-0.5 px-0.5">
<div className="text-[11px] font-medium">Prompt editor</div>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-medium">Prompt editor</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSystemVariablesOpen((open) => !open)}
className="h-7 gap-1.5 rounded-full px-2.5 text-[11px] text-muted-foreground"
aria-expanded={systemVariablesOpen}
>
<Braces className="size-3.5" />
Variables
<ChevronDown
className={cn(
"size-3.5 transition-transform",
systemVariablesOpen && "rotate-180",
)}
/>
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Use this for longer edits. Save writes back to the active
configuration only.
configuration only. Insert variables with {"{{ env }}"}.
</p>
</div>
{systemVariablesOpen ? (
<div className="space-y-2 px-0.5">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="space-y-0.5">
<div className="text-[11px] font-medium">
Prompt variables
</div>
<p className="text-[11px] text-muted-foreground">
Define values as JSON below, then use each key in your
prompt, like {"{{ env }}"}.
</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="text-[10px] text-muted-foreground">
Built-in, fill in automatically
</span>
<div className="flex flex-wrap justify-end gap-1">
{["{{$date}}", "{{$time}}", "{{$now}}"].map((token) => (
<span
key={token}
title={`${token} is replaced automatically when you send`}
className="rounded-full bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground"
>
{token}
</span>
))}
</div>
</div>
</div>
<Textarea
value={systemVariablesDraft}
onChange={(event) =>
setSystemVariablesDraft(event.target.value)
}
placeholder='{ "env": "staging", "version": "v2.3.1" }'
fieldSizing="fixed"
className={cn(
"min-h-24 border-0 font-mono text-xs leading-5 corner-squircle focus-visible:ring-0",
systemVariablesError &&
"ring-1 ring-destructive focus-visible:ring-destructive",
)}
rows={5}
aria-label="Prompt variables JSON"
aria-invalid={Boolean(systemVariablesError)}
/>
{systemVariablesError ? (
<p className="px-1 text-[11px] text-destructive">
{systemVariablesError}
</p>
) : (
<p className="px-1 text-[11px] text-muted-foreground">
Names you don&apos;t define are left unchanged, so a stray
{" {{ typo }} "}stays visible in the prompt.
</p>
)}
</div>
) : null}
<Textarea
value={systemPromptDraft}
onChange={(event) => setSystemPromptDraft(event.target.value)}
placeholder="You are a helpful assistant..."
fieldSizing="fixed"
className="min-h-[24rem] max-h-[50vh] overflow-y-auto border-0 text-sm leading-6 corner-squircle focus-visible:ring-0"
className="min-h-[20rem] max-h-[48vh] overflow-y-auto border-0 text-sm leading-6 corner-squircle focus-visible:ring-0"
rows={14}
/>
</div>
@ -1744,7 +1870,8 @@ export function ChatSettingsPanel({
type="button"
variant="ghost"
onClick={() => {
setSystemPromptDraft(params.systemPrompt);
setSystemPromptDraft(currentSystemPrompt);
setSystemVariablesDraft(currentSystemVariables);
setSystemPromptEditorOpen(false);
}}
>
@ -1753,7 +1880,9 @@ export function ChatSettingsPanel({
<Button
type="button"
onClick={saveSystemPromptEditor}
disabled={!systemPromptEditorDirty}
disabled={
!systemPromptEditorDirty || Boolean(systemVariablesError)
}
>
Save
</Button>

View file

@ -23,6 +23,7 @@ export type PresetOwnedParams = Pick<
| "presencePenalty"
| "maxTokens"
| "systemPrompt"
| "systemVariables"
>;
export const BUILTIN_PRESETS: Preset[] = [
@ -104,7 +105,8 @@ export function getPresetOwnedParams(
repetitionPenalty: params.repetitionPenalty,
presencePenalty: params.presencePenalty,
maxTokens: params.maxTokens,
systemPrompt: params.systemPrompt,
systemPrompt: params.systemPrompt ?? "",
systemVariables: params.systemVariables ?? "",
};
}
@ -122,7 +124,8 @@ export function isSamePresetConfig(
left.repetitionPenalty === right.repetitionPenalty &&
left.presencePenalty === right.presencePenalty &&
left.maxTokens === right.maxTokens &&
left.systemPrompt === right.systemPrompt
left.systemPrompt === right.systemPrompt &&
left.systemVariables === right.systemVariables
);
}

View file

@ -837,6 +837,7 @@ const PERSISTED_INFERENCE_PARAM_KEYS = [
"maxSeqLength",
"maxTokens",
"systemPrompt",
"systemVariables",
"trustRemoteCode",
"fastMode",
] as const satisfies readonly PersistedInferenceParamKey[];

View file

@ -11,6 +11,7 @@ export interface InferenceParams {
maxSeqLength: number;
maxTokens: number;
systemPrompt: string;
systemVariables: string;
checkpoint: string;
/** Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust. */
trustRemoteCode?: boolean;
@ -32,6 +33,7 @@ export const DEFAULT_INFERENCE_PARAMS: InferenceParams = {
maxSeqLength: 4096,
maxTokens: 8192,
systemPrompt: "",
systemVariables: "",
checkpoint: "",
trustRemoteCode: false,
fastMode: false,

View file

@ -140,6 +140,9 @@ function sanitizeInferenceParams(
if (typeof value.systemPrompt === "string") {
params.systemPrompt = value.systemPrompt;
}
if (typeof value.systemVariables === "string") {
params.systemVariables = value.systemVariables;
}
// trustRemoteCode is no longer persisted: custom code is consented per model via the dialog.
if (typeof value.fastMode === "boolean") {
params.fastMode = value.fastMode;

View file

@ -0,0 +1,42 @@
"""Regression checks for system prompt variable substitution."""
from __future__ import annotations
from pathlib import Path
WORKSPACE = Path(__file__).resolve().parents[2]
ADAPTER_SRC = (WORKSPACE / "studio/frontend/src/features/chat/api/chat-adapter.ts").read_text()
def _function_source(name: str) -> str:
start = ADAPTER_SRC.index(f"function {name}")
body_start = ADAPTER_SRC.index("{", start)
depth = 0
for index in range(body_start, len(ADAPTER_SRC)):
if ADAPTER_SRC[index] == "{":
depth += 1
elif ADAPTER_SRC[index] == "}":
depth -= 1
if depth == 0:
return ADAPTER_SRC[start : index + 1]
raise AssertionError(f"Could not parse function body for {name}")
def test_prompt_variable_builtins_use_local_time_helpers():
resolver = _function_source("resolveSystemPromptVariables")
assert "formatLocalDate(now)" in resolver
assert "formatLocalTime(now)" in resolver
assert "formatTimezoneOffset(now)" in resolver
assert "toISOString()" not in resolver
def test_prompt_variable_builtins_use_own_property_lookup():
resolver = _function_source("resolveSystemPromptVariables")
assert "if (hasOwn(systemVariables, key))" in resolver
assert "key in systemVariables" not in resolver
def test_prompt_variable_nested_lookup_ignores_prototype_properties():
nested_lookup = _function_source("getNestedValue")
assert "if (!hasOwn(current, part))" in nested_lookup