feat: 增强代码节点编辑器与放大阅读体验
- tinyflow-ui: 新增 CodeScriptEditor(CodeMirror 6)并支持语法高亮、上下文补全、自动括号与 _result 高亮 - tinyflow-ui: 代码节点接入引擎能力列表与节点说明提示,统一 JS/Python 编辑体验 - tinyflow-ui: 增加放大编辑模式,支持居中弹层、ESC 与点击外部关闭 - app/workflow: 对接 supportedCodeEngines 能力并透传 codeEngine provider
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
key: string;
|
||||
icon?: string | Snippet;
|
||||
title: string | Snippet;
|
||||
titleHelp?: string;
|
||||
description?: string | Snippet;
|
||||
content: string | Snippet;
|
||||
}
|
||||
@@ -47,6 +48,15 @@
|
||||
{/if}
|
||||
|
||||
<Render target={item.title} />
|
||||
{#if item.titleHelp}
|
||||
<span
|
||||
class="tf-collapse-item-title-help"
|
||||
data-help={item.titleHelp}
|
||||
aria-label="节点使用说明"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="tf-collapse-item-title-arrow {activeKeys.includes(item.key) ? 'rotate-90' : ''}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path
|
||||
@@ -75,4 +85,70 @@
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tf-collapse-item-title-help {
|
||||
margin-left: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #c6cfdd;
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tf-collapse-item-title-help:hover {
|
||||
border-color: #94a3b8;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tf-collapse-item-title-help::after {
|
||||
content: attr(data-help);
|
||||
position: absolute;
|
||||
left: calc(100% + 10px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
white-space: pre-line;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 8px 22px rgba(2, 6, 23, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.tf-collapse-item-title-help::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + 4px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 6px solid #0f172a;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
z-index: 121;
|
||||
}
|
||||
|
||||
.tf-collapse-item-title-help:hover::after,
|
||||
.tf-collapse-item-title-help:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from 'svelte';
|
||||
import {Compartment, EditorState, type Transaction} from '@codemirror/state';
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
pickedCompletion
|
||||
} from '@codemirror/autocomplete';
|
||||
import {defaultKeymap, history, historyKeymap, indentWithTab} from '@codemirror/commands';
|
||||
import {javascript} from '@codemirror/lang-javascript';
|
||||
import {defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting} from '@codemirror/language';
|
||||
import {python} from '@codemirror/lang-python';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
keymap,
|
||||
placeholder as cmPlaceholder,
|
||||
ViewPlugin,
|
||||
type ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import {Button, FloatingTrigger} from '../base';
|
||||
import type {Parameter} from '#types';
|
||||
import {flattenParameterCandidates} from '../utils/paramToken';
|
||||
import {
|
||||
createBusinessCompletionSource,
|
||||
type CodeEngine,
|
||||
normalizeCodeEngine,
|
||||
shouldAutoAppendCallParens
|
||||
} from '../utils/codeCompletion';
|
||||
|
||||
const {
|
||||
value = '',
|
||||
mode = 'textarea',
|
||||
engine = 'js',
|
||||
parameters = [],
|
||||
rows = 10,
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
style = '',
|
||||
...rest
|
||||
}: {
|
||||
value?: string;
|
||||
mode?: 'input' | 'textarea';
|
||||
engine?: string;
|
||||
parameters?: Parameter[];
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
style?: string;
|
||||
[key: string]: any;
|
||||
} = $props();
|
||||
|
||||
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||
const RESULT_PATTERN = /\b_result\b/g;
|
||||
const languageCompartment = new Compartment();
|
||||
const indentCompartment = new Compartment();
|
||||
const completionCompartment = new Compartment();
|
||||
const completionSourceCompartment = new Compartment();
|
||||
const decorationCompartment = new Compartment();
|
||||
const editableCompartment = new Compartment();
|
||||
const placeholderCompartment = new Compartment();
|
||||
|
||||
let containerEl = $state<HTMLDivElement | null>(null);
|
||||
let editorShellEl = $state<HTMLDivElement | null>(null);
|
||||
let editorView = $state<EditorView | null>(null);
|
||||
let triggerObject: any;
|
||||
let applyingExternalValue = $state(false);
|
||||
let expanded = $state(false);
|
||||
let shellOriginalParent = $state<HTMLElement | null>(null);
|
||||
let shellOriginalNextSibling = $state<ChildNode | null>(null);
|
||||
|
||||
const isSingleLine = $derived(mode === 'input');
|
||||
const normalizedEngine = $derived(normalizeCodeEngine(engine));
|
||||
const minRows = $derived(isSingleLine ? 1 : Math.max(3, rows || 3));
|
||||
const minHeight = $derived(`${minRows * 24}px`);
|
||||
const editorMinHeight = $derived(expanded ? '100%' : minHeight);
|
||||
const editorStyle = $derived(`${style};--code-editor-min-height:${editorMinHeight};`);
|
||||
const paramCandidates = $derived(flattenParameterCandidates(parameters));
|
||||
const hasParams = $derived(paramCandidates.length > 0);
|
||||
const paramResolvedMap = $derived.by(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
for (const candidate of paramCandidates) {
|
||||
map.set(candidate.name, candidate.resolved);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function emitInput(nextValue: string) {
|
||||
rest.oninput?.({
|
||||
target: {
|
||||
value: nextValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function emitChange(nextValue: string) {
|
||||
rest.onchange?.({
|
||||
target: {
|
||||
value: nextValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createLanguageExtension(targetEngine: CodeEngine) {
|
||||
if (targetEngine === 'python') {
|
||||
return python();
|
||||
}
|
||||
return javascript();
|
||||
}
|
||||
|
||||
function getIndentUnitForEngine(targetEngine: CodeEngine) {
|
||||
if (targetEngine === 'python') {
|
||||
return ' ';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
function createBusinessCompletionExtension(targetEngine: CodeEngine, candidates: typeof paramCandidates) {
|
||||
return EditorState.languageData.of((_state, _pos, _side) => {
|
||||
return [
|
||||
{
|
||||
autocomplete: createBusinessCompletionSource({
|
||||
engine: targetEngine,
|
||||
paramCandidates: candidates
|
||||
})
|
||||
}
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function handlePickedCompletionParens(transactions: readonly Transaction[], currentState: EditorState) {
|
||||
for (const tr of transactions) {
|
||||
const picked = tr.annotation(pickedCompletion);
|
||||
if (!picked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = currentState.selection.main.head;
|
||||
const nextChar = currentState.sliceDoc(cursor, cursor + 1);
|
||||
if (!shouldAutoAppendCallParens(picked, nextChar)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
const head = editorView.state.selection.main.head;
|
||||
const afterHead = editorView.state.sliceDoc(head, head + 1);
|
||||
if (afterHead === '(') {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
changes: {
|
||||
from: head,
|
||||
to: head,
|
||||
insert: '()'
|
||||
},
|
||||
selection: {
|
||||
anchor: head + 1
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createHighlightDecorations(resolvedMap: Map<string, boolean>) {
|
||||
const resultMark = Decoration.mark({class: 'cm-special-result'});
|
||||
const validTokenMark = Decoration.mark({class: 'cm-template-token-valid'});
|
||||
const unresolvedTokenMark = Decoration.mark({class: 'cm-template-token-unresolved'});
|
||||
const invalidTokenMark = Decoration.mark({class: 'cm-template-token-invalid'});
|
||||
|
||||
function buildDecorations(source: string): DecorationSet {
|
||||
const ranges: Array<{from: number; to: number; value: Decoration}> = [];
|
||||
|
||||
RESULT_PATTERN.lastIndex = 0;
|
||||
let resultMatch: RegExpExecArray | null = RESULT_PATTERN.exec(source);
|
||||
while (resultMatch) {
|
||||
ranges.push({
|
||||
from: resultMatch.index,
|
||||
to: resultMatch.index + resultMatch[0].length,
|
||||
value: resultMark
|
||||
});
|
||||
resultMatch = RESULT_PATTERN.exec(source);
|
||||
}
|
||||
|
||||
TOKEN_PATTERN.lastIndex = 0;
|
||||
let tokenMatch: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
|
||||
while (tokenMatch) {
|
||||
const tokenKey = (tokenMatch[1] || '').trim();
|
||||
const resolved = resolvedMap.get(tokenKey);
|
||||
const decoration =
|
||||
resolved === undefined ? invalidTokenMark : resolved ? validTokenMark : unresolvedTokenMark;
|
||||
ranges.push({
|
||||
from: tokenMatch.index,
|
||||
to: tokenMatch.index + tokenMatch[0].length,
|
||||
value: decoration
|
||||
});
|
||||
tokenMatch = TOKEN_PATTERN.exec(source);
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view.state.doc.toString());
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.state.doc.toString());
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (value) => value.decorations
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createEditorTheme() {
|
||||
return EditorView.theme({
|
||||
'&': {
|
||||
border: '1px solid #dcdfe6',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#fff',
|
||||
fontSize: '13px'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
borderColor: '#4c82f7',
|
||||
boxShadow: '0 0 0 1px rgba(76, 130, 247, 0.25)'
|
||||
},
|
||||
'&.cm-editor.cm-disabled': {
|
||||
backgroundColor: '#f5f7fa',
|
||||
opacity: 0.85
|
||||
},
|
||||
'.cm-scroller': {
|
||||
minHeight: 'var(--code-editor-min-height)',
|
||||
lineHeight: '1.6',
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace"
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '8px 38px 8px 10px',
|
||||
caretColor: '#1f2937'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
border: 'none',
|
||||
background: 'transparent'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
background: 'rgba(76, 130, 247, 0.06)'
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: '#c0c4cc'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function insertTextAtSelection(text: string) {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
const selection = editorView.state.selection.main;
|
||||
const from = selection.from;
|
||||
const to = selection.to;
|
||||
editorView.dispatch({
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert: text
|
||||
},
|
||||
selection: {
|
||||
anchor: from + text.length
|
||||
}
|
||||
});
|
||||
editorView.focus();
|
||||
}
|
||||
|
||||
function insertParam(paramName: string) {
|
||||
insertTextAtSelection(`{{${paramName}}}`);
|
||||
emitChange(editorView?.state.doc.toString() || '');
|
||||
triggerObject?.hide?.();
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
function closeExpanded() {
|
||||
expanded = false;
|
||||
}
|
||||
|
||||
function moveExpandedShellToBody() {
|
||||
if (typeof document === 'undefined' || !editorShellEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorShellEl.parentElement === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
shellOriginalParent = editorShellEl.parentElement;
|
||||
shellOriginalNextSibling = editorShellEl.nextSibling;
|
||||
document.body.appendChild(editorShellEl);
|
||||
}
|
||||
|
||||
function restoreExpandedShell() {
|
||||
if (!editorShellEl || !shellOriginalParent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorShellEl.parentElement !== document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shellOriginalNextSibling && shellOriginalNextSibling.parentNode === shellOriginalParent) {
|
||||
shellOriginalParent.insertBefore(editorShellEl, shellOriginalNextSibling);
|
||||
} else {
|
||||
shellOriginalParent.appendChild(editorShellEl);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorView = new EditorView({
|
||||
parent: containerEl,
|
||||
state: EditorState.create({
|
||||
doc: (value || '') as string,
|
||||
extensions: [
|
||||
createEditorTheme(),
|
||||
syntaxHighlighting(defaultHighlightStyle, {fallback: true}),
|
||||
history(),
|
||||
indentOnInput(),
|
||||
closeBrackets(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
handlePickedCompletionParens(update.transactions, update.state);
|
||||
|
||||
if (!update.docChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = update.state.doc.toString();
|
||||
if (!applyingExternalValue) {
|
||||
emitInput(nextValue);
|
||||
}
|
||||
}),
|
||||
EditorView.contentAttributes.of({spellcheck: 'false'}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: (event) => {
|
||||
rest.onfocus?.(event);
|
||||
return false;
|
||||
},
|
||||
blur: (event) => {
|
||||
emitChange(editorView?.state.doc.toString() || '');
|
||||
rest.onblur?.(event);
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...closeBracketsKeymap,
|
||||
...completionKeymap
|
||||
]),
|
||||
languageCompartment.of(createLanguageExtension(normalizedEngine)),
|
||||
indentCompartment.of(indentUnit.of(getIndentUnitForEngine(normalizedEngine))),
|
||||
completionSourceCompartment.of(createBusinessCompletionExtension(normalizedEngine, paramCandidates)),
|
||||
completionCompartment.of(
|
||||
autocompletion({
|
||||
activateOnTyping: true
|
||||
})
|
||||
),
|
||||
decorationCompartment.of(createHighlightDecorations(paramResolvedMap)),
|
||||
editableCompartment.of([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
|
||||
placeholderCompartment.of(placeholder ? cmPlaceholder(placeholder) : [])
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
return () => {
|
||||
editorView?.destroy();
|
||||
editorView = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
effects: languageCompartment.reconfigure(createLanguageExtension(normalizedEngine))
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
effects: indentCompartment.reconfigure(indentUnit.of(getIndentUnitForEngine(normalizedEngine)))
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
effects: completionSourceCompartment.reconfigure(
|
||||
createBusinessCompletionExtension(normalizedEngine, paramCandidates)
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
effects: decorationCompartment.reconfigure(createHighlightDecorations(paramResolvedMap))
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
effects: editableCompartment.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)])
|
||||
});
|
||||
|
||||
const editorDom = editorView.dom;
|
||||
if (disabled) {
|
||||
editorDom.classList.add('cm-disabled');
|
||||
} else {
|
||||
editorDom.classList.remove('cm-disabled');
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
editorView.dispatch({
|
||||
effects: placeholderCompartment.reconfigure(placeholder ? cmPlaceholder(placeholder) : [])
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!editorView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = (value || '') as string;
|
||||
const currentValue = editorView.state.doc.toString();
|
||||
if (nextValue === currentValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyingExternalValue = true;
|
||||
editorView.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: nextValue
|
||||
}
|
||||
});
|
||||
applyingExternalValue = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeExpanded();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const className = 'code-script-expanded-open';
|
||||
if (expanded) {
|
||||
document.body.classList.add(className);
|
||||
} else {
|
||||
document.body.classList.remove(className);
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove(className);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined' || !editorShellEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
moveExpandedShellToBody();
|
||||
} else {
|
||||
restoreExpandedShell();
|
||||
}
|
||||
|
||||
return () => {
|
||||
restoreExpandedShell();
|
||||
};
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<div class="code-script-editor-shell {expanded ? 'expanded-host' : ''}" bind:this={editorShellEl}>
|
||||
{#if expanded}
|
||||
<button type="button" class="code-script-overlay" aria-label="关闭放大代码编辑器" onclick={closeExpanded}></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="code-script-editor nopan nodrag {className} {expanded ? 'expanded' : ''}"
|
||||
style={editorStyle}
|
||||
>
|
||||
<div class="code-script-editor-inner" bind:this={containerEl}></div>
|
||||
|
||||
<div class="code-script-action">
|
||||
<div class="code-script-action-buttons">
|
||||
<Button
|
||||
class="code-script-button {expanded ? 'expanded-cancel' : ''}"
|
||||
onclick={toggleExpanded}
|
||||
title={expanded ? '取消放大' : '放大编辑区域'}
|
||||
aria-label={expanded ? '取消放大' : '放大编辑区域'}
|
||||
>
|
||||
{#if expanded}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3V9H19V6.414L15.707 9.707L14.293 8.293L17.586 5H15V3H21ZM3 21V15H5V17.586L8.293 14.293L9.707 15.707L6.414 19H9V21H3ZM9.707 8.293L8.293 9.707L5 6.414V9H3V3H9V5H6.414L9.707 8.293ZM14.293 15.707L15.707 14.293L19 17.586V15H21V21H15V19H17.586L14.293 15.707Z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15 3H21V9H19V5H15V3ZM3 9V3H9V5H5V9H3ZM15 21V19H19V15H21V21H15ZM3 15H5V19H9V21H3V15Z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</Button>
|
||||
<FloatingTrigger placement="bottom-end" bind:this={triggerObject}>
|
||||
<Button
|
||||
class="code-script-button"
|
||||
disabled={!hasParams || disabled}
|
||||
title={hasParams ? '插入参数模板' : '请先在输入参数中定义参数'}
|
||||
>
|
||||
<span>{"{x}"}</span>
|
||||
</Button>
|
||||
{#snippet floating()}
|
||||
<div class="code-script-panel nowheel">
|
||||
{#if hasParams}
|
||||
{#each paramCandidates as candidate}
|
||||
<button
|
||||
class="code-script-item {candidate.resolved ? '' : 'unresolved'}"
|
||||
title={candidate.resolved ? candidate.name : `${candidate.name}(未映射)`}
|
||||
onclick={() => {
|
||||
insertParam(candidate.name);
|
||||
}}
|
||||
>
|
||||
<span>{candidate.name}</span>
|
||||
{#if !candidate.resolved}
|
||||
<span class="code-script-item-warn" title="该参数未映射,运行时可能为空">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="code-script-empty">请先在输入参数中定义参数</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FloatingTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
.code-script-editor-shell {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-script-editor-shell.expanded-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.code-script-editor {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.code-script-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-editor),
|
||||
.code-script-editor :global(.cm-scroller),
|
||||
.code-script-editor :global(.cm-content),
|
||||
.code-script-editor :global(.cm-line) {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.code-script-editor.expanded {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 700px;
|
||||
max-width: calc(100vw - 360px);
|
||||
min-width: 520px;
|
||||
height: min(80vh, calc(100vh - 180px));
|
||||
max-height: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.code-script-editor.expanded .code-script-editor-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-script-editor.expanded :global(.cm-editor) {
|
||||
height: 100%;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.code-script-editor.expanded :global(.cm-scroller) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.code-script-editor-inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code-script-action {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.code-script-action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:global(.code-script-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);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:global(.code-script-button svg) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
:global(.code-script-button.expanded-cancel) {
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
border-color: #d3defd;
|
||||
color: #1d4ed8;
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.code-script-editor.expanded {
|
||||
width: calc(100vw - 120px);
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
height: calc(100vh - 96px);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.code-script-button:hover) {
|
||||
background: #f0f3ff;
|
||||
color: #2563eb;
|
||||
border-color: #d3defd;
|
||||
}
|
||||
|
||||
.code-script-panel {
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
max-height: 240px;
|
||||
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;
|
||||
}
|
||||
|
||||
.code-script-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;
|
||||
}
|
||||
|
||||
.code-script-item:hover {
|
||||
background: #f5f7ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.code-script-item.unresolved {
|
||||
background: #fff8e6;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.code-script-item-warn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #d97706;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-script-empty {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-special-result) {
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-template-token-valid) {
|
||||
color: #1e4ed8;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-template-token-unresolved) {
|
||||
color: #92400e;
|
||||
background: rgba(217, 119, 6, 0.16);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-template-token-invalid) {
|
||||
color: #b42318;
|
||||
background: rgba(217, 45, 32, 0.14);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete) {
|
||||
border: 1px solid #e6eaf2;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
padding: 6px;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul) {
|
||||
font-family:
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace";
|
||||
font-size: 13px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li) {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]) {
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover) {
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6b7280;
|
||||
opacity: 1;
|
||||
font-size: 12px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionIcon) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel) {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionDetail) {
|
||||
margin-left: auto;
|
||||
color: #94a3b8;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText) {
|
||||
text-decoration: none;
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb) {
|
||||
background: #d6dce8;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(body.code-script-expanded-open) {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@
|
||||
allowSettingOfCondition = true,
|
||||
showSourceHandle = true,
|
||||
showTargetHandle = true,
|
||||
titleHelp = '',
|
||||
onCollapse
|
||||
}: {
|
||||
data: NodeProps['data'],
|
||||
@@ -34,6 +35,7 @@
|
||||
allowSettingOfCondition?: boolean,
|
||||
showSourceHandle?: boolean,
|
||||
showTargetHandle?: boolean,
|
||||
titleHelp?: string,
|
||||
onCollapse?: (key: string) => void,
|
||||
} = $props();
|
||||
|
||||
@@ -45,6 +47,7 @@
|
||||
key: 'key',
|
||||
icon,
|
||||
title: data.title as string,
|
||||
titleHelp,
|
||||
description: data.description as string,
|
||||
content: children
|
||||
}];
|
||||
@@ -296,4 +299,3 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -4,47 +4,78 @@
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {getOptions} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
// 添加生命周期函数
|
||||
import {onMount} from 'svelte';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import CodeScriptEditor from '../core/CodeScriptEditor.svelte';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
[key: string]: any
|
||||
} = $props();
|
||||
// 在组件挂载时检查并设置默认值
|
||||
onMount(() => {
|
||||
if (!data.engine) {
|
||||
updateNodeData(currentNodeId, () => {
|
||||
return {
|
||||
engine: 'qlexpress'
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const options = getOptions();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const codeNodeHelp = `结果如何返回
|
||||
- 请把代码执行结果写到 _result 中。
|
||||
- JS:_result.answer = 'ok'
|
||||
- Python:_result['answer'] = 'ok'
|
||||
|
||||
输出参数如何配置
|
||||
- 在“输出参数”中新增字段(如 answer、score)。
|
||||
- 字段名建议与 _result 的 key 保持一致,便于下游节点引用。
|
||||
- 下游节点可引用:代码节点ID.输出参数名。
|
||||
|
||||
示例
|
||||
- 代码里写:
|
||||
_result.answer = '你好';
|
||||
_result.score = 95;
|
||||
- 输出参数配置:answer(String)、score(Number)
|
||||
- 结束节点输出参数可引用:代码节点ID.answer、代码节点ID.score`;
|
||||
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
});
|
||||
|
||||
let engines = $state<SelectItem[]>([
|
||||
{ label: 'JavaScript', value: 'js' }
|
||||
]);
|
||||
const defaultEngine = $derived.by(() => {
|
||||
const firstAvailable = engines.find((item) => item.selectable !== false);
|
||||
return firstAvailable?.value || 'js';
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
onMount(async () => {
|
||||
const codeEngines = await options.provider?.codeEngine?.();
|
||||
if (codeEngines && codeEngines.length > 0) {
|
||||
engines = codeEngines;
|
||||
}
|
||||
|
||||
const engines = [
|
||||
{ label: 'JavaScript', value: 'js' },
|
||||
{ label: 'Groovy', value: 'groovy' },
|
||||
{ label: 'QLExpress', value: 'qlexpress' }
|
||||
];
|
||||
const currentEngine = data.engine;
|
||||
const currentEngineSupported = engines.some((item) => item.value === currentEngine && item.selectable !== false);
|
||||
if (!currentEngine || !currentEngineSupported) {
|
||||
updateNodeData(currentNodeId, () => {
|
||||
return {
|
||||
engine: defaultEngine
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<NodeWrapper {data} {...rest}>
|
||||
<NodeWrapper
|
||||
{data}
|
||||
titleHelp={codeNodeHelp}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
{#snippet icon()}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -75,16 +106,17 @@
|
||||
engine: newValue
|
||||
}
|
||||
})
|
||||
}} value={data.engine ? [data.engine] : ['qlexpress']} />
|
||||
}} value={data.engine ? [data.engine] : [defaultEngine]} />
|
||||
</div>
|
||||
|
||||
<div class="setting-title">执行代码</div>
|
||||
<div class="setting-item">
|
||||
<ParamTokenEditor
|
||||
<CodeScriptEditor
|
||||
mode="textarea"
|
||||
rows={10}
|
||||
placeholder="请输入执行代码,注:输出内容需添加到_result中,如:_result['key'] = value 或者 _result.key = value"
|
||||
style="width: 100%"
|
||||
engine={(data.engine as string) || (defaultEngine as string)}
|
||||
parameters={editorParameters}
|
||||
oninput={(e:any)=>{
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
@@ -134,4 +166,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createBusinessCompletions,
|
||||
normalizeCodeEngine,
|
||||
resolveBusinessCompletionContext,
|
||||
shouldAutoAppendCallParens,
|
||||
shouldSkipBusinessCompletion
|
||||
} from './codeCompletion';
|
||||
|
||||
describe('codeCompletion utils', () => {
|
||||
it('should normalize engine aliases', () => {
|
||||
expect(normalizeCodeEngine('py')).toBe('python');
|
||||
expect(normalizeCodeEngine('python')).toBe('python');
|
||||
expect(normalizeCodeEngine('js')).toBe('javascript');
|
||||
expect(normalizeCodeEngine('javascript')).toBe('javascript');
|
||||
expect(normalizeCodeEngine('')).toBe('javascript');
|
||||
});
|
||||
|
||||
it('should create business completion list with stable priority and dedupe', () => {
|
||||
const completions = createBusinessCompletions('python', [
|
||||
{ name: 'input.text', resolved: true },
|
||||
{ name: 'input.text', resolved: false },
|
||||
{ name: 'question', resolved: false }
|
||||
]);
|
||||
|
||||
expect(completions[0]).toMatchObject({
|
||||
label: '_result',
|
||||
type: 'variable'
|
||||
});
|
||||
|
||||
expect(completions.filter((item) => item.label === 'input.text').length).toBe(1);
|
||||
expect(completions.find((item) => item.label === 'input.text')).toMatchObject({
|
||||
apply: '{{input.text}}',
|
||||
detail: '参数模板'
|
||||
});
|
||||
expect(completions.find((item) => item.label === 'question')).toMatchObject({
|
||||
apply: '{{question}}',
|
||||
detail: '参数模板(未映射)'
|
||||
});
|
||||
|
||||
expect(completions.some((item) => item.type === 'snippet' && item.label === 'if-else')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect blocked nodes for comment/string/property contexts', () => {
|
||||
expect(shouldSkipBusinessCompletion('Comment')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('LineComment')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('String')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('TemplateString')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('PropertyName')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('VariableName')).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve template context for parameter token completion', () => {
|
||||
const source = "value = {{input.use";
|
||||
const pos = source.length;
|
||||
const context = resolveBusinessCompletionContext(source, pos);
|
||||
expect(context).toBeTruthy();
|
||||
expect(context?.from).toBe(8);
|
||||
expect(context?.validFor).toBeInstanceOf(RegExp);
|
||||
expect(context?.validFor.source).toBe('^[\\w.]*$');
|
||||
});
|
||||
|
||||
it('should resolve word context and skip property access context', () => {
|
||||
const source = '_result.value';
|
||||
expect(resolveBusinessCompletionContext(source, source.length)).toBeNull();
|
||||
|
||||
const wordSource = 'ret';
|
||||
const context = resolveBusinessCompletionContext(wordSource, wordSource.length);
|
||||
expect(context).toBeTruthy();
|
||||
expect(context?.from).toBe(0);
|
||||
expect(context?.validFor).toBeInstanceOf(RegExp);
|
||||
expect(context?.validFor.source).toBe('^[\\w$]*$');
|
||||
});
|
||||
|
||||
it('should auto append call parens for function/method completions only', () => {
|
||||
expect(shouldAutoAppendCallParens({ label: 'print', type: 'function' }, '')).toBe(true);
|
||||
expect(shouldAutoAppendCallParens({ label: 'run', type: 'method' }, '')).toBe(true);
|
||||
expect(shouldAutoAppendCallParens({ label: 'count', type: 'variable' }, '')).toBe(false);
|
||||
expect(shouldAutoAppendCallParens({ label: 'print', type: 'function' }, '(')).toBe(false);
|
||||
expect(shouldAutoAppendCallParens({ label: 'call', type: 'function', apply: 'call()' }, '')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Completion, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { ParameterCandidate } from './paramToken';
|
||||
|
||||
export type CodeEngine = 'javascript' | 'python';
|
||||
|
||||
export interface BusinessCompletionConfig {
|
||||
engine: CodeEngine;
|
||||
paramCandidates: ParameterCandidate[];
|
||||
}
|
||||
|
||||
interface CompletionContextHint {
|
||||
from: number;
|
||||
validFor: RegExp;
|
||||
}
|
||||
|
||||
const BLOCKED_NODE_NAMES = new Set([
|
||||
'String',
|
||||
'TemplateString',
|
||||
'FormatString',
|
||||
'Comment',
|
||||
'LineComment',
|
||||
'BlockComment',
|
||||
'PropertyName',
|
||||
'PrivatePropertyName'
|
||||
]);
|
||||
|
||||
const JS_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [
|
||||
{
|
||||
label: 'if-else',
|
||||
detail: '条件分支',
|
||||
insert: "if (condition) {\n _result.value = value;\n} else {\n _result.value = null;\n}"
|
||||
},
|
||||
{
|
||||
label: 'for-of',
|
||||
detail: '遍历数组',
|
||||
insert: "for (const item of items) {\n // TODO\n}\n_result.done = true;"
|
||||
},
|
||||
{
|
||||
label: 'result-object',
|
||||
detail: '返回对象',
|
||||
insert: "_result.message = 'ok';\n_result.data = data;"
|
||||
}
|
||||
];
|
||||
|
||||
const PYTHON_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [
|
||||
{
|
||||
label: 'if-else',
|
||||
detail: '条件分支',
|
||||
insert: "if condition:\n _result['value'] = value\nelse:\n _result['value'] = None"
|
||||
},
|
||||
{
|
||||
label: 'for-loop',
|
||||
detail: '遍历数组',
|
||||
insert: "for item in items:\n # TODO\n pass\n_result['done'] = True"
|
||||
},
|
||||
{
|
||||
label: 'result-object',
|
||||
detail: '返回对象',
|
||||
insert: "_result['message'] = 'ok'\n_result['data'] = data"
|
||||
}
|
||||
];
|
||||
|
||||
export function normalizeCodeEngine(rawEngine?: string): CodeEngine {
|
||||
const normalized = (rawEngine || 'js').trim().toLowerCase();
|
||||
if (normalized === 'python' || normalized === 'py') {
|
||||
return 'python';
|
||||
}
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
export function shouldSkipBusinessCompletion(nodeName: string): boolean {
|
||||
if (!nodeName) {
|
||||
return false;
|
||||
}
|
||||
if (BLOCKED_NODE_NAMES.has(nodeName)) {
|
||||
return true;
|
||||
}
|
||||
if (nodeName.endsWith('Comment')) {
|
||||
return true;
|
||||
}
|
||||
return nodeName.includes('String');
|
||||
}
|
||||
|
||||
export function createBusinessCompletions(
|
||||
targetEngine: CodeEngine,
|
||||
candidates: ParameterCandidate[]
|
||||
): Completion[] {
|
||||
const resultCompletion: Completion = {
|
||||
label: '_result',
|
||||
type: 'variable',
|
||||
detail: '代码节点输出对象',
|
||||
boost: 900
|
||||
};
|
||||
|
||||
const parameterCompletions: Completion[] = candidates.map((candidate) => ({
|
||||
label: candidate.name,
|
||||
type: 'variable',
|
||||
detail: candidate.resolved ? '参数模板' : '参数模板(未映射)',
|
||||
apply: `{{${candidate.name}}}`,
|
||||
boost: 700
|
||||
}));
|
||||
|
||||
const snippetCompletions: Completion[] = (targetEngine === 'python' ? PYTHON_SNIPPETS : JS_SNIPPETS).map(
|
||||
(snippet) => ({
|
||||
label: snippet.label,
|
||||
type: 'snippet',
|
||||
detail: snippet.detail,
|
||||
apply: snippet.insert,
|
||||
boost: 500
|
||||
})
|
||||
);
|
||||
|
||||
return dedupeCompletions([resultCompletion, ...parameterCompletions, ...snippetCompletions]);
|
||||
}
|
||||
|
||||
function dedupeCompletions(items: Completion[]): Completion[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: Completion[] = [];
|
||||
for (const item of items) {
|
||||
const key = `${item.label}::${item.type || ''}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function resolveTemplateContext(source: string, pos: number): CompletionContextHint | null {
|
||||
const start = Math.max(0, pos - 300);
|
||||
const prefix = source.slice(start, pos);
|
||||
const match = /\{\{\s*([\w.]*)$/.exec(prefix);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: pos - match[0].length,
|
||||
validFor: /^[\w.]*$/
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWordContext(source: string, pos: number): CompletionContextHint | null {
|
||||
let from = pos;
|
||||
while (from > 0 && /[\w$]/.test(source[from - 1])) {
|
||||
from -= 1;
|
||||
}
|
||||
if (from === pos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (from > 0 && source[from - 1] === '.') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from,
|
||||
validFor: /^[\w$]*$/
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBusinessCompletionContext(source: string, pos: number): CompletionContextHint | null {
|
||||
return resolveTemplateContext(source, pos) || resolveWordContext(source, pos);
|
||||
}
|
||||
|
||||
export function createBusinessCompletionSource(config: BusinessCompletionConfig): CompletionSource {
|
||||
return (context) => {
|
||||
const node = syntaxTree(context.state).resolveInner(context.pos, -1);
|
||||
if (shouldSkipBusinessCompletion(node.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docText = context.state.doc.toString();
|
||||
const resolved = resolveBusinessCompletionContext(docText, context.pos);
|
||||
if (!resolved && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: CompletionResult = {
|
||||
from: resolved?.from ?? context.pos,
|
||||
options: createBusinessCompletions(config.engine, config.paramCandidates)
|
||||
};
|
||||
|
||||
if (resolved?.validFor) {
|
||||
result.validFor = resolved.validFor;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoAppendCallParens(completion: Completion | null | undefined, nextChar: string): boolean {
|
||||
if (!completion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const completionType = (completion.type || '').toLowerCase();
|
||||
if (completionType !== 'function' && completionType !== 'method') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextChar === '(') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof completion.apply === 'string' && completion.apply.includes('(')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof completion.apply === 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -6,6 +6,13 @@ export type TinyflowData = Partial<ReturnType<ReturnType<typeof useSvelteFlow>['
|
||||
export type SelectItem = {
|
||||
value: number | string;
|
||||
label: string | Snippet;
|
||||
description?: string;
|
||||
selectable?: boolean;
|
||||
icon?: string;
|
||||
nodeType?: string;
|
||||
dataType?: string;
|
||||
displayLabel?: string;
|
||||
tags?: string[];
|
||||
children?: SelectItem[];
|
||||
};
|
||||
|
||||
@@ -80,6 +87,7 @@ export type TinyflowOptions = {
|
||||
llm?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
knowledge?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
searchEngine?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
codeEngine?: () => SelectItem[] | Promise<SelectItem[]>;
|
||||
} & Record<string, () => SelectItem[] | Promise<SelectItem[]>>;
|
||||
//type : node
|
||||
customNodes?: Record<string, CustomNode>;
|
||||
|
||||
Reference in New Issue
Block a user