feat: 优化工作流参数编辑与告警交互

- 新增 ParamTokenEditor,支持参数选择插入、token 高亮、整段删除与光标避让

- 参数候选改为动态监测,未映射参数可选择并在下拉与输入框顶部告警

- 接入知识库/搜索引擎/LLM/动态代码/HTTP Body 及 SQL、查询数据自定义节点

- 优化 Http 节点布局并补充参数解析工具与单测
This commit is contained in:
2026-02-28 21:37:49 +08:00
parent 59c95a3b06
commit 4ef17da6f4
13 changed files with 1465 additions and 120 deletions

View File

@@ -47,6 +47,7 @@ export const SearchDatacenterNode = async () => {
}, },
{ {
type: 'textarea', type: 'textarea',
templateSupport: true,
label: "如name='张三' and age=21 or field = {{流程变量}}", label: "如name='张三' and age=21 or field = {{流程变量}}",
description: '', description: '',
name: 'where', name: 'where',

View File

@@ -16,6 +16,7 @@ export default {
{ {
name: 'sql', name: 'sql',
type: 'textarea', type: 'textarea',
templateSupport: true,
label: 'SQL', label: 'SQL',
placeholder: $t('aiWorkflow.descriptions.enterSQL'), placeholder: $t('aiWorkflow.descriptions.enterSQL'),
}, },

View File

@@ -0,0 +1,748 @@
<script lang="ts">
import { tick } from 'svelte';
import {Button, FloatingTrigger} from '../base';
import type {Parameter} from '#types';
import {
escapeHtml,
flattenParameterCandidates,
findBackspaceTokenRange,
findTokenRangeAtCursor,
insertTextAtCursor,
parseTokenParts,
splitTokenDisplay
} from '../utils/paramToken';
const {
mode = 'textarea',
value = '',
parameters = [],
rows = 3,
placeholder = '',
disabled = false,
class: className = '',
style = '',
...rest
}: {
mode?: 'input' | 'textarea';
value?: string;
parameters?: Parameter[];
rows?: number;
placeholder?: string;
disabled?: boolean;
class?: string;
style?: string;
[key: string]: any;
} = $props();
let inputEl = $state<HTMLInputElement | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let highlightEl = $state<HTMLDivElement | null>(null);
let triggerObject: any;
let isFocused = $state(false);
let isComposing = $state(false);
let localValue = $state((value || '') as string);
$effect(() => {
const nextValue = (value || '') as string;
if (!isFocused && nextValue !== localValue) {
localValue = nextValue;
}
});
const paramCandidates = $derived(flattenParameterCandidates(parameters));
const paramNames = $derived(paramCandidates.map((item) => item.name));
const unresolvedParamSet = $derived.by(() => {
return new Set(paramCandidates.filter((item) => !item.resolved).map((item) => item.name));
});
const hasParams = $derived(paramCandidates.length > 0);
const tokenParts = $derived(parseTokenParts(localValue, paramNames));
const unresolvedTokensInEditor = $derived.by(() => {
const keySet = new Set<string>();
for (const part of tokenParts) {
if (part.type === 'token' && part.valid && unresolvedParamSet.has(part.key)) {
keySet.add(part.key);
}
}
return Array.from(keySet);
});
const undefinedTokensInEditor = $derived.by(() => {
const keySet = new Set<string>();
for (const part of tokenParts) {
if (part.type === 'token' && !part.valid) {
keySet.add(part.key);
}
}
return Array.from(keySet);
});
const showInlineHint = $derived(unresolvedTokensInEditor.length > 0 || undefinedTokensInEditor.length > 0);
const highlightedHtml = $derived.by(() => {
return tokenParts
.map((part) => {
if (part.type === 'text') {
return escapeHtml(part.text);
}
const cssClass = !part.valid
? 'param-token-invalid'
: unresolvedParamSet.has(part.key)
? 'param-token-warning'
: 'param-token-valid';
const display = splitTokenDisplay(part.text, part.key);
return `<span class="param-token-token"><span class="param-token-chip ${cssClass}"><span class="param-token-hidden">${escapeHtml(display.hiddenPrefix)}</span><span class="param-token-chip-text">${escapeHtml(display.visibleText)}</span><span class="param-token-hidden">${escapeHtml(display.hiddenSuffix)}</span></span></span>`;
})
.join('');
});
const getEditorElement = () => {
return mode === 'input' ? inputEl : textareaEl;
};
const emitInput = (event?: Event) => {
if (event) {
rest.oninput?.(event);
return;
}
rest.oninput?.({
target: {
value: localValue
}
});
};
const emitChange = (event?: Event) => {
if (event) {
rest.onchange?.(event);
return;
}
rest.onchange?.({
target: {
value: localValue
}
});
};
const syncScroll = () => {
const el = getEditorElement();
if (!el || !highlightEl) {
return;
}
highlightEl.scrollTop = el.scrollTop;
highlightEl.scrollLeft = el.scrollLeft;
};
const handleInput = (event: Event) => {
localValue = ((event.target as HTMLInputElement | HTMLTextAreaElement).value || '') as string;
syncScroll();
emitInput(event);
};
const handleChange = (event: Event) => {
localValue = ((event.target as HTMLInputElement | HTMLTextAreaElement).value || '') as string;
emitChange(event);
};
const insertParam = async (paramName: string) => {
const editorEl = getEditorElement();
const result = insertTextAtCursor(
localValue,
`{{${paramName}}}`,
editorEl?.selectionStart,
editorEl?.selectionEnd
);
localValue = result.value;
emitInput();
emitChange();
await tick();
const newEditorEl = getEditorElement();
if (newEditorEl) {
newEditorEl.focus();
newEditorEl.setSelectionRange(result.cursor, result.cursor);
}
syncScroll();
triggerObject?.hide?.();
};
const handleFocus = (event: Event) => {
isFocused = true;
rest.onfocus?.(event);
};
const handleBlur = (event: Event) => {
isFocused = false;
rest.onblur?.(event);
};
const handleCompositionStart = (event: Event) => {
isComposing = true;
rest.oncompositionstart?.(event);
};
const handleCompositionEnd = (event: Event) => {
isComposing = false;
rest.oncompositionend?.(event);
};
const setCaretPosition = (position: number) => {
const editorEl = getEditorElement();
if (!editorEl) {
return;
}
editorEl.setSelectionRange(position, position);
};
const normalizeCaretAsync = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
moveCaretOutOfToken();
});
});
};
const moveCaretOutOfToken = (prefer?: 'start' | 'end') => {
const editorEl = getEditorElement();
if (!editorEl) {
return false;
}
const selectionStart = editorEl.selectionStart ?? 0;
const selectionEnd = editorEl.selectionEnd ?? 0;
if (selectionStart !== selectionEnd) {
return false;
}
const tokenRange = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: false,
includeEnd: false
});
if (!tokenRange) {
return false;
}
const target =
prefer === 'start'
? tokenRange.start
: prefer === 'end'
? tokenRange.end
: selectionStart - tokenRange.start <= tokenRange.end - selectionStart
? tokenRange.start
: tokenRange.end;
editorEl.setSelectionRange(target, target);
return true;
};
const handleKeydown = async (event: KeyboardEvent) => {
if (disabled || isComposing) {
rest.onkeydown?.(event);
return;
}
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const selectionStart = target.selectionStart ?? 0;
const selectionEnd = target.selectionEnd ?? 0;
if (selectionStart !== selectionEnd) {
rest.onkeydown?.(event);
return;
}
if (event.key === 'ArrowLeft') {
const leftToken = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: false,
includeEnd: true
});
if (leftToken && leftToken.end === selectionStart) {
event.preventDefault();
setCaretPosition(leftToken.start);
rest.onkeydown?.(event);
return;
}
if (moveCaretOutOfToken('start')) {
event.preventDefault();
}
rest.onkeydown?.(event);
return;
}
if (event.key === 'ArrowRight') {
const rightToken = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: true,
includeEnd: false
});
if (rightToken && rightToken.start === selectionStart) {
event.preventDefault();
setCaretPosition(rightToken.end);
rest.onkeydown?.(event);
return;
}
if (moveCaretOutOfToken('end')) {
event.preventDefault();
}
rest.onkeydown?.(event);
return;
}
const tokenRangeInside = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: false,
includeEnd: false
});
if (tokenRangeInside && event.key !== 'Backspace' && event.key !== 'Delete') {
event.preventDefault();
moveCaretOutOfToken();
rest.onkeydown?.(event);
return;
}
if (event.key === 'Delete') {
const deleteRange = findTokenRangeAtCursor(localValue, selectionStart, {
includeStart: true,
includeEnd: false
});
if (deleteRange) {
event.preventDefault();
const result = insertTextAtCursor(localValue, '', deleteRange.start, deleteRange.end);
localValue = result.value;
emitInput();
emitChange();
await tick();
const editorEl = getEditorElement();
if (editorEl) {
editorEl.focus();
editorEl.setSelectionRange(deleteRange.start, deleteRange.start);
}
syncScroll();
rest.onkeydown?.(event);
return;
}
}
if (event.key !== 'Backspace') {
rest.onkeydown?.(event);
return;
}
const tokenRange = findBackspaceTokenRange(localValue, selectionStart);
if (!tokenRange) {
rest.onkeydown?.(event);
return;
}
event.preventDefault();
const result = insertTextAtCursor(localValue, '', tokenRange.start, tokenRange.end);
localValue = result.value;
emitInput();
emitChange();
await tick();
const editorEl = getEditorElement();
if (editorEl) {
editorEl.focus();
editorEl.setSelectionRange(tokenRange.start, tokenRange.start);
}
syncScroll();
rest.onkeydown?.(event);
};
const handleSelect = (event: Event) => {
normalizeCaretAsync();
rest.onselect?.(event);
};
const handleMouseUp = (event: MouseEvent) => {
normalizeCaretAsync();
rest.onmouseup?.(event);
};
const handleKeyup = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') {
normalizeCaretAsync();
}
rest.onkeyup?.(event);
};
const handleClick = (event: MouseEvent) => {
normalizeCaretAsync();
rest.onclick?.(event);
};
</script>
<div class="param-token-editor {className}" style={style}>
{#if showInlineHint}
<div class="param-token-inline-hint">
{#if unresolvedTokensInEditor.length > 0}
<div class="hint-item hint-unresolved">
<span class="hint-item-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12S6.486 22 12 22 22 17.514 22 12 17.514 2 12 2zm0 15a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 12 17zm1-4h-2V7h2v6z"></path>
</svg>
</span>
<span>
未映射参数:{unresolvedTokensInEditor.join('、')}
</span>
</div>
{/if}
{#if undefinedTokensInEditor.length > 0}
<div class="hint-item hint-undefined">
<span class="hint-item-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.486 2 2 6.486 2 12S6.486 22 12 22 22 17.514 22 12 17.514 2 12 2zm0 15a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 12 17zm1-4h-2V7h2v6z"></path>
</svg>
</span>
<span>
未定义参数:{undefinedTokensInEditor.join('、')}
</span>
</div>
{/if}
</div>
{/if}
<div class="param-token-editor-inner">
<div
class="param-token-editor-highlight {mode === 'input' ? 'single-line' : 'multi-line'}"
bind:this={highlightEl}
aria-hidden="true"
>
{@html highlightedHtml}
</div>
{#if mode === 'input'}
<input
{...rest}
bind:this={inputEl}
type="text"
class="tf-input param-token-input {rest.class}"
placeholder={placeholder}
value={localValue}
disabled={disabled}
spellcheck="false"
onfocus={handleFocus}
onblur={handleBlur}
oninput={handleInput}
onchange={handleChange}
onscroll={syncScroll}
onkeydown={handleKeydown}
onselect={handleSelect}
onmouseup={handleMouseUp}
onclick={handleClick}
onkeyup={handleKeyup}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
/>
{:else}
<textarea
{...rest}
bind:this={textareaEl}
class="tf-textarea param-token-textarea {rest.class}"
placeholder={placeholder}
value={localValue}
rows={rows}
disabled={disabled}
spellcheck="false"
onfocus={handleFocus}
onblur={handleBlur}
oninput={handleInput}
onchange={handleChange}
onscroll={syncScroll}
onkeydown={handleKeydown}
onselect={handleSelect}
onmouseup={handleMouseUp}
onclick={handleClick}
onkeyup={handleKeyup}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
></textarea>
{/if}
<div class="param-token-action">
<FloatingTrigger placement="bottom-end" bind:this={triggerObject}>
<Button
class="param-token-button"
disabled={!hasParams || disabled}
title={hasParams ? '选择参数' : '请先在输入参数中定义参数'}
>
<span>{"{x}"}</span>
</Button>
{#snippet floating()}
<div class="param-token-panel nowheel">
{#if hasParams}
{#each paramCandidates as candidate}
<button
class="param-token-item {candidate.resolved ? '' : 'unresolved'}"
title={candidate.resolved ? candidate.name : `${candidate.name}(未配置引用值)`}
onclick={() => {
insertParam(candidate.name);
}}
>
<span>{candidate.name}</span>
{#if !candidate.resolved}
<span
class="param-token-item-warn"
title="该参数尚未映射引用值,运行时可能为空"
aria-label="未映射参数"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 1 21h22L12 2zm0 5.2 6.4 11.8H5.6L12 7.2zm-1 3.3v4.6h2v-4.6h-2zm0 5.8v1.9h2v-1.9h-2z"></path>
</svg>
</span>
{/if}
</button>
{/each}
{:else}
<div class="param-token-empty">请先在输入参数中定义参数</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
</div>
</div>
</div>
<style lang="less">
.param-token-editor {
width: 100%;
position: relative;
}
.param-token-inline-hint {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
line-height: 1.4;
}
.hint-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 6px;
border: 1px solid transparent;
}
.hint-item-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
svg {
width: 14px;
height: 14px;
display: block;
}
}
.hint-item.hint-unresolved {
background: #fff8e6;
border-color: #f6d99a;
color: #8a5a00;
.hint-item-icon {
color: #d97706;
}
}
.hint-item.hint-undefined {
background: #fff1f1;
border-color: #f5c7c4;
color: #b42318;
.hint-item-icon {
color: #d92d20;
}
}
.param-token-editor-inner {
width: 100%;
position: relative;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
letter-spacing: inherit;
}
.param-token-editor-highlight {
position: absolute;
inset: 0;
border: 1px solid transparent;
border-radius: 5px;
padding: 5px 8px;
box-sizing: border-box;
color: #333;
pointer-events: none;
z-index: 0;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.6;
letter-spacing: inherit;
overflow: hidden;
}
.param-token-editor-highlight.single-line {
white-space: pre;
word-break: normal;
}
.param-token-editor-highlight.multi-line {
white-space: pre-wrap;
word-break: break-word;
}
.param-token-input,
.param-token-textarea {
width: 100%;
position: relative;
z-index: 1;
background: transparent;
color: transparent;
caret-color: #333;
padding-right: 36px;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1.6;
letter-spacing: inherit;
}
.param-token-input::placeholder,
.param-token-textarea::placeholder {
color: #ccc;
}
.param-token-textarea {
overflow: auto;
}
.param-token-action {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
}
:global(.param-token-button) {
height: 20px;
min-width: 20px;
border: 1px solid transparent;
padding: 0 4px;
font-size: 10px;
border-radius: 4px;
color: #666;
background: rgba(255, 255, 255, 0.9);
}
:global(.param-token-button:hover) {
background: #f0f3ff;
color: #2563eb;
border-color: #d3defd;
}
.param-token-panel {
min-width: 200px;
max-width: 320px;
max-height: 220px;
overflow: auto;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.param-token-item {
border: none;
background: #fff;
padding: 6px 8px;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
line-height: 1.3;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.param-token-item:hover {
background: #f5f7ff;
color: #2563eb;
}
.param-token-item.unresolved {
background: #fff8e6;
color: #92400e;
}
.param-token-item-warn {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #d97706;
flex-shrink: 0;
svg {
width: 14px;
height: 14px;
display: block;
}
}
.param-token-empty {
padding: 8px;
font-size: 12px;
color: #999;
text-align: center;
}
:global(.param-token-hidden) {
color: transparent;
}
:global(.param-token-token) {
display: inline;
}
:global(.param-token-chip) {
display: inline-block;
vertical-align: baseline;
line-height: inherit;
white-space: nowrap;
border-radius: 4px;
padding: 0;
margin: 0;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
:global(.param-token-chip-text) {
position: relative;
}
:global(.param-token-valid) {
background: #eaf2ff;
color: #1e4ed8;
box-shadow: inset 0 0 0 1px #c6d8ff;
}
:global(.param-token-invalid) {
background: #fff1f1;
color: #b42318;
box-shadow: inset 0 0 0 1px #f5c7c4;
}
:global(.param-token-warning) {
background: #fff8e6;
color: #92400e;
box-shadow: inset 0 0 0 1px #f7d79e;
}
</style>

View File

@@ -42,7 +42,7 @@
[key]: value [key]: value
}; };
return { return {
[dataKeyName]: parameters [dataKeyName]: [...parameters]
}; };
}); });
}; };
@@ -172,4 +172,3 @@
</style> </style>

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading, Select} from '../base'; import {Button, Heading, Select} from '../base';
import {Textarea} from '../base/index.js';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte'; import {useAddParameter} from '../utils/useAddParameter.svelte';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
// 添加生命周期函数 // 添加生命周期函数
import {onMount} from 'svelte'; import {onMount} from 'svelte';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
data: NodeProps['data'], data: NodeProps['data'],
@@ -25,7 +25,11 @@
} }
}); });
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const { updateNodeData } = useSvelteFlow(); const { updateNodeData } = useSvelteFlow();
@@ -76,15 +80,21 @@
<div class="setting-title">执行代码</div> <div class="setting-title">执行代码</div>
<div class="setting-item"> <div class="setting-item">
<Textarea rows={10} <ParamTokenEditor
mode="textarea"
rows={10}
placeholder="请输入执行代码输出内容需添加到_result中_result['key'] = value 或者 _result.key = value" placeholder="请输入执行代码输出内容需添加到_result中_result['key'] = value 或者 _result.key = value"
style="width: 100%" onchange={(e:any)=>{ style="width: 100%"
parameters={editorParameters}
oninput={(e:any)=>{
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
return { return {
code: e.target.value code: e.target.value
} }
}) })
}} value={data.code as string||""} /> }}
value={data.code as string||""}
/>
</div> </div>
@@ -125,5 +135,3 @@
</style> </style>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type Node, type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type Node, type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base'; import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
@@ -8,6 +8,7 @@
import {getOptions} from '../utils/NodeUtils'; import {getOptions} from '../utils/NodeUtils';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
import {fillParameterId} from '../utils/useAddParameter.svelte.js'; import {fillParameterId} from '../utils/useAddParameter.svelte.js';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
data: NodeProps['data'], data: NodeProps['data'],
@@ -15,9 +16,13 @@
} = $props(); } = $props();
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const flowInstance = useSvelteFlow(); const flowInstance = useSvelteFlow();
const { updateNodeData: updateNodeDataInner } = flowInstance; const { updateNodeData: updateNodeDataInner } = flowInstance;
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const updateNodeData = (data: Record<string, any>) => { const updateNodeData = (data: Record<string, any>) => {
updateNodeDataInner(currentNodeId, data); updateNodeDataInner(currentNodeId, data);
@@ -104,19 +109,47 @@
{#if form.type === 'input'} {#if form.type === 'input'}
<div class="setting-title">{form.label}</div> <div class="setting-title">{form.label}</div>
<div class="setting-item"> <div class="setting-item">
<Input {#if form.templateSupport}
placeholder={form.placeholder} <ParamTokenEditor
style="width: 100%" mode="input"
value={data[form.name] || form.defaultValue} placeholder={form.placeholder}
{...form.attrs} style="width: 100%"
onchange={(e)=>{ value={data[form.name] || form.defaultValue}
parameters={editorParameters}
{...form.attrs}
oninput={(e)=>{
updateNodeDataByEvent(form.name,e) updateNodeDataByEvent(form.name,e)
}} }}
/> />
{:else}
<Input
placeholder={form.placeholder}
style="width: 100%"
value={data[form.name] || form.defaultValue}
{...form.attrs}
onchange={(e)=>{
updateNodeDataByEvent(form.name,e)
}}
/>
{/if}
</div> </div>
{:else if form.type === 'textarea'} {:else if form.type === 'textarea'}
<div class="setting-title">{form.label}</div> <div class="setting-title">{form.label}</div>
<div class="setting-item"> <div class="setting-item">
{#if form.templateSupport}
<ParamTokenEditor
mode="textarea"
rows={3}
placeholder={form.placeholder}
style="width: 100%"
value={data[form.name] || form.defaultValue}
parameters={editorParameters}
{...form.attrs}
oninput={(e)=>{
updateNodeDataByEvent(form.name,e)
}}
/>
{:else}
<Textarea <Textarea
rows={3} rows={3}
placeholder={form.placeholder} placeholder={form.placeholder}
@@ -127,6 +160,7 @@
updateNodeDataByEvent(form.name,e) updateNodeDataByEvent(form.name,e)
}} }}
/> />
{/if}
</div> </div>
{:else if form.type === 'slider'} {:else if form.type === 'slider'}
<div class="setting-title">{form.label}</div> <div class="setting-title">{form.label}</div>
@@ -246,5 +280,3 @@
} }
</style> </style>

View File

@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading, Select} from '../base'; import {Button, Heading, Select} from '../base';
import {Input, Textarea} from '../base/index.js'; import {Input} from '../base/index.js';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte'; import {useAddParameter} from '../utils/useAddParameter.svelte';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
// 添加生命周期函数 // 添加生命周期函数
import {onMount} from 'svelte'; import {onMount} from 'svelte';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
data: NodeProps['data'], data: NodeProps['data'],
@@ -60,10 +61,23 @@
{ value: 'head', label: 'HEAD' }, { value: 'head', label: 'HEAD' },
{ value: 'patch', label: 'PATCH' } { value: 'patch', label: 'PATCH' }
]; ];
const bodyTypeOptions = [
{ value: '', label: 'none' },
{ value: 'form-data', label: 'form-data' },
{ value: 'x-www-form-urlencoded', label: 'x-www-form-urlencoded' },
{ value: 'json', label: 'json' },
{ value: 'raw', label: 'raw' }
];
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const { updateNodeData } = useSvelteFlow(); const { updateNodeData } = useSvelteFlow();
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const bodyMethods = new Set(['post', 'put', 'delete', 'patch']);
const showBodyConfig = $derived(bodyMethods.has((data.method || '').toLowerCase()));
</script> </script>
@@ -89,9 +103,10 @@
</div> </div>
<RefParameterList /> <RefParameterList />
<Heading level={3} mt="10px">URL 地址</Heading> <div class="http-section">
<div style="display: flex;gap: 2px;width: 100%;padding: 10px 0"> <div class="section-title">请求配置</div>
<div> <div class="http-url-row">
<div class="method-select">
<Select items={method} style="width: 100%" placeholder="请选择请求方式" onSelect={(item)=>{ <Select items={method} style="width: 100%" placeholder="请选择请求方式" onSelect={(item)=>{
const newValue = item.value; const newValue = item.value;
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
@@ -100,80 +115,68 @@
} }
}) })
}} value={data.method ? [data.method] : ['get']} /> }} value={data.method ? [data.method] : ['get']} />
</div> </div>
<div style="width: 100%"> <div class="url-input-wrap">
<Input placeholder="请输入url" style="width: 100%" onchange={(e:any)=>{ <Input
style="width: 100%"
placeholder="请输入 URL"
value={data.url as string||""}
onchange={(e:any)=>{
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
return { return {
url: e.target.value url: e.target.value
} }
}) })
}} value={data.url as string||""} /> }}
/>
</div>
</div> </div>
</div> </div>
<div class="heading"> <div class="http-section">
<Heading level={3}>Http 头信息</Heading> <div class="heading heading-tight">
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{ <Heading level={3}>Http 头信息</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId,'headers') addParameter(currentNodeId,'headers')
}}> }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path> <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg> </svg>
</Button> </Button>
</div>
<RefParameterList dataKeyName="headers" />
</div> </div>
<RefParameterList dataKeyName="headers" />
{#if data.method === 'post' || data.method === 'put' || data.method === 'delete' || data.method === 'patch'} {#if showBodyConfig}
<div class="http-section">
<Heading level={3} mt="10px">Body</Heading> <div class="section-title">Body 类型</div>
<div class="radio-group"> <div class="radio-group">
<label><Input type="radio" value="" checked={!data.bodyType || data.bodyType === ''} {#each bodyTypeOptions as option}
onchange={(e:any)=>{ <label class:active={((data.bodyType || '') === option.value)}>
if (e.target?.checked){ <Input
updateNodeData(currentNodeId,{ type="radio"
bodyType: '', name={"http-body-type-" + currentNodeId}
}) value={option.value}
} checked={(data.bodyType || '') === option.value}
}} />none</label> onchange={(e:any)=>{
<label><Input type="radio" value="form-data" checked={data.bodyType === 'form-data'} if (e.target?.checked){
onchange={(e:any)=>{ updateNodeData(currentNodeId,{
if (e.target?.checked){ bodyType: option.value,
updateNodeData(currentNodeId,{ })
bodyType: 'form-data', }
}) }}
} />
}} />form-data</label> <span>{option.label}</span>
<label><Input type="radio" value="x-www-form-urlencoded" </label>
checked={data.bodyType === 'x-www-form-urlencoded'} onchange={(e:any)=>{ {/each}
if (e.target?.checked){ </div>
updateNodeData(currentNodeId,{
bodyType: 'x-www-form-urlencoded',
})
}
}} />x-www-form-urlencoded</label>
<label><Input type="radio" value="json" checked={data.bodyType === 'json'}
onchange={(e:any)=>{
if (e.target?.checked){
updateNodeData(currentNodeId,{
bodyType: 'json',
})
}
}} />json</label>
<label><Input type="radio" value="raw" checked={data.bodyType === 'raw'}
onchange={(e:any)=>{
if (e.target?.checked){
updateNodeData(currentNodeId,{
bodyType: 'raw',
})
}
}} />raw</label>
</div> </div>
{/if} {/if}
{#if data.bodyType === 'form-data'} {#if data.bodyType === 'form-data'}
<div class="heading" style="padding-top: 10px"> <div class="http-section">
<div class="heading heading-tight">
<Heading level={3}>参数</Heading> <Heading level={3}>参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{ <Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId,'formData') addParameter(currentNodeId,'formData')
@@ -182,13 +185,15 @@
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path> <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg> </svg>
</Button> </Button>
</div>
<RefParameterList dataKeyName="formData" />
</div> </div>
<RefParameterList dataKeyName="formData" />
{/if} {/if}
{#if data.bodyType === 'x-www-form-urlencoded'} {#if data.bodyType === 'x-www-form-urlencoded'}
<div class="heading" style="padding-top: 10px"> <div class="http-section">
<div class="heading heading-tight">
<Heading level={3}>Body 参数</Heading> <Heading level={3}>Body 参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{ <Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId,'formUrlencoded') addParameter(currentNodeId,'formUrlencoded')
@@ -197,13 +202,20 @@
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path> <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg> </svg>
</Button> </Button>
</div>
<RefParameterList dataKeyName="formUrlencoded" />
</div> </div>
<RefParameterList dataKeyName="formUrlencoded" />
{/if} {/if}
{#if data.bodyType === 'json'} {#if data.bodyType === 'json'}
<div style="width: 100%"> <div class="http-section">
<Textarea rows={5} style="width: 100%" placeholder="请输入 json 信息" value={data.bodyJson} <div class="section-title">JSON Body</div>
<ParamTokenEditor
mode="textarea"
rows={5}
style="width: 100%"
parameters={editorParameters}
placeholder="请输入 json 信息" value={data.bodyJson}
oninput={(e:any)=>{ oninput={(e:any)=>{
updateNodeData(currentNodeId,{ updateNodeData(currentNodeId,{
bodyJson: e.target.value, bodyJson: e.target.value,
@@ -214,8 +226,14 @@
{#if data.bodyType === 'raw'} {#if data.bodyType === 'raw'}
<div style="width: 100%"> <div class="http-section">
<Textarea rows={5} style="width: 100%" placeholder="请输入请求信息" value={data.bodyRaw} <div class="section-title">Raw Body</div>
<ParamTokenEditor
mode="textarea"
rows={5}
style="width: 100%"
parameters={editorParameters}
placeholder="请输入请求信息" value={data.bodyRaw}
oninput={(e:any)=>{ oninput={(e:any)=>{
updateNodeData(currentNodeId,{ updateNodeData(currentNodeId,{
bodyRaw: e.target.value, bodyRaw: e.target.value,
@@ -242,22 +260,78 @@
<style lang="less"> <style lang="less">
.heading { .heading {
display: flex; display: flex;
align-items: center;
margin-bottom: 10px; margin-bottom: 10px;
} }
.heading-tight {
margin-bottom: 8px;
}
.http-section {
margin: 10px 0;
padding: 10px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #fafcff;
}
.section-title {
font-size: 12px;
line-height: 1.3;
color: #6b7280;
font-weight: 600;
margin-bottom: 8px;
}
.http-url-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
}
.method-select {
width: 118px;
flex-shrink: 0;
}
.url-input-wrap {
flex: 1;
min-width: 0;
}
.radio-group { .radio-group {
display: flex; display: flex;
margin: 10px 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px;
label { label {
display: flex; display: inline-flex;
font-size: 14px; align-items: center;
gap: 6px;
font-size: 12px;
box-sizing: border-box; box-sizing: border-box;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid #dbe3f3;
background: #fff;
color: #4b5563;
cursor: pointer;
transition: all 0.15s ease;
}
label.active {
background: #eef4ff;
border-color: #c7d8ff;
color: #1d4ed8;
}
:global(input[type='radio']) {
margin: 0;
width: 12px;
height: 12px;
} }
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading, Input, Select} from '../base'; import {Button, Heading, Select} from '../base';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte'; import {useAddParameter} from '../utils/useAddParameter.svelte';
@@ -9,6 +9,7 @@
import {onMount} from 'svelte'; import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
import type {SelectItem} from '#types'; import type {SelectItem} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
data: NodeProps['data'], data: NodeProps['data'],
@@ -16,7 +17,11 @@
} = $props(); } = $props();
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const options = getOptions(); const options = getOptions();
@@ -112,9 +117,13 @@
<div class="setting-title">关键字</div> <div class="setting-title">关键字</div>
<div class="setting-item"> <div class="setting-item">
<Input placeholder="请输入关键字" style="width: 100%" <ParamTokenEditor
value={data.keyword} mode="input"
onchange={(e)=>{ placeholder="请输入关键字"
style="width: 100%"
parameters={editorParameters}
value={data.keyword || ''}
oninput={(e)=>{
const newValue = e.target.value; const newValue = e.target.value;
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
return { return {
@@ -128,14 +137,21 @@
<div class="setting-title">获取数据量</div> <div class="setting-title">获取数据量</div>
<div class="setting-item"> <div class="setting-item">
<Input placeholder="搜索的数据条数" style="width: 100%" onchange={(e)=>{ <ParamTokenEditor
mode="input"
placeholder="搜索的数据条数"
style="width: 100%"
parameters={editorParameters}
oninput={(e)=>{
const newValue = e.target.value; const newValue = e.target.value;
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
return { return {
limit: newValue limit: newValue
} }
}) })
}} value={data.limit || ''} /> }}
value={data.limit || ''}
/>
</div> </div>
@@ -169,5 +185,3 @@
</style> </style>

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, FloatingTrigger, Heading, Select} from '../base'; import {Button, FloatingTrigger, Heading, Select} from '../base';
import {MenuButton, Textarea} from '../base/index.js'; import {MenuButton} from '../base/index.js';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte'; import {useAddParameter} from '../utils/useAddParameter.svelte';
@@ -10,6 +10,7 @@
import {onMount} from 'svelte'; import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
import type {SelectItem} from '#types'; import type {SelectItem} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
data: NodeProps['data'], data: NodeProps['data'],
@@ -17,7 +18,11 @@
} = $props(); } = $props();
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const options = getOptions(); const options = getOptions();
@@ -177,25 +182,29 @@
<div class="setting-title">系统提示词</div> <div class="setting-title">系统提示词</div>
<div class="setting-item"> <div class="setting-item">
<Textarea rows={5} <ParamTokenEditor
mode="textarea"
rows={5}
placeholder="请输入系统提示词" placeholder="请输入系统提示词"
style="width: 100%" style="width: 100%"
parameters={editorParameters}
value={data.systemPrompt || ''} value={data.systemPrompt || ''}
oninput={(e)=>{ oninput={(e)=>{
updateNodeData(currentNodeId, { updateNodeData(currentNodeId, {
systemPrompt: e.target.value systemPrompt: e.target.value
}); });
}} }}
/> />
</div> </div>
<div class="setting-title">用户提示词</div> <div class="setting-title">用户提示词</div>
<div class="setting-item"> <div class="setting-item">
<Textarea <ParamTokenEditor
mode="textarea"
rows={5} rows={5}
placeholder="请输入用户提示词" placeholder="请输入用户提示词"
style="width: 100%" style="width: 100%"
parameters={editorParameters}
value={data.userPrompt || ''} value={data.userPrompt || ''}
oninput={(e)=>{ oninput={(e)=>{
updateNodeData(currentNodeId, { updateNodeData(currentNodeId, {
@@ -288,5 +297,3 @@
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading, Input, Select} from '../base'; import {Button, Heading, Select} from '../base';
import RefParameterList from '../core/RefParameterList.svelte'; import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils'; import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte'; import {useAddParameter} from '../utils/useAddParameter.svelte';
@@ -9,6 +9,7 @@
import {onMount} from 'svelte'; import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
import type {SelectItem} from '#types'; import type {SelectItem} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: { const { data, ...rest }: {
data: NodeProps['data'], data: NodeProps['data'],
@@ -16,7 +17,11 @@
} = $props(); } = $props();
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const options = getOptions(); const options = getOptions();
@@ -99,9 +104,13 @@
<div class="setting-title">关键字</div> <div class="setting-title">关键字</div>
<div class="setting-item"> <div class="setting-item">
<Input placeholder="请输入关键字" style="width: 100%" <ParamTokenEditor
value={data.keyword} mode="input"
onchange={(e)=>{ placeholder="请输入关键字"
style="width: 100%"
parameters={editorParameters}
value={data.keyword || ''}
oninput={(e)=>{
const newValue = e.target.value; const newValue = e.target.value;
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
return { return {
@@ -114,15 +123,21 @@
<div class="setting-title">搜索数据量</div> <div class="setting-title">搜索数据量</div>
<div class="setting-item"> <div class="setting-item">
<Input placeholder="搜索的数据条数" style="width: 100%" value={data.limit} <ParamTokenEditor
onchange={(e)=>{ mode="input"
placeholder="搜索的数据条数"
style="width: 100%"
value={data.limit || ''}
parameters={editorParameters}
oninput={(e)=>{
const newValue = e.target.value; const newValue = e.target.value;
updateNodeData(currentNodeId, ()=>{ updateNodeData(currentNodeId, ()=>{
return { return {
limit: newValue limit: newValue
} }
}) })
}} /> }}
/>
</div> </div>
@@ -156,5 +171,3 @@
</style> </style>

View File

@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import {
flattenParameterCandidates,
findBackspaceTokenRange,
findTokenRangeAtCursor,
flattenParameterNames,
insertTextAtCursor,
parseTokenParts,
splitTokenDisplay
} from './paramToken';
describe('paramToken utils', () => {
it('should flatten parameter names with nested paths', () => {
const result = flattenParameterNames([
{
name: 'input'
},
{
name: 'documents',
children: [
{
name: 'title'
},
{
name: 'meta',
children: [
{
name: 'author'
}
]
}
]
}
]);
expect(result).toEqual([
'input',
'documents',
'documents.title',
'documents.meta',
'documents.meta.author'
]);
});
it('should keep unresolved candidates in parameter list', () => {
const result = flattenParameterCandidates([
{
name: 'input',
refType: 'ref',
ref: ''
},
{
name: 'docs',
refType: 'ref',
ref: 'documents'
},
{
name: 'runtimeInput',
refType: 'input'
}
]);
expect(result).toEqual([
{
name: 'input',
resolved: false
},
{
name: 'docs',
resolved: true
},
{
name: 'runtimeInput',
resolved: true
}
]);
});
it('should insert token text in the middle by cursor range', () => {
const result = insertTextAtCursor('hello world', '{{input}}', 6, 11);
expect(result).toEqual({
value: 'hello {{input}}',
cursor: 15
});
});
it('should append token text when cursor info is missing', () => {
const result = insertTextAtCursor('hello', '{{name}}');
expect(result).toEqual({
value: 'hello{{name}}',
cursor: 13
});
});
it('should parse token parts and mark valid tokens', () => {
const parts = parseTokenParts(
'你好 {{ user.name }} 与 {{unknown}}',
['user.name', 'docs']
);
expect(parts).toEqual([
{
type: 'text',
text: '你好 '
},
{
type: 'token',
text: '{{ user.name }}',
key: 'user.name',
valid: true
},
{
type: 'text',
text: ' 与 '
},
{
type: 'token',
text: '{{unknown}}',
key: 'unknown',
valid: false
}
]);
});
it('should keep plain text when token syntax is invalid', () => {
const parts = parseTokenParts('abc {{}} def', ['a']);
expect(parts).toEqual([
{
type: 'text',
text: 'abc {{}} def'
}
]);
});
it('should split token display and hide braces text', () => {
const result = splitTokenDisplay('{{ user.name }}', 'user.name');
expect(result).toEqual({
hiddenPrefix: '{{ ',
visibleText: 'user.name',
hiddenSuffix: ' }}'
});
});
it('should find full token range for backspace delete', () => {
const content = 'hello {{input}} world';
const tokenEndCursor = 'hello {{input}}'.length;
const range = findBackspaceTokenRange(content, tokenEndCursor);
expect(range).toEqual({
start: 6,
end: 15,
text: '{{input}}',
key: 'input'
});
});
it('should support boundary match for arrow skip behavior', () => {
const content = 'x{{docs}}y';
const tokenStart = 1;
const tokenEnd = 9;
const rightBoundary = findTokenRangeAtCursor(content, tokenStart, {
includeStart: true,
includeEnd: false
});
const leftBoundary = findTokenRangeAtCursor(content, tokenEnd, {
includeStart: false,
includeEnd: true
});
expect(rightBoundary?.key).toBe('docs');
expect(leftBoundary?.key).toBe('docs');
});
});

View File

@@ -0,0 +1,272 @@
export interface ParameterLike {
name?: string;
ref?: string;
refType?: string;
children?: ParameterLike[];
}
export interface TokenRange {
start: number;
end: number;
text: string;
key: string;
}
export type TokenPart =
| {
type: 'text';
text: string;
}
| {
type: 'token';
text: string;
key: string;
valid: boolean;
};
export interface ParameterCandidate {
name: string;
resolved: boolean;
}
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
export function normalizeTokenKey(tokenKey: string): string {
return tokenKey.trim();
}
export function flattenParameterNames(parameters?: ParameterLike[] | null): string[] {
return flattenParameterCandidates(parameters).map((item) => item.name);
}
function isParameterResolved(parameter?: ParameterLike): boolean {
if (!parameter) {
return false;
}
const refType = (parameter.refType || '').trim();
if (refType === 'fixed' || refType === 'input') {
return true;
}
const ref = (parameter.ref || '').trim();
return !!ref;
}
export function flattenParameterCandidates(parameters?: ParameterLike[] | null): ParameterCandidate[] {
if (!parameters || parameters.length === 0) {
return [];
}
const candidates: ParameterCandidate[] = [];
const indexMap = new Map<string, number>();
const addCandidate = (name: string, resolved: boolean) => {
const normalized = name.trim();
if (!normalized) {
return;
}
const exists = indexMap.get(normalized);
if (exists === undefined) {
indexMap.set(normalized, candidates.length);
candidates.push({
name: normalized,
resolved
});
return;
}
// 同名参数只要有一个可解析,就视为可解析
if (resolved) {
candidates[exists].resolved = true;
}
};
const walk = (items: ParameterLike[], parentPath = '', inheritedResolved = true) => {
for (const item of items) {
const rawName = item?.name?.trim();
if (!rawName) {
continue;
}
const currentPath = parentPath ? `${parentPath}.${rawName}` : rawName;
const currentResolved = inheritedResolved && isParameterResolved(item);
addCandidate(currentPath, currentResolved);
if (item.children && item.children.length > 0) {
walk(item.children, currentPath, currentResolved);
}
}
};
walk(parameters);
return candidates;
}
export function parseTokenParts(content: string, validParams: string[] = []): TokenPart[] {
const source = content ?? '';
const validSet = new Set(validParams.map(normalizeTokenKey));
const parts: TokenPart[] = [];
let lastIndex = 0;
TOKEN_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
while (match) {
if (match.index > lastIndex) {
parts.push({
type: 'text',
text: source.slice(lastIndex, match.index)
});
}
const rawToken = match[0];
const tokenKey = normalizeTokenKey(match[1] || '');
parts.push({
type: 'token',
text: rawToken,
key: tokenKey,
valid: validSet.has(tokenKey)
});
lastIndex = match.index + rawToken.length;
match = TOKEN_PATTERN.exec(source);
}
if (lastIndex < source.length) {
parts.push({
type: 'text',
text: source.slice(lastIndex)
});
}
if (parts.length === 0) {
parts.push({
type: 'text',
text: source
});
}
return parts;
}
export function getTokenRanges(content: string): TokenRange[] {
const source = content ?? '';
const ranges: TokenRange[] = [];
TOKEN_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
while (match) {
const rawToken = match[0];
ranges.push({
start: match.index,
end: match.index + rawToken.length,
text: rawToken,
key: normalizeTokenKey(match[1] || '')
});
match = TOKEN_PATTERN.exec(source);
}
return ranges;
}
export function findTokenRangeAtCursor(
content: string,
cursor: number,
options?: {
includeStart?: boolean;
includeEnd?: boolean;
}
): TokenRange | null {
if (!Number.isInteger(cursor) || cursor < 0) {
return null;
}
const includeStart = options?.includeStart ?? false;
const includeEnd = options?.includeEnd ?? false;
const ranges = getTokenRanges(content);
for (const range of ranges) {
const leftValid = includeStart ? cursor >= range.start : cursor > range.start;
const rightValid = includeEnd ? cursor <= range.end : cursor < range.end;
if (leftValid && rightValid) {
return range;
}
}
return null;
}
export function findBackspaceTokenRange(content: string, cursor: number): TokenRange | null {
return findTokenRangeAtCursor(content, cursor, {
includeStart: false,
includeEnd: true
});
}
export function splitTokenDisplay(rawToken: string, normalizedKey?: string): {
hiddenPrefix: string;
visibleText: string;
hiddenSuffix: string;
} {
const source = rawToken ?? '';
if (!source.startsWith('{{') || !source.endsWith('}}')) {
return {
hiddenPrefix: '',
visibleText: normalizedKey || source,
hiddenSuffix: ''
};
}
const inner = source.slice(2, -2);
const visibleText = normalizeTokenKey(normalizedKey || inner);
if (!visibleText) {
return {
hiddenPrefix: '',
visibleText: source,
hiddenSuffix: ''
};
}
const innerStart = inner.indexOf(visibleText);
const leading = innerStart >= 0 ? inner.slice(0, innerStart) : '';
const trailing = innerStart >= 0 ? inner.slice(innerStart + visibleText.length) : '';
return {
hiddenPrefix: `{{${leading}`,
visibleText,
hiddenSuffix: `${trailing}}}`
};
}
export function insertTextAtCursor(
content: string,
insertedText: string,
selectionStart?: number | null,
selectionEnd?: number | null
): {
value: string;
cursor: number;
} {
const source = content ?? '';
const start = Number.isInteger(selectionStart)
? Math.max(0, Math.min(selectionStart as number, source.length))
: source.length;
const end = Number.isInteger(selectionEnd)
? Math.max(start, Math.min(selectionEnd as number, source.length))
: start;
const nextValue = source.slice(0, start) + insertedText + source.slice(end);
return {
value: nextValue,
cursor: start + insertedText.length
};
}
export function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

View File

@@ -36,6 +36,7 @@ export type CustomNodeForm = {
defaultValue?: string | number | boolean; defaultValue?: string | number | boolean;
attrs?: Record<string, any>; attrs?: Record<string, any>;
options?: SelectItem[]; options?: SelectItem[];
templateSupport?: boolean;
chosen?: { chosen?: {
labelDataKey: string; labelDataKey: string;
valueDataKey: string; valueDataKey: string;