perf: 优化工作流的节点UI和交互

This commit is contained in:
2026-03-01 15:52:22 +08:00
parent 4ef17da6f4
commit 05990072e6
10 changed files with 848 additions and 150 deletions

View File

@@ -1,23 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import {computed, onMounted, onUnmounted, ref} from 'vue';
import {useRoute} from 'vue-router';
import { getOptions, sortNodes } from '@easyflow/utils';
import {getOptions, sortNodes} from '@easyflow/utils';
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
import { ArrowLeft, Position } from '@element-plus/icons-vue';
import { Tinyflow } from '@tinyflow-ai/vue';
import { ElButton, ElDrawer, ElMessage, ElSkeleton } from 'element-plus';
import {ArrowLeft, Position} from '@element-plus/icons-vue';
import {Tinyflow} from '@tinyflow-ai/vue';
import {ElButton, ElDrawer, ElMessage, ElSkeleton} from 'element-plus';
import { api } from '#/api/request';
import {api} from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
import { $t } from '#/locales';
import { router } from '#/router';
import {$t} from '#/locales';
import {router} from '#/router';
import ExecResult from '#/views/ai/workflow/components/ExecResult.vue';
import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import { getCustomNode } from './customNode/index';
import {getCustomNode} from './customNode/index';
import nodeNames from './customNode/nodeNames';
import '@tinyflow-ai/vue/dist/index.css';
@@ -46,7 +47,35 @@ const tinyFlowData = ref<any>(null);
const llmList = ref<any>([]);
const knowledgeList = ref<any>([]);
const provider = computed(() => ({
llm: () => getOptions('title', 'id', llmList.value),
llm: () => llmList.value.map((item: any) => {
let iconStr = undefined;
if (item.modelProvider?.icon) {
iconStr = `<img src="${item.modelProvider.icon}" style="width:100%; height:100%; object-fit:contain;" />`;
} else if (item.modelProvider?.providerType) {
const svgStr = getIconByValue(item.modelProvider.providerType);
if (svgStr) {
iconStr = svgStr;
}
}
// Extract brand and model name directly from the title if it contains '/'
let displayTitle = item.title || '';
let brandName = item.modelProvider?.providerName || '其他';
if (displayTitle.includes('/')) {
const parts = displayTitle.split('/');
brandName = parts[0];
displayTitle = parts.slice(1).join('/');
}
return {
label: displayTitle,
value: item.id,
brand: brandName,
icon: iconStr,
description: item.description,
};
}),
knowledge: () => getOptions('title', 'id', knowledgeList.value),
searchEngine: (): any => [
{

View File

@@ -3,11 +3,13 @@
import {createFloating} from '../utils/createFloating';
import type {Placement} from '@floating-ui/dom';
const { children, floating, placement = 'bottom' }:
const { children, floating, placement = 'bottom', onShow, onHide }:
{
children: Snippet,
floating: Snippet,
placement?: Placement,
onShow?: () => void,
onHide?: () => void
} = $props();
let triggerEl!: HTMLDivElement, contentEl!: HTMLDivElement;
@@ -18,23 +20,26 @@
trigger: triggerEl,
floatContent: contentEl,
interactive: true,
placement
placement,
onShow,
onHide
});
return () => {
floatingInstance.destroy();
};
});
export function hide() {
floatingInstance.hide();
floatingInstance?.hide();
onHide?.();
}
</script>
<div style="position: relative">
<div bind:this={triggerEl}>
<div style="position: relative" bind:this={triggerEl}>
{@render children() }
</div>
<div style="display: none; width: 100%;z-index: 9999" bind:this={contentEl}>
{@render floating() }
</div>
</div>
<div style="display: none; width: max-content; z-index: 99999; position: absolute;" bind:this={contentEl}>
{@render floating() }
</div>

View File

@@ -1,30 +1,28 @@
<script lang="ts">
import {FloatingTrigger, Render} from './index';
import {FloatingTrigger} from './index';
import type {SelectItem} from '#types';
import {nodeIcons} from '../../consts';
let {
items,
onSelect,
value = [],
defaultValue = [],
expandAll = true,
multiple = false,
expandValue = [],
placeholder,
variant = 'default',
...rest
}: {
items: SelectItem[],
onSelect?: (item: SelectItem) => void,
value?: (any)[],
defaultValue?: (number | string | undefined)[],
expandAll?: boolean,
expandValue?: (number | string)[],
multiple?: boolean
placeholder?: string
variant?: 'default' | 'reference' | 'model'
[key: string]: any
} = $props();
let activeItemsState = $derived.by(() => {
const resultItems: SelectItem[] = [];
const fillResult = (items: SelectItem[]) => {
@@ -48,50 +46,179 @@
return resultItems;
});
let triggerObject: any;
function handlerOnSelect(item: SelectItem) {
let triggerObject: any = $state();
let hoveredItem: SelectItem | null = $state(null);
let isOpen = $state(false);
function closeMenu() {
triggerObject?.hide();
onSelect?.(item);
isOpen = false;
hoveredItem = null;
}
function handlerOnSelect(item: SelectItem) {
if (item.selectable !== false) {
onSelect?.(item);
closeMenu();
} else {
if (variant === 'reference') {
hoveredItem = item;
} else {
hoveredItem = hoveredItem === item ? null : item;
}
}
}
function handleMouseEnter(item: SelectItem) {
if (variant === 'reference' && item.children && item.children.length > 0) {
hoveredItem = item;
}
}
</script>
{#snippet selectItems(items: SelectItem[])}
{#each items as item, index (`${index}_${item.value}`)}
<button class="tf-select-content-item"
onclick={() => handlerOnSelect(item)}
>
<span>
{#if item.children && item.children.length > 0}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path
d="M12 14L8 10H16L12 14Z"></path></svg>
{/if}
</span>
<Render target={item.label} />
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
{#each items as item}
<button class="tf-select-default-item" style="padding-left: {10 + depth * 14}px" onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}>
<span class="tf-select-default-item-label">{item.label}</span>
</button>
{#if (item.children && item.children.length > 0 && (expandAll || expandValue.includes(item.value)))}
<div class="tf-select-content-children">
{@render selectItems(item.children)}
{#if item.children && item.children.length > 0}
<div class="tf-select-default-children">
{@render renderDefaultItems(item.children, depth + 1)}
</div>
{/if}
{/each}
{/snippet}
{#snippet renderModelItems(items: SelectItem[])}
{#each items as group}
{#if group.selectable === false}
<div class="tf-select-model-group-title">
{#if group.icon}
<span class="tf-select-model-group-icon">{@html group.icon}</span>
{/if}
<span>{group.label}</span>
</div>
{#each group.children || [] as model}
<button class="tf-select-model-item {value.includes(model.value) ? 'active' : ''}" onclick={(e) => { e.stopPropagation(); handlerOnSelect(model); }}>
<div class="tf-select-model-icon">
{#if model.icon}
{@html model.icon}
{:else}
<div class="tf-select-model-avatar">{group.label ? group.label.toString().charAt(0) : 'M'}</div>
{/if}
</div>
<div class="tf-select-model-info">
<div class="tf-select-model-title">{model.label}</div>
{#if model.tags && model.tags.length > 0}
<div class="tf-select-model-tags">
{#each model.tags as tag}
<span class="tf-select-model-tag">{tag}</span>
{/each}
</div>
{/if}
{#if model.description}
<div class="tf-select-model-desc">{model.description}</div>
{/if}
</div>
</button>
{/each}
{:else}
<button class="tf-select-model-item {value.includes(group.value) ? 'active' : ''}" onclick={(e) => { e.stopPropagation(); handlerOnSelect(group); }}>
<div class="tf-select-model-icon">
{#if group.icon}
{@html group.icon}
{:else}
<div class="tf-select-model-avatar">{group.label ? group.label.toString().charAt(0) : 'M'}</div>
{/if}
</div>
<div class="tf-select-model-info">
<div class="tf-select-model-title">{group.label}</div>
{#if group.tags && group.tags.length > 0}
<div class="tf-select-model-tags">
{#each group.tags as tag}
<span class="tf-select-model-tag">{tag}</span>
{/each}
</div>
{/if}
{#if group.description}
<div class="tf-select-model-desc">{group.description}</div>
{/if}
</div>
</button>
{/if}
{/each}
{/snippet}
{#snippet renderNestedItems(items: SelectItem[], depth = 0)}
{#each items as item}
<div class="tf-select-item-container" style="padding-left: {depth * 16}px">
<button class="tf-select-item {item.children && item.children.length > 0 ? 'has-children' : ''}"
onclick={() => handlerOnSelect(item)}>
<div class="tf-parameter-label">
<div class="tf-parameter-name-wrapper">
{#if item.children && item.children.length > 0}
<span class="tf-parameter-expand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
</span>
{/if}
<span class="tf-parameter-name">{item.label}</span>
</div>
{#if item.dataType}
<span class="tf-parameter-type">{item.dataType}</span>
{/if}
</div>
</button>
</div>
{#if item.children && item.children.length > 0}
<div class="tf-select-item-children">
{@render renderNestedItems(item.children, depth + 1)}
</div>
{/if}
{/each}
{/snippet}
<div {...rest} class="tf-select {rest['class']}">
<FloatingTrigger bind:this={triggerObject}>
<button class="tf-select-input nopan nodrag" {...rest}>
<FloatingTrigger bind:this={triggerObject} onShow={() => isOpen = true} onHide={() => { isOpen = false; hoveredItem = null; }}>
<button class="tf-select-input nopan nodrag {isOpen ? 'active' : ''}" {...rest}>
<div class="tf-select-input-value">
{#each activeItemsState as item, index (`${index}_${item.value}`)}
{#if !multiple}
{#if index === 0}
<Render target={item.label} />
<div class="tf-parameter-label-input">
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
<span class="tf-select-item-icon-input">
{@html nodeIcons[item.nodeType]}
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{@html item.icon}
</span>
{/if}
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
{#if variant === 'reference' && item.dataType}
<span class="tf-parameter-type">{item.dataType}</span>
{/if}
</div>
{/if}
{:else}
<Render target={item.label} />
<div class="tf-parameter-label-input">
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
<span class="tf-select-item-icon-input">
{@html nodeIcons[item.nodeType]}
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{@html item.icon}
</span>
{/if}
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
{#if variant === 'reference' && item.dataType}
<span class="tf-parameter-type">{item.dataType}</span>
{/if}
</div>
{#if index < activeItemsState.length - 1}
,
<span style="margin-right: 4px;">,</span>
{/if}
{/if}
{:else}
@@ -101,17 +228,486 @@
{/each}
</div>
<div class="tf-select-input-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z"></path>
{#if variant === 'reference'}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L20.6603 7V17L12 22L3.33975 17V7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2" fill="currentColor"/>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.9999 13.1714L16.9497 8.22168L18.3639 9.63589L11.9999 15.9999L5.63599 9.63589L7.0502 8.22168L11.9999 13.1714Z"></path>
</svg>
{/if}
</div>
</button>
{#snippet floating()}
<div class="tf-select-content nopan nodrag nowheel ">
{@render selectItems(items)}
{#if variant === 'default'}
<div class="tf-select-default-wrapper nopan nodrag nowheel">
{@render renderDefaultItems(items)}
</div>
{:else if variant === 'model'}
<div class="tf-select-model-wrapper nopan nodrag nowheel">
<div class="tf-select-model-header">
<span class="tf-select-model-header-title">Model selection</span>
<svg class="tf-select-model-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<div class="tf-select-model-list">
{@render renderModelItems(items)}
</div>
</div>
{:else}
<div class="tf-select-wrapper nopan nodrag nowheel">
<!-- 一级列表:节点/分类 -->
<div class="tf-select-list tf-select-primary-list">
{#each items as item}
<button class="tf-select-item {hoveredItem?.value === item.value ? 'active' : ''}"
onmouseenter={() => handleMouseEnter(item)}
onclick={() => handlerOnSelect(item)}
>
<span class="tf-select-item-icon">
{#if item.icon}
{@html item.icon}
{:else if item.nodeType && nodeIcons[item.nodeType]}
{@html nodeIcons[item.nodeType]}
{:else if item.children && item.children.length > 0}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h18M3 12h18M3 17h18"></path></svg>
{/if}
</span>
<span class="tf-select-item-label">{item.label}</span>
{#if item.children && item.children.length > 0}
<span class="tf-select-item-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</span>
{/if}
</button>
{/each}
</div>
<!-- 二级列表:树形展示参数及其嵌套子项 -->
{#if hoveredItem && hoveredItem.children && hoveredItem.children.length > 0}
<div class="tf-select-list tf-select-secondary-list">
{@render renderNestedItems(hoveredItem.children)}
</div>
{/if}
</div>
{/if}
{/snippet}
</FloatingTrigger>
</div>
<style lang="less">
.tf-select-default-wrapper {
display: flex;
flex-direction: column;
background: #fff;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
width: max-content;
min-width: 100%;
z-index: 99999;
box-sizing: border-box;
max-height: 220px;
overflow: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.tf-select-default-item {
display: flex;
align-items: center;
padding: 5px 10px;
border: none;
background: #fff;
border-radius: 5px;
cursor: pointer;
line-height: 100%;
gap: 2px;
width: 100%;
text-align: left;
font-size: 13px;
color: #333;
&:hover {
background: #f0f0f0;
}
}
.tf-select-default-children {
display: flex;
flex-direction: column;
}
/* Model Variant Styles */
.tf-select-model-wrapper {
display: flex;
flex-direction: column;
background: #fff;
margin-top: 5px;
border: 1px solid #e5e7eb;
border-radius: 10px; /* slightly smaller radius */
width: 280px; /* Reduced width to be more compact */
z-index: 99999;
box-sizing: border-box;
max-height: 400px; /* slightly less tall */
overflow: hidden;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
}
.tf-select-model-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px; /* tighter padding */
border-bottom: 1px solid #f3f4f6;
background: #fff;
}
.tf-select-model-header-title {
font-size: 13px; /* smaller title */
font-weight: 600;
color: #111827;
}
.tf-select-model-header-icon {
width: 14px;
height: 14px;
color: #6b7280;
cursor: pointer;
}
.tf-select-model-list {
display: flex;
flex-direction: column;
padding: 8px; /* tighter padding */
overflow-y: auto;
gap: 4px; /* tighter gap between items */
}
.tf-select-model-group-title {
font-size: 12px; /* smaller group title */
font-weight: 500;
color: #6b7280;
margin-top: 6px;
margin-bottom: 2px;
padding-left: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.tf-select-model-group-icon {
width: 14px; /* smaller icon */
height: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tf-select-model-group-icon :global(svg), .tf-select-model-group-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.tf-select-model-group-title:first-child {
margin-top: 0;
}
.tf-select-model-item {
display: flex;
align-items: flex-start;
padding: 6px 8px; /* tighter item padding */
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
text-align: left;
gap: 10px;
transition: all 0.2s;
}
.tf-select-model-item:hover {
background: #f9fafb;
}
.tf-select-model-item.active {
background: #e0e7ff;
}
.tf-select-model-icon {
flex-shrink: 0;
width: 32px; /* smaller item icon */
height: 32px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
margin-top: 2px;
}
.tf-select-model-icon :global(svg), .tf-select-model-icon img {
width: 70%; /* icon inside box */
height: 70%;
object-fit: contain;
}
.tf-select-model-avatar {
font-size: 14px;
font-weight: 600;
color: #9ca3af;
}
.tf-select-model-info {
display: flex;
flex-direction: column;
gap: 2px; /* tighter info gap */
flex-grow: 1;
overflow: hidden;
}
.tf-select-model-title {
font-size: 13px; /* smaller font */
font-weight: 500;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tf-select-model-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tf-select-model-tag {
background: #f3f4f6;
color: #6b7280;
font-size: 10px; /* smaller tag */
padding: 1px 6px;
border-radius: 4px;
}
.tf-select-model-desc {
font-size: 11px; /* smaller desc */
color: #6b7280;
line-height: 1.3;
margin-top: 2px;
display: -webkit-box;
-webkit-line-clamp: 2; /* limit lines */
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Reference Variant Styles */
.tf-select-wrapper {
display: flex;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e7eb;
overflow: hidden;
min-width: 220px;
width: max-content;
max-width: 600px;
max-height: 480px;
transition: width 0.2s ease;
z-index: 99999;
}
.tf-select-list {
display: flex;
flex-direction: column;
padding: 8px;
overflow-y: auto;
&.tf-select-primary-list {
width: 220px;
flex-shrink: 0;
background: #f9fafb;
}
&.tf-select-secondary-list {
flex-grow: 1;
min-width: 200px;
background: #fff;
padding: 12px;
border-left: 1px solid #f3f4f6;
animation: slideIn 0.2s ease-out;
}
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.tf-select-item-container {
position: relative;
}
.tf-select-item-children {
position: relative;
&::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 1px;
background: #f0f0f0;
}
}
.tf-select-item {
display: flex;
align-items: center;
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
text-align: left;
font-size: 13px;
color: #374151;
gap: 10px;
width: 100%;
transition: all 0.15s;
margin-bottom: 2px;
&:hover {
background: #f3f4f6;
}
&.active {
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
&.has-children {
background: #f8fafc;
margin-bottom: 4px;
&:hover {
background: #f1f5f9;
}
}
&-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #3474ff;
background: #cedafb;
border-radius: 5px;
padding: 3px;
box-sizing: border-box;
:global(svg) {
width: 16px;
height: 16px;
}
}
&-label {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-arrow {
width: 14px;
height: 14px;
color: #9ca3af;
}
}
.tf-parameter-label-input {
display: flex;
align-items: center;
gap: 6px;
margin-right: 4px;
.tf-select-item-icon-input {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #3474ff;
background: #cedafb;
border-radius: 4px;
padding: 2px;
box-sizing: border-box;
:global(svg) {
width: 12px;
height: 12px;
}
}
.tf-select-item-icon-input-model {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
:global(svg), img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
.tf-parameter-label {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
gap: 8px;
}
.tf-parameter-name-wrapper {
display: flex;
align-items: center;
gap: 6px;
}
.tf-parameter-expand-icon {
width: 12px;
height: 12px;
color: currentColor;
opacity: 0.6;
}
.tf-parameter-name {
color: inherit;
}
.tf-parameter-type {
background: rgba(0, 0, 0, 0.06);
color: #6b7280;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
}
</style>

View File

@@ -94,7 +94,7 @@
{#if param.refType === 'fixed'}
<Input value={param.value} placeholder="请输入参数值" oninput={(event)=>updateParamByEvent('value', event)} />
{:else if (param.refType !== 'input')}
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]}
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]} variant="reference"
expandAll
onSelect={updateRef} />
{/if}

View File

@@ -95,7 +95,7 @@
{#if param.refType === 'fixed'}
<Input value={param.value} placeholder="请输入参数值" oninput={(event)=>updateParamByEvent('value', event)} />
{:else if (param.refType !== 'input')}
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]}
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]} variant="reference"
expandAll
onSelect={updateRef} />
{/if}

View File

@@ -29,7 +29,53 @@
let llmArray = $state<SelectItem[]>([]);
onMount(async () => {
const newLLMs = await options.provider?.llm?.();
const isFlat = newLLMs?.every(item => !item.children);
if (isFlat && newLLMs && newLLMs.length > 0) {
const grouped = new Map<string, SelectItem[]>();
for (const llm of newLLMs) {
// If it still has a slash, parse it; otherwise, check if there's a custom logic we can infer brand.
// In WorkflowDesign we pass `item.modelProvider?.providerName` via some other way, but here it's flat.
// Actually, the label is just the title now (e.g. 'deepseek-chat').
// Wait, LLMNode doesn't know the brand unless it's in the string or we modify WorkflowDesign to pass `brand`.
// Let's modify WorkflowDesign to pass `brand` instead.
let brand = (llm as any).brand || '其他';
let modelName = llm.label;
if (!grouped.has(brand)) {
grouped.set(brand, []);
}
grouped.get(brand)!.push({
...llm,
label: modelName,
displayLabel: modelName // 外部选中时也只显示模型名称
});
}
const treeArray: SelectItem[] = [];
for (const [brand, models] of grouped) {
// Try to get a representative icon for the brand from its children
let groupIcon = undefined;
if (models.length > 0) {
const modelWithIcon = models.find(m => m.icon);
if (modelWithIcon) {
groupIcon = modelWithIcon.icon;
}
}
treeArray.push({
label: brand,
value: 'group_' + brand,
selectable: false,
icon: groupIcon,
children: models
});
}
llmArray.push(...treeArray);
} else {
llmArray.push(...(newLLMs || []));
}
});
const { updateNodeData } = useSvelteFlow();
@@ -116,7 +162,7 @@
<Heading level={3} mt="10px">模型设置</Heading>
<div class="setting-title">模型</div>
<div class="setting-item">
<Select items={llmArray} style="width: 100%" placeholder="请选择模型" onSelect={(item)=>{
<Select items={llmArray} variant="model" style="width: 100%" placeholder="请选择模型" onSelect={(item)=>{
const newValue = item.value;
updateNodeData(currentNodeId, ()=>{
return {

View File

@@ -1,7 +1,6 @@
import {
arrow,
computePosition,
flip,
type FlipOptions,
offset,
type OffsetOptions,
@@ -20,6 +19,8 @@ export type FloatingOptions = {
shiftOptions?: ShiftOptions;
interactive?: boolean;
showArrow?: boolean;
onShow?: () => void;
onHide?: () => void;
};
export type FloatingInstance = {
@@ -37,7 +38,9 @@ export const createFloating = ({
flipOptions,
shiftOptions,
interactive,
showArrow
showArrow,
onShow,
onHide
}: FloatingOptions): FloatingInstance => {
if (typeof trigger === 'string') {
const triggerEl = document.querySelector(trigger);
@@ -78,14 +81,15 @@ export const createFloating = ({
placement: placement,
middleware: [
offset(offsetOptions), // 手动偏移配置
flip(flipOptions), //自动翻转
// flip(flipOptions), // 注释掉自动翻转,强制向下弹出,避免遮挡顶部工具栏
shift(shiftOptions), //自动偏移(使得浮动元素能够进入视野)
...(showArrow ? [arrow({ element: arrowElement })] : [])
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(floating.style, {
left: `${x}px`,
top: `${y}px`
top: `${y}px`,
position: 'absolute'
});
if (showArrow) {
@@ -113,7 +117,7 @@ export const createFloating = ({
function showTooltip() {
floating.style.display = 'block';
floating.style.visibility = 'block';
floating.style.visibility = 'visible';
floating.style.position = 'absolute';
if (showArrow) {
@@ -122,6 +126,7 @@ export const createFloating = ({
visible = true;
updatePosition();
onShow?.();
}
function hideTooltip() {
@@ -130,10 +135,10 @@ export const createFloating = ({
arrowElement.style.display = 'none';
}
visible = false;
onHide?.();
}
function onTrigger(event: any) {
event.stopPropagation();
if (!visible) {
showTooltip();
} else {
@@ -142,7 +147,7 @@ export const createFloating = ({
}
function hideTooltipCompute(event: any) {
if (floating.contains(event.target as Node)) {
if (floating.contains(event.target as Node) || (trigger as Node).contains(event.target as Node)) {
return;
}
hideTooltip();

View File

@@ -1,6 +1,7 @@
import {type Edge, type Node, useNodesData, useStore} from '@xyflow/svelte';
import type {Parameter} from '#types';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {getCurrentNodeId, getOptions} from '#components/utils/NodeUtils';
import {nodeIcons} from '../../consts';
const fillRefNodeIds = (refNodeIds: string[], currentNodeId: string, edges: Edge[]) => {
for (const edge of edges) {
@@ -11,51 +12,82 @@ const fillRefNodeIds = (refNodeIds: string[], currentNodeId: string, edges: Edge
}
};
const getChildren = (params: any, parentId: string, nodeIsChildren: boolean) => {
const getChildren = (params: any, parentId: string, nodeIsChildren: boolean, nodeType: string) => {
if (!params || params.length === 0) return [];
return params.map((param: any) => ({
label:
param.name +
(nodeIsChildren
? ` (Array<${param.dataType || 'String'}>)`
: ` (${param.dataType || 'String'})`),
return params.map((param: any) => {
const dataType = nodeIsChildren
? `Array<${param.dataType || 'String'}>`
: (param.dataType || 'String');
return {
label: param.name,
dataType: dataType,
value: parentId + '.' + param.name,
children: getChildren(param.children, parentId + '.' + param.name, nodeIsChildren)
}));
selectable: true,
nodeType: nodeType,
children: getChildren(param.children, parentId + '.' + param.name, nodeIsChildren, nodeType)
};
});
};
const nodeToOptions = (node: Node, nodeIsChildren: boolean, currentNode: Node) => {
const options = getOptions();
let icon = nodeIcons[node.type];
if (!icon && options?.customNodes && options.customNodes[node.type]) {
icon = options.customNodes[node.type].icon;
}
// 如果仍然获取不到,尝试使用 data.icon (作为回退)
if (!icon && node.data && node.data.icon) {
icon = node.data.icon as string;
}
const title = node.data.title;
if (node.type === 'startNode') {
const parameters = node.data.parameters as Array<Parameter>;
const children = [];
if (parameters)
for (const parameter of parameters) {
const dataType = nodeIsChildren
? `Array<${parameter.dataType || 'String'}>`
: (parameter.dataType || 'String');
children.push({
label:
parameter.name +
(nodeIsChildren
? ` (Array<${parameter.dataType || 'String'}>)`
: ` (${parameter.dataType || 'String'})`),
value: node.id + '.' + parameter.name
label: parameter.name,
dataType: dataType,
value: node.id + '.' + parameter.name,
selectable: true,
nodeType: node.type
});
}
return {
label: node.data.title,
label: title,
icon: icon,
value: node.id,
selectable: false,
nodeType: node.type,
children
};
} else if (node.type === 'loopNode' && currentNode.parentId) {
return {
label: node.data.title,
label: title,
icon: icon,
value: node.id,
selectable: false,
nodeType: node.type,
children: [
{
label: 'loopItem',
value: node.id + '.loopItem'
dataType: 'Any',
value: node.id + '.loopItem',
selectable: true,
nodeType: node.type
},
{
label: 'index (Number)',
value: node.id + '.index'
label: 'index',
dataType: 'Number',
value: node.id + '.index',
selectable: true,
nodeType: node.type
}
]
};
@@ -63,9 +95,12 @@ const nodeToOptions = (node: Node, nodeIsChildren: boolean, currentNode: Node) =
const outputDefs = node.data.outputDefs;
if (outputDefs) {
return {
label: node.data.title,
label: title,
icon: icon,
value: node.id,
children: getChildren(outputDefs, node.id, nodeIsChildren)
selectable: false,
nodeType: node.type,
children: getChildren(outputDefs, node.id, nodeIsChildren, node.type)
};
}
}

View File

@@ -1,5 +1,18 @@
export const componentName = 'tinyflow-component';
export const nodeIcons: Record<string, string> = {
startNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path></svg>',
loopNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>',
llmNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.7134 7.12811L20.4668 7.69379C20.2864 8.10792 19.7136 8.10792 19.5331 7.69379L19.2866 7.12811C18.8471 6.11947 18.0555 5.31641 17.0677 4.87708L16.308 4.53922C15.8973 4.35653 15.8973 3.75881 16.308 3.57612L17.0252 3.25714C18.0384 2.80651 18.8442 1.97373 19.2761 0.930828L19.5293 0.319534C19.7058 -0.106511 20.2942 -0.106511 20.4706 0.319534L20.7238 0.930828C21.1558 1.97373 21.9616 2.80651 22.9748 3.25714L23.6919 3.57612C24.1027 3.75881 24.1027 4.35653 23.6919 4.53922L22.9323 4.87708C21.9445 5.31641 21.1529 6.11947 20.7134 7.12811ZM9 2C13.0675 2 16.426 5.03562 16.9337 8.96494L19.1842 12.5037C19.3324 12.7367 19.3025 13.0847 18.9593 13.2317L17 14.071V17C17 18.1046 16.1046 19 15 19H13.001L13 22H4L4.00025 18.3061C4.00033 17.1252 3.56351 16.0087 2.7555 15.0011C1.65707 13.6313 1 11.8924 1 10C1 5.58172 4.58172 2 9 2ZM9 4C5.68629 4 3 6.68629 3 10C3 11.3849 3.46818 12.6929 4.31578 13.7499C5.40965 15.114 6.00036 16.6672 6.00025 18.3063L6.00013 20H11.0007L11.0017 17H15V12.7519L16.5497 12.0881L15.0072 9.66262L14.9501 9.22118C14.5665 6.25141 12.0243 4 9 4ZM19.4893 16.9929L21.1535 18.1024C22.32 16.3562 23 14.2576 23 12.0001C23 11.317 22.9378 10.6486 22.8186 10L20.8756 10.5C20.9574 10.9878 21 11.489 21 12.0001C21 13.8471 20.4436 15.5642 19.4893 16.9929Z"></path></svg>',
knowledgeNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path></svg>',
searchEngineNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z"></path></svg>',
httpNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.23509 6.45329C4.85101 7.89148 4 9.84636 4 12C4 16.4183 7.58172 20 12 20C13.0808 20 14.1116 19.7857 15.0521 19.3972C15.1671 18.6467 14.9148 17.9266 14.8116 17.6746C14.582 17.115 13.8241 16.1582 12.5589 14.8308C12.2212 14.4758 12.2429 14.2035 12.3636 13.3943L12.3775 13.3029C12.4595 12.7486 12.5971 12.4209 14.4622 12.1248C15.4097 11.9746 15.6589 12.3533 16.0043 12.8777C16.0425 12.9358 16.0807 12.9928 16.1198 13.0499C16.4479 13.5297 16.691 13.6394 17.0582 13.8064C17.2227 13.881 17.428 13.9751 17.7031 14.1314C18.3551 14.504 18.3551 14.9247 18.3551 15.8472V15.9518C18.3551 16.3434 18.3168 16.6872 18.2566 16.9859C19.3478 15.6185 20 13.8854 20 12C20 8.70089 18.003 5.8682 15.1519 4.64482C14.5987 5.01813 13.8398 5.54726 13.575 5.91C13.4396 6.09538 13.2482 7.04166 12.6257 7.11976C12.4626 7.14023 12.2438 7.12589 12.012 7.11097C11.3905 7.07058 10.5402 7.01606 10.268 7.75495C10.0952 8.2232 10.0648 9.49445 10.6239 10.1543C10.7134 10.2597 10.7307 10.4547 10.6699 10.6735C10.59 10.9608 10.4286 11.1356 10.3783 11.1717C10.2819 11.1163 10.0896 10.8931 9.95938 10.7412C9.64554 10.3765 9.25405 9.92233 8.74797 9.78176C8.56395 9.73083 8.36166 9.68867 8.16548 9.64736C7.6164 9.53227 6.99443 9.40134 6.84992 9.09302C6.74442 8.8672 6.74488 8.55621 6.74529 8.22764C6.74529 7.8112 6.74529 7.34029 6.54129 6.88256C6.46246 6.70541 6.35689 6.56446 6.23509 6.45329ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z"></path></svg>',
codeNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path></svg>',
templateNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM4 5V19H20V5H4ZM7 8H17V11H15V10H13V14H14.5V16H9.5V14H11V10H9V11H7V8Z"></path></svg>',
confirmNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path></svg>',
endNode: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>'
};
export const parameterDataTypes = [
{
value: 'String',

View File

@@ -68,86 +68,55 @@
.tf-select {
&-input {
display: flex;
border: 1px solid #ccc;
padding: 3px 10px;
border-radius: 5px;
font-size: 14px;
border: 1px solid #e5e7eb;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
justify-content: space-between;
align-items: center;
cursor: pointer;
background: #fff;
height: 27px;
height: 32px;
transition: all 0.2s;
width: 100%;
color: #111827;
&:focus {
border-color: var(--tf-primary-color);
box-shadow: 0 0 5px rgba(81, 203, 238, .2);
&:hover {
border-color: #d1d5db;
}
&:focus, &.active {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
&-value {
height: 21px;
height: 100%;
min-width: 10px;
font-size: 12px;
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&-arrow {
display: block;
display: flex;
width: 16px;
height: 16px;
color: #666;
color: #6b7280;
flex-shrink: 0;
margin-left: 4px;
}
&-placeholder {
color: #ccc;
color: #9ca3af;
}
}
&-content {
display: flex;
flex-direction: column;
background: #fff;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 5px;
padding: 5px;
width: max-content;
min-width: 100%;
z-index: 999;
box-sizing: border-box;
max-height: 220px;
overflow: auto;
&-item {
display: flex;
align-items: center;
padding: 5px 10px;
border: none;
background: #fff;
border-radius: 5px;
cursor: pointer;
line-height: 100%;
gap: 2px;
span {
width: 16px;
display: flex;
}
svg {
width: 16px;
height: 16px;
margin: auto;
}
&:hover {
background: #f0f0f0;
}
}
&-children {
padding-left: 14px;
}
display: none; // 现在改用 .tf-select-wrapper
}
}