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";
|
} from "@/components/ui/tabs";
|
||||||
import type { WorkflowGraph } from "@/components/workflow/specs";
|
import type { WorkflowGraph } from "@/components/workflow/specs";
|
||||||
import type { DynamicVariableDefinition } from "@/lib/api";
|
import type { DynamicVariableDefinition } from "@/lib/api";
|
||||||
|
import {
|
||||||
const DYNAMIC_VARIABLE_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_]{0,63})\s*}}/g;
|
extractDynamicVariableNames,
|
||||||
const SYSTEM_DYNAMIC_VARIABLES = [
|
isPublicDynamicVariableName,
|
||||||
["system__conversation_id", "会话 ID"],
|
SYSTEM_DYNAMIC_VARIABLES,
|
||||||
["system__time", "当前时间"],
|
} from "@/components/assistant-editor/dynamic-variable-template";
|
||||||
["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];
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectStructuredVariableNames(value: unknown, names: Set<string>): void {
|
function collectStructuredVariableNames(value: unknown, names: Set<string>): void {
|
||||||
if (Array.isArray(value)) {
|
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 className="rounded bg-surface-strong px-1 py-0.5 font-mono text-foreground">
|
||||||
{"{{"}
|
{"{{"}
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
添加变量,保存时自动识别
|
选择或创建变量,保存时自动识别
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AnalysisConfigEditor,
|
AnalysisConfigEditor,
|
||||||
} from "@/components/assistant-editor/analysis-config";
|
} from "@/components/assistant-editor/analysis-config";
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
DynamicVariableEditorHint,
|
DynamicVariableEditorHint,
|
||||||
DynamicVariablesSection,
|
DynamicVariablesSection,
|
||||||
} from "@/components/assistant-editor/dynamic-variables";
|
} from "@/components/assistant-editor/dynamic-variables";
|
||||||
|
import { PromptTemplateEditor } from "@/components/assistant-editor/prompt-template-editor";
|
||||||
import {
|
import {
|
||||||
AssistantIdentity,
|
AssistantIdentity,
|
||||||
EditableTitle,
|
EditableTitle,
|
||||||
@@ -200,6 +201,13 @@ export function PromptEditor({
|
|||||||
const [debugOpen, setDebugOpen] = useState(true);
|
const [debugOpen, setDebugOpen] = useState(true);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
|
const [cleanupDialogOpen, setCleanupDialogOpen] = useState(false);
|
||||||
|
const promptVariableSuggestions = useMemo(
|
||||||
|
() => ({
|
||||||
|
...form.dynamicVariableDefinitions,
|
||||||
|
...dynamicVariableDefinitions,
|
||||||
|
}),
|
||||||
|
[form.dynamicVariableDefinitions, dynamicVariableDefinitions],
|
||||||
|
);
|
||||||
const selectedRealtimeModel = realtimeOptions.find(
|
const selectedRealtimeModel = realtimeOptions.find(
|
||||||
(option) => option.value === form.realtimeModel,
|
(option) => option.value === form.realtimeModel,
|
||||||
);
|
);
|
||||||
@@ -468,10 +476,12 @@ export function PromptEditor({
|
|||||||
title="提示词"
|
title="提示词"
|
||||||
description="描述助手的角色、能力和回答要求"
|
description="描述助手的角色、能力和回答要求"
|
||||||
>
|
>
|
||||||
<TextAreaField
|
<PromptTemplateEditor
|
||||||
value={form.prompt}
|
value={form.prompt}
|
||||||
onChange={(value) => updateForm("prompt", value)}
|
onChange={(value) => updateForm("prompt", value)}
|
||||||
|
variables={promptVariableSuggestions}
|
||||||
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
||||||
|
ariaLabel="提示词"
|
||||||
rows={16}
|
rows={16}
|
||||||
/>
|
/>
|
||||||
<DynamicVariableEditorHint
|
<DynamicVariableEditorHint
|
||||||
@@ -485,10 +495,12 @@ export function PromptEditor({
|
|||||||
title="开场白"
|
title="开场白"
|
||||||
description="助手与用户首次对话时的开场语"
|
description="助手与用户首次对话时的开场语"
|
||||||
>
|
>
|
||||||
<TextAreaField
|
<PromptTemplateEditor
|
||||||
value={form.greeting}
|
value={form.greeting}
|
||||||
onChange={(value) => updateForm("greeting", value)}
|
onChange={(value) => updateForm("greeting", value)}
|
||||||
|
variables={promptVariableSuggestions}
|
||||||
placeholder="请输入助手开场白"
|
placeholder="请输入助手开场白"
|
||||||
|
ariaLabel="开场白"
|
||||||
/>
|
/>
|
||||||
<DynamicVariableEditorHint
|
<DynamicVariableEditorHint
|
||||||
count={Object.keys(dynamicVariableDefinitions).length}
|
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} />
|
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({
|
function PopoverContent({
|
||||||
className,
|
className,
|
||||||
align = "center",
|
align = "center",
|
||||||
@@ -39,4 +45,4 @@ function PopoverContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent }
|
export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent }
|
||||||
|
|||||||
Reference in New Issue
Block a user