diff --git a/studio/backend/routes/chat_history.py b/studio/backend/routes/chat_history.py index 1243b284b4..2c87ce8c6e 100644 --- a/studio/backend/routes/chat_history.py +++ b/studio/backend/routes/chat_history.py @@ -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 diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 5e8fc9cb25..0106980871 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -185,6 +185,124 @@ function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function parseSystemVariablesMap(raw: string): Record { + if (!raw.trim()) { + return {}; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } 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, + 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)[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 = { + $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 = [ diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index f5136e0181..74578b9438 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -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 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(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(null); @@ -1538,7 +1587,7 @@ export function ChatSettingsPanel({ >