feat(frontend): add dynamic variable prompt editor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user