feat: 增强代码节点编辑器与放大阅读体验

- tinyflow-ui: 新增 CodeScriptEditor(CodeMirror 6)并支持语法高亮、上下文补全、自动括号与 _result 高亮

- tinyflow-ui: 代码节点接入引擎能力列表与节点说明提示,统一 JS/Python 编辑体验

- tinyflow-ui: 增加放大编辑模式,支持居中弹层、ESC 与点击外部关闭

- app/workflow: 对接 supportedCodeEngines 能力并透传 codeEngine provider
This commit is contained in:
2026-03-01 19:57:28 +08:00
parent beeb62c4fc
commit ac8de7dbb8
11 changed files with 1541 additions and 23 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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>;