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:
parent
a636693019
commit
c873ef052d
8 changed files with 322 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -837,6 +837,7 @@ const PERSISTED_INFERENCE_PARAM_KEYS = [
|
|||
"maxSeqLength",
|
||||
"maxTokens",
|
||||
"systemPrompt",
|
||||
"systemVariables",
|
||||
"trustRemoteCode",
|
||||
"fastMode",
|
||||
] as const satisfies readonly PersistedInferenceParamKey[];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
42
tests/studio/test_chat_prompt_variables.py
Normal file
42
tests/studio/test_chat_prompt_variables.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue