feat(frontend): add dynamic variable prompt editor

This commit is contained in:
Xin Wang
2026-08-25 14:02:04 +08:00
parent 9e4dfbe596
commit 26582bfb87
5 changed files with 732 additions and 32 deletions
@@ -0,0 +1,102 @@
export const DYNAMIC_VARIABLE_PATTERN =
/{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
export const SYSTEM_DYNAMIC_VARIABLES = [
["system__conversation_id", "会话 ID"],
["system__time", "当前时间"],
["system__timezone", "会话时区"],
["system__agent_turns", "助手轮次"],
["system__conversation_history", "会话历史"],
] as const;
const SYSTEM_DYNAMIC_VARIABLE_NAMES = new Set<string>(
SYSTEM_DYNAMIC_VARIABLES.map(([name]) => name),
);
const DYNAMIC_VARIABLE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
export type DynamicVariableToken = {
start: number;
end: number;
name: string;
source: "custom" | "system";
};
export type DynamicVariableTrigger = {
start: number;
query: string;
};
export function isPublicDynamicVariableName(name: string): boolean {
return (
DYNAMIC_VARIABLE_NAME_PATTERN.test(name) &&
!name.startsWith("system__") &&
!name.startsWith("secret__")
);
}
export function isKnownSystemDynamicVariableName(name: string): boolean {
return SYSTEM_DYNAMIC_VARIABLE_NAMES.has(name);
}
export function extractDynamicVariableNames(...templates: string[]): string[] {
const names = new Set<string>();
for (const template of templates) {
for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
if (isPublicDynamicVariableName(match[1])) names.add(match[1]);
}
}
return [...names];
}
export function findDynamicVariableTokens(
template: string,
): DynamicVariableToken[] {
const tokens: DynamicVariableToken[] = [];
for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
const name = match[1];
const start = match.index;
const source = isPublicDynamicVariableName(name)
? "custom"
: isKnownSystemDynamicVariableName(name)
? "system"
: null;
if (source && start !== undefined) {
tokens.push({
start,
end: start + match[0].length,
name,
source,
});
}
}
return tokens;
}
export function findDynamicVariableTrigger(
value: string,
selectionStart: number,
selectionEnd: number,
): DynamicVariableTrigger | null {
if (selectionStart !== selectionEnd) return null;
const containingToken = findDynamicVariableTokens(value).some(
(token) => selectionStart > token.start && selectionStart < token.end,
);
if (containingToken) return null;
const beforeCaret = value.slice(0, selectionStart);
const start = beforeCaret.lastIndexOf("{{");
if (start < 0 || beforeCaret[start - 1] === "{") return null;
const query = beforeCaret.slice(start + 2);
if (query.includes("{") || query.includes("}") || /\s/.test(query)) {
return null;
}
if (query && !DYNAMIC_VARIABLE_NAME_PATTERN.test(query)) return null;
return { start, query };
}
@@ -20,33 +20,11 @@ import {
} from "@/components/ui/tabs";
import type { WorkflowGraph } from "@/components/workflow/specs";
import type { DynamicVariableDefinition } from "@/lib/api";
const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
const SYSTEM_DYNAMIC_VARIABLES = [
["system__conversation_id", "会话 ID"],
["system__time", "当前时间"],
["system__timezone", "会话时区"],
["system__agent_turns", "助手轮次"],
["system__conversation_history", "会话历史"],
] as const;
function isPublicDynamicVariableName(name: string): boolean {
return (
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) &&
!name.startsWith("system__") &&
!name.startsWith("secret__")
);
}
function extractDynamicVariableNames(...templates: string[]): string[] {
const names = new Set<string>();
for (const template of templates) {
for (const match of template.matchAll(DYNAMIC_VARIABLE_PATTERN)) {
if (isPublicDynamicVariableName(match[1])) names.add(match[1]);
}
}
return [...names];
}
import {
extractDynamicVariableNames,
isPublicDynamicVariableName,
SYSTEM_DYNAMIC_VARIABLES,
} from "@/components/assistant-editor/dynamic-variable-template";
function collectStructuredVariableNames(value: unknown, names: Set<string>): void {
if (Array.isArray(value)) {
@@ -136,7 +114,7 @@ export function DynamicVariableEditorHint({
<code className="rounded bg-surface-strong px-1 py-0.5 font-mono text-foreground">
{"{{"}
</code>{" "}
添加变量,保存时自动识别
选择或创建变量,保存时自动识别
</span>
<button
type="button"
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AnalysisConfigEditor,
} from "@/components/assistant-editor/analysis-config";
@@ -26,6 +26,7 @@ import {
DynamicVariableEditorHint,
DynamicVariablesSection,
} from "@/components/assistant-editor/dynamic-variables";
import { PromptTemplateEditor } from "@/components/assistant-editor/prompt-template-editor";
import {
AssistantIdentity,
EditableTitle,
@@ -200,6 +201,13 @@ export function PromptEditor({
const [debugOpen, setDebugOpen] = useState(true);
const [deleting, setDeleting] = useState(false);
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
const promptVariableSuggestions = useMemo(
() => ({
...form.dynamicVariableDefinitions,
...dynamicVariableDefinitions,
}),
[form.dynamicVariableDefinitions, dynamicVariableDefinitions],
);
const selectedRealtimeModel = realtimeOptions.find(
(option) => option.value === form.realtimeModel,
);
@@ -468,10 +476,12 @@ export function PromptEditor({
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextAreaField
<PromptTemplateEditor
value={form.prompt}
onChange={(value) => updateForm("prompt", value)}
variables={promptVariableSuggestions}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
ariaLabel="提示词"
rows={16}
/>
<DynamicVariableEditorHint
@@ -485,10 +495,12 @@ export function PromptEditor({
title="开场白"
description="助手与用户首次对话时的开场语"
>
<TextAreaField
<PromptTemplateEditor
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
variables={promptVariableSuggestions}
placeholder="请输入助手开场白"
ariaLabel="开场白"
/>
<DynamicVariableEditorHint
count={Object.keys(dynamicVariableDefinitions).length}
@@ -0,0 +1,602 @@
"use client";
import {
type CompositionEvent,
type KeyboardEvent,
type UIEvent,
useCallback,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
findDynamicVariableTokens,
findDynamicVariableTrigger,
isPublicDynamicVariableName,
SYSTEM_DYNAMIC_VARIABLES,
type DynamicVariableTrigger,
} from "@/components/assistant-editor/dynamic-variable-template";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import type { DynamicVariableDefinition } from "@/lib/api";
import { cn } from "@/lib/utils";
type VariableSuggestion = {
id: string;
name: string;
label?: string;
source: "custom" | "system" | "create";
type?: DynamicVariableDefinition["type"];
};
type CaretAnchor = {
left: number;
top: number;
height: number;
};
const VARIABLE_TYPE_LABELS: Record<DynamicVariableDefinition["type"], string> = {
string: "文本",
number: "数字",
boolean: "布尔值",
};
const MIRRORED_TEXTAREA_STYLES = [
"borderBottomWidth",
"borderLeftWidth",
"borderRightWidth",
"borderTopWidth",
"boxSizing",
"fontFamily",
"fontSize",
"fontStyle",
"fontWeight",
"letterSpacing",
"lineHeight",
"paddingBottom",
"paddingLeft",
"paddingRight",
"paddingTop",
"tabSize",
"textIndent",
"textTransform",
"wordSpacing",
] as const;
function measureTextareaCaret(
textarea: HTMLTextAreaElement,
position: number,
): CaretAnchor {
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
mirror.style.position = "fixed";
mirror.style.left = "-9999px";
mirror.style.top = "0";
mirror.style.visibility = "hidden";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.overflowWrap = "break-word";
mirror.style.wordBreak = "break-word";
mirror.style.width = `${textarea.clientWidth}px`;
for (const property of MIRRORED_TEXTAREA_STYLES) {
mirror.style[property] = computed[property];
}
mirror.append(document.createTextNode(textarea.value.slice(0, position)));
const marker = document.createElement("span");
marker.textContent = "\u200b";
mirror.append(marker);
document.body.append(mirror);
const lineHeight = Number.parseFloat(computed.lineHeight) || 24;
const anchor = {
left: textarea.offsetLeft + marker.offsetLeft - textarea.scrollLeft,
top: textarea.offsetTop + marker.offsetTop - textarea.scrollTop,
height: lineHeight,
};
mirror.remove();
return anchor;
}
function suggestionScore(suggestion: VariableSuggestion, query: string): number {
const normalizedQuery = query.toLocaleLowerCase();
const name = suggestion.name.toLocaleLowerCase();
const label = suggestion.label?.toLocaleLowerCase() ?? "";
if (!normalizedQuery) return 0;
if (name === normalizedQuery) return 0;
if (name.startsWith(normalizedQuery)) return 1;
if (label.startsWith(normalizedQuery)) return 2;
return 3;
}
function matchesQuery(suggestion: VariableSuggestion, query: string): boolean {
const normalizedQuery = query.toLocaleLowerCase();
return (
!normalizedQuery ||
suggestion.name.toLocaleLowerCase().includes(normalizedQuery) ||
suggestion.label?.toLocaleLowerCase().includes(normalizedQuery) === true
);
}
function HighlightedPrompt({ value }: { value: string }) {
const tokens = useMemo(() => findDynamicVariableTokens(value), [value]);
const content: React.ReactNode[] = [];
let cursor = 0;
for (const token of tokens) {
if (token.start > cursor) {
content.push(value.slice(cursor, token.start));
}
content.push(
<mark
key={`${token.start}-${token.end}`}
className="rounded-sm bg-surface-strong text-transparent ring-1 ring-inset ring-hairline"
>
{value.slice(token.start, token.end)}
</mark>,
);
cursor = token.end;
}
if (cursor < value.length) content.push(value.slice(cursor));
return (
<>
{content}
{value.endsWith("\n") && "\u200b"}
</>
);
}
export function PromptTemplateEditor({
value,
onChange,
variables,
rows = 4,
placeholder,
ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
variables: Record<string, DynamicVariableDefinition>;
rows?: number;
placeholder?: string;
ariaLabel: string;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false);
const listboxId = useId();
const [trigger, setTrigger] = useState<DynamicVariableTrigger | null>(null);
const [activeSuggestionId, setActiveSuggestionId] = useState<string | null>(
null,
);
const [caretAnchor, setCaretAnchor] = useState<CaretAnchor>({
left: 12,
top: 12,
height: 24,
});
const customSuggestions = useMemo<VariableSuggestion[]>(
() =>
Object.entries(variables)
.filter(([name]) => isPublicDynamicVariableName(name))
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, definition]) => ({
id: `custom-${name}`,
name,
source: "custom",
type: definition.type,
})),
[variables],
);
const systemSuggestions = useMemo<VariableSuggestion[]>(
() =>
SYSTEM_DYNAMIC_VARIABLES.map(([name, label]) => ({
id: `system-${name}`,
name,
label,
source: "system",
})),
[],
);
const filteredCustomSuggestions = useMemo(() => {
if (!trigger) return [];
return customSuggestions
.filter((suggestion) => matchesQuery(suggestion, trigger.query))
.sort(
(left, right) =>
suggestionScore(left, trigger.query) -
suggestionScore(right, trigger.query) ||
left.name.localeCompare(right.name),
);
}, [customSuggestions, trigger]);
const filteredSystemSuggestions = useMemo(() => {
if (!trigger) return [];
return systemSuggestions
.filter((suggestion) => matchesQuery(suggestion, trigger.query))
.sort(
(left, right) =>
suggestionScore(left, trigger.query) -
suggestionScore(right, trigger.query),
);
}, [systemSuggestions, trigger]);
const createSuggestion = useMemo<VariableSuggestion | null>(() => {
if (!trigger || !isPublicDynamicVariableName(trigger.query)) return null;
const exactMatch = [...customSuggestions, ...systemSuggestions].some(
(suggestion) =>
suggestion.name.toLocaleLowerCase() ===
trigger.query.toLocaleLowerCase(),
);
if (exactMatch) return null;
return {
id: `create-${trigger.query}`,
name: trigger.query,
source: "create",
type: "string",
};
}, [customSuggestions, systemSuggestions, trigger]);
const selectableSuggestions = useMemo(
() => [
...(createSuggestion ? [createSuggestion] : []),
...filteredCustomSuggestions,
...filteredSystemSuggestions,
],
[
createSuggestion,
filteredCustomSuggestions,
filteredSystemSuggestions,
],
);
const activeSuggestionIndex = Math.max(
0,
selectableSuggestions.findIndex(
(suggestion) => suggestion.id === activeSuggestionId,
),
);
const activeSuggestion = selectableSuggestions[activeSuggestionIndex] ?? null;
const open = trigger !== null;
const updateCaretAnchor = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
setCaretAnchor(measureTextareaCaret(textarea, textarea.selectionStart));
}, []);
const syncHighlightViewport = useCallback(() => {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) return;
highlight.style.width = `${textarea.clientWidth}px`;
highlight.style.height = `${textarea.clientHeight}px`;
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
}, []);
const refreshTrigger = useCallback((textarea: HTMLTextAreaElement) => {
if (isComposingRef.current) return;
setTrigger(
findDynamicVariableTrigger(
textarea.value,
textarea.selectionStart,
textarea.selectionEnd,
),
);
setActiveSuggestionId(null);
setCaretAnchor(measureTextareaCaret(textarea, textarea.selectionStart));
}, []);
const closeSuggestions = useCallback(() => {
setTrigger(null);
setActiveSuggestionId(null);
}, []);
const insertSuggestion = useCallback(
(suggestion: VariableSuggestion) => {
const textarea = textareaRef.current;
if (!textarea || !trigger) return;
const insertedValue = `{{${suggestion.name}}}`;
const nextValue =
value.slice(0, trigger.start) +
insertedValue +
value.slice(textarea.selectionStart);
const nextCaret = trigger.start + insertedValue.length;
onChange(nextValue);
closeSuggestions();
window.requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(nextCaret, nextCaret);
setCaretAnchor(measureTextareaCaret(textarea, nextCaret));
});
},
[closeSuggestions, onChange, trigger, value],
);
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (isComposingRef.current || !open) return;
if (event.key === "Escape") {
event.preventDefault();
closeSuggestions();
return;
}
if (!selectableSuggestions.length) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
const nextIndex =
(activeSuggestionIndex + direction + selectableSuggestions.length) %
selectableSuggestions.length;
setActiveSuggestionId(selectableSuggestions[nextIndex].id);
return;
}
if ((event.key === "Enter" || event.key === "Tab") && activeSuggestion) {
event.preventDefault();
insertSuggestion(activeSuggestion);
}
}
function handleScroll(event: UIEvent<HTMLTextAreaElement>) {
const highlight = highlightRef.current;
if (highlight) {
highlight.scrollTop = event.currentTarget.scrollTop;
highlight.scrollLeft = event.currentTarget.scrollLeft;
}
if (open) updateCaretAnchor();
}
function handleCompositionStart() {
isComposingRef.current = true;
closeSuggestions();
}
function handleCompositionEnd(event: CompositionEvent<HTMLTextAreaElement>) {
isComposingRef.current = false;
refreshTrigger(event.currentTarget);
}
useLayoutEffect(() => {
if (!open) return;
updateCaretAnchor();
}, [open, trigger?.query, updateCaretAnchor, value]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
syncHighlightViewport();
const resizeObserver = new ResizeObserver(() => {
syncHighlightViewport();
if (open) updateCaretAnchor();
});
resizeObserver.observe(textarea);
return () => resizeObserver.disconnect();
}, [open, syncHighlightViewport, updateCaretAnchor, value]);
useLayoutEffect(() => {
if (!open || !activeSuggestion) return;
document
.getElementById(`${listboxId}-${activeSuggestion.id}`)
?.scrollIntoView({ block: "nearest" });
}, [activeSuggestion, listboxId, open]);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) closeSuggestions();
}}
>
<div className="relative rounded-md border border-hairline-strong bg-background transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/30">
<div
ref={highlightRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 overflow-hidden whitespace-pre-wrap break-words px-3 py-3 text-sm leading-6 text-transparent"
>
<HighlightedPrompt value={value} />
</div>
<Textarea
ref={textareaRef}
value={value}
rows={rows}
placeholder={placeholder}
aria-label={ariaLabel}
aria-autocomplete="list"
aria-controls={open ? listboxId : undefined}
aria-expanded={open}
aria-activedescendant={
open && activeSuggestion
? `${listboxId}-${activeSuggestion.id}`
: undefined
}
onChange={(event) => {
onChange(event.target.value);
refreshTrigger(event.currentTarget);
}}
onFocus={(event) => refreshTrigger(event.currentTarget)}
onBlur={closeSuggestions}
onClick={(event) => refreshTrigger(event.currentTarget)}
onSelect={(event) => refreshTrigger(event.currentTarget)}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
className="field-sizing-fixed relative z-10 min-h-28 resize-y rounded-md border-0 bg-transparent px-3 py-3 text-sm leading-6 text-foreground shadow-none outline-none placeholder:text-muted-soft focus-visible:border-0 focus-visible:ring-0"
/>
<PopoverAnchor asChild>
<span
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-20 w-px opacity-0"
style={{
height: caretAnchor.height,
transform: `translate(${caretAnchor.left}px, ${caretAnchor.top}px)`,
}}
/>
</PopoverAnchor>
</div>
<PopoverContent
id={listboxId}
role="listbox"
aria-label="动态变量"
side="bottom"
align="start"
sideOffset={6}
collisionPadding={12}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
className="max-h-72 w-80 max-w-[calc(100vw-24px)] overflow-y-auto rounded-xl border-hairline bg-popover p-1.5 shadow-md"
>
{createSuggestion && (
<SuggestionGroup label="新变量">
<SuggestionRow
suggestion={createSuggestion}
optionId={`${listboxId}-${createSuggestion.id}`}
active={activeSuggestion?.id === createSuggestion.id}
onActive={setActiveSuggestionId}
onSelect={insertSuggestion}
/>
</SuggestionGroup>
)}
{filteredCustomSuggestions.length > 0 && (
<SuggestionGroup label="自定义变量">
{filteredCustomSuggestions.map((suggestion) => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
optionId={`${listboxId}-${suggestion.id}`}
active={activeSuggestion?.id === suggestion.id}
onActive={setActiveSuggestionId}
onSelect={insertSuggestion}
/>
))}
</SuggestionGroup>
)}
{filteredSystemSuggestions.length > 0 && (
<SuggestionGroup label="系统变量">
{filteredSystemSuggestions.map((suggestion) => (
<SuggestionRow
key={suggestion.id}
suggestion={suggestion}
optionId={`${listboxId}-${suggestion.id}`}
active={activeSuggestion?.id === suggestion.id}
onActive={setActiveSuggestionId}
onSelect={insertSuggestion}
/>
))}
</SuggestionGroup>
)}
{selectableSuggestions.length === 0 && (
<div className="px-3 py-5 text-center text-xs leading-5 text-muted-foreground">
没有匹配的可用变量
</div>
)}
</PopoverContent>
</Popover>
);
}
function SuggestionGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="py-1 first:pt-0 last:pb-0">
<div className="px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-soft">
{label}
</div>
<div className="space-y-0.5">{children}</div>
</div>
);
}
function SuggestionRow({
suggestion,
optionId,
active,
onActive,
onSelect,
}: {
suggestion: VariableSuggestion;
optionId: string;
active: boolean;
onActive: (suggestionId: string) => void;
onSelect: (suggestion: VariableSuggestion) => void;
}) {
return (
<button
id={optionId}
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
onPointerMove={() => onActive(suggestion.id)}
onPointerDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}
className={cn(
"flex min-h-10 w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left transition-colors",
active ? "bg-surface-strong text-foreground" : "hover:bg-canvas-soft",
)}
>
{suggestion.source === "system" ? (
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{suggestion.label}
</div>
<code className="block truncate font-mono text-[11px] text-muted-foreground">
{suggestion.name}
</code>
</div>
) : (
<div className="min-w-0">
<code className="block truncate font-mono text-xs font-medium text-foreground">
{suggestion.name}
</code>
{suggestion.source === "create" && (
<span className="block text-[11px] text-muted-foreground">
创建新的文本变量
</span>
)}
</div>
)}
{suggestion.source === "custom" && suggestion.type && (
<span className="shrink-0 rounded-full bg-surface-strong px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{VARIABLE_TYPE_LABELS[suggestion.type]}
</span>
)}
</button>
);
}
+7 -1
View File
@@ -17,6 +17,12 @@ function PopoverTrigger({
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverContent({
className,
align = "center",
@@ -39,4 +45,4 @@ function PopoverContent({
)
}
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent }