feat: 工作流适配数据中枢查询节点

- 新增查询数据与写入数据节点并移除旧数据中心节点入口

- 将查询数据节点切换为连接服务加 SQL 的执行模型

- 同步更新工作流校验、提示词上下文与设计器交互
This commit is contained in:
2026-04-02 18:56:34 +08:00
parent 798effbd5b
commit 1ecc28e498
40 changed files with 1973 additions and 692 deletions

View File

@@ -43,11 +43,19 @@
"fileDownloadURL": "FileDownloadURL",
"pluginSelect": "PluginSelect",
"saveData": "SaveData",
"saveDataset": "Write Data",
"dataToBeSaved": "DataToBeSaved",
"successInsertedRecords": "SuccessInsertedRecords",
"dataTable": "DataTable",
"dataset": "Dataset",
"datasetDsl": "Query Conditions",
"datasetDslPlaceholder": "Structured query conditions",
"queryData": "QueryData",
"queryDataset": "Query Data",
"querySpec": "SQL",
"querySql": "SQL",
"queryResult": "QueryResult",
"querySummary": "QuerySummary",
"filterConditions": "FilterConditions",
"limit": "Limit",
"sqlQuery": "SQL Query",
@@ -79,9 +87,15 @@
"fileDownloadURL": "Generated file URL",
"plugin": "Select a predefined plugin",
"saveData": "Save data to data hub",
"saveDataset": "Write data into a managed table",
"dataToBeSaved": "List of data to be saved",
"dataTable": "Please select a data table",
"dataset": "Please select a managed table",
"queryData": "Query data from the data hub",
"queryDataset": "Run a read-only SQL query through the selected connection service",
"querySpec": "Enter SQL. Parameters can be referenced",
"querySql": "Enter SQL. Parameters can be referenced",
"datasetDsl": "Enter read-only SQL. Write statements, multiple statements, and unmanaged tables are not allowed",
"sqlQuery": "Query the database via SQL",
"enterSQL": "Please enter the SQL statement",
"queryResultJson": "Query result (JSON object)",

View File

@@ -43,11 +43,19 @@
"fileDownloadURL": "文件下载地址",
"pluginSelect": "插件选择",
"saveData": "保存数据",
"saveDataset": "写入数据",
"dataToBeSaved": "待保存的数据",
"successInsertedRecords": "成功插入条数",
"dataTable": "数据表",
"dataset": "数据集",
"datasetDsl": "查询条件",
"datasetDslPlaceholder": "结构化查询条件",
"queryData": "查询数据",
"queryDataset": "查询数据",
"querySpec": "SQL",
"querySql": "SQL",
"queryResult": "查询结果",
"querySummary": "查询摘要",
"filterConditions": "过滤条件",
"limit": "限制条数",
"sqlQuery": "SQL 查询",
@@ -79,9 +87,15 @@
"fileDownloadURL": "生成后的文件地址",
"plugin": "选择定义好的插件",
"saveData": "保存数据到数据中枢",
"saveDataset": "将数据写入已接入表",
"dataToBeSaved": "待保存的数据列表",
"dataTable": "请选择数据表",
"dataset": "请选择已接入表",
"queryData": "查询数据中枢的数据",
"queryDataset": "按连接服务执行只读 SQL 查询",
"querySpec": "请输入 SQL可引用输入参数",
"querySql": "请输入 SQL可引用输入参数",
"datasetDsl": "请输入只读 SQL不支持写入语句、多语句和未接入表访问",
"sqlQuery": "通过 SQL 查询数据库",
"enterSQL": "请输入SQL语句",
"queryResultJson": "查询结果(json对象)",

View File

@@ -6,7 +6,7 @@ const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ant-design:apartment-outlined',
title: $t('datacenterTable.title'),
title: '工作流设计',
hideInMenu: true,
activePath: '/ai/workflow',
},

View File

@@ -20,7 +20,6 @@ import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
import {getCustomNode} from './customNode/index';
import nodeNames from './customNode/nodeNames';
import '@tinyflow-ai/vue/dist/index.css';
@@ -59,17 +58,35 @@ const codeEngineList = ref<any[]>([
available: true,
},
]);
function escapeHtmlAttr(value?: string) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function buildModelIconMarkup(icon?: string, providerType?: string) {
const normalized = String(icon || '').trim();
if (normalized) {
if (normalized.startsWith('<svg') || normalized.startsWith('<img')) {
return normalized;
}
return `<img src="${escapeHtmlAttr(normalized)}" alt="" style="width:100%; height:100%; object-fit:contain;" />`;
}
if (!providerType) {
return undefined;
}
return getIconByValue(providerType) || undefined;
}
const provider = computed(() => ({
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;
}
}
const iconStr = buildModelIconMarkup(
item.modelProvider?.icon,
item.modelProvider?.providerType,
);
// Extract brand and model name directly from the title if it contains '/'
let displayTitle = item.title || '';
@@ -330,15 +347,15 @@ async function runCheck(stage: WorkflowCheckStage, silentPass: boolean = false)
stage,
});
checkResult.value = res.data;
const issues = Array.isArray(res.data?.issues) ? res.data.issues : [];
checkIssuesVisible.value = issues.length > 0;
if (!res.data?.passed) {
checkIssuesVisible.value = true;
ElMessage.error($t('aiWorkflow.checkFailed'));
return false;
}
checkIssuesVisible.value = false;
focusedIssueKey.value = '';
issueFocusActive.value = false;
if (!silentPass) {
if (!silentPass && issues.length === 0) {
ElMessage.success($t('aiWorkflow.checkPassed'));
}
return true;

View File

@@ -0,0 +1,573 @@
import type {
DatasetRefPayload,
ManagedDatasetOption,
ManagedDatasetSourceOption,
} from './datasetOptions';
import huaweiIcon from '#/assets/datacenter/huawei-icon.svg';
import mysqlIcon from '#/assets/datacenter/mysql-icon.svg';
import postgresqlIcon from '#/assets/datacenter/postgresql-icon.svg';
import {
groupManagedDatasetOptions,
loadManagedDatasetOptions,
} from './datasetOptions';
const SOURCE_LOGO_MAP: Record<string, string> = {
EXCEL: 'excel',
EXCEL_MATERIALIZED: 'excel',
GAUSSDB_NATIVE: 'gaussdb',
GBASE_8A: 'gbase',
GBASE_8S: 'gbase',
MYSQL: 'mysql',
ORACLE: 'oracle',
POSTGRESQL: 'postgresql',
PROJECT_MYSQL: 'mysql',
};
type NodeLike = {
id: string;
data?: Record<string, any>;
};
type UpdateNodeData = (
nodeId: string,
data: Record<string, any> | ((node: Record<string, any>) => Record<string, any>),
) => void;
type FlowInstance = {
updateNodeData: UpdateNodeData;
};
type RenderContext = FlowInstance | undefined;
type RendererState = {
pickerOpen: boolean;
loadingOptions: boolean;
optionsLoaded: boolean;
options: ManagedDatasetOption[];
sources: ManagedDatasetSourceOption[];
tableSearchText: string;
updateNodeData?: UpdateNodeData;
};
function getState(parent: HTMLElement): RendererState {
const holder = parent as HTMLElement & { __datasetState?: RendererState };
if (!holder.__datasetState) {
holder.__datasetState = {
pickerOpen: false,
loadingOptions: false,
optionsLoaded: false,
options: [],
sources: [],
tableSearchText: '',
};
}
return holder.__datasetState;
}
function getUpdateNodeData(parent: HTMLElement, flowInstance?: RenderContext) {
const state = getState(parent);
if (flowInstance?.updateNodeData) {
state.updateNodeData = flowInstance.updateNodeData.bind(flowInstance);
}
return state.updateNodeData;
}
function getDatasetKey(datasetRef?: DatasetRefPayload | null) {
return datasetRef?.tableId == null ? '' : String(datasetRef.tableId);
}
function getSourceKey(datasetRef?: DatasetRefPayload | null) {
return datasetRef?.sourceId == null ? '' : String(datasetRef.sourceId);
}
function createSourceOnlyDatasetRef(source: ManagedDatasetSourceOption): DatasetRefPayload {
return {
sourceId: source.sourceId,
catalogId: null,
catalogName: '',
tableId: null,
tableName: '',
versionId: null,
};
}
function escapeHtml(value?: string | number | null) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function getSourceLogoType(sourceType?: string) {
return SOURCE_LOGO_MAP[sourceType || ''] || 'default';
}
function buildSourceLogo(sourceType?: string) {
const logoType = getSourceLogoType(sourceType);
if (logoType === 'mysql') {
return `<img class="dataset-node-brand-image" src="${mysqlIcon}" alt="" />`;
}
if (logoType === 'postgresql') {
return `<img class="dataset-node-brand-image" src="${postgresqlIcon}" alt="" />`;
}
if (logoType === 'gaussdb') {
return `<img class="dataset-node-brand-image" src="${huaweiIcon}" alt="" />`;
}
if (logoType === 'oracle') {
return `
<svg class="dataset-node-brand-svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="4.6" y="7.2" width="14.8" height="9.6" rx="4.8" fill="#ea4335" />
<rect x="7.1" y="9.35" width="9.8" height="5.3" rx="2.65" fill="white" />
<rect x="8.4" y="10.65" width="7.2" height="2.7" rx="1.35" fill="#ea4335" />
</svg>
`;
}
if (logoType === 'gbase') {
return `
<svg class="dataset-node-brand-svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" fill="#10b981" />
<path
d="M15.85 9.2C15.1 8.02 13.8 7.3 12.38 7.3C9.94 7.3 8.1 9.14 8.1 11.98C8.1 14.87 10.05 16.7 12.56 16.7C13.88 16.7 15.03 16.18 15.88 15.15V12.55H12.45"
stroke="white"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.7"
/>
</svg>
`;
}
if (logoType === 'excel') {
return `
<svg class="dataset-node-brand-svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="4.4" y="4.2" width="15.2" height="15.6" rx="3.2" fill="#16a34a" />
<path
d="M9 9L11.1 12L9 15M15 9L12.9 12L15 15"
stroke="white"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.65"
/>
</svg>
`;
}
return `
<svg class="dataset-node-brand-svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<ellipse cx="12" cy="6.5" rx="5.75" ry="2.5" fill="#94a3b8" />
<path d="M6.25 6.5V12.2C6.25 13.58 8.82 14.7 12 14.7C15.18 14.7 17.75 13.58 17.75 12.2V6.5" fill="#cbd5e1" />
<path d="M6.25 12.2V17.35C6.25 18.72 8.82 19.85 12 19.85C15.18 19.85 17.75 18.72 17.75 17.35V12.2" fill="#e2e8f0" />
</svg>
`;
}
function buildStyles() {
return `
<style>
.dataset-node-section {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.dataset-node-label {
font-size: 12px;
color: var(--tf-text-muted);
}
.dataset-node-picker-anchor {
position: relative;
width: 100%;
}
.dataset-node-picker-trigger {
width: 100%;
min-height: 38px;
border: 1px solid var(--tf-border-color);
border-radius: 8px;
padding: 7px 10px 7px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--tf-bg-surface);
color: var(--tf-text-primary);
text-align: left;
box-sizing: border-box;
}
.dataset-node-picker-trigger:hover {
border-color: var(--tf-border-color-strong);
}
.dataset-node-picker-trigger.is-open {
border-color: var(--tf-color-primary);
}
.dataset-node-picker-value {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.dataset-node-picker-placeholder {
font-size: 12px;
color: var(--tf-text-secondary);
}
.dataset-node-picker-text {
min-width: 0;
display: flex;
align-items: center;
}
.dataset-node-picker-main {
min-width: 0;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dataset-node-picker-arrow {
width: 14px;
height: 14px;
color: var(--tf-text-secondary);
flex-shrink: 0;
}
.dataset-node-picker {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: 100%;
border: 1px solid var(--tf-border-color);
border-radius: 8px;
background: var(--tf-bg-surface);
box-shadow: var(--tf-shadow-medium);
overflow: hidden;
z-index: 40;
}
.dataset-node-picker-body,
.dataset-node-list {
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 240px;
overflow: auto;
}
.dataset-node-picker-item {
width: 100%;
border: none;
background: transparent;
border-radius: 6px;
padding: 9px 10px;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
cursor: pointer;
color: inherit;
}
.dataset-node-picker-item:hover {
background: var(--tf-bg-muted);
}
.dataset-node-picker-item.is-active {
background: var(--tf-bg-muted);
color: var(--tf-color-primary);
}
.dataset-node-picker-item-inner {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.dataset-node-picker-item-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dataset-node-picker-item-title {
font-size: 12px;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dataset-node-picker-item-meta {
display: block;
font-size: 11px;
line-height: 16px;
color: var(--tf-text-secondary);
}
.dataset-node-empty {
font-size: 12px;
color: var(--tf-text-secondary);
}
.dataset-node-brand {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dataset-node-brand-image,
.dataset-node-brand-svg {
width: 18px;
height: 18px;
display: block;
object-fit: contain;
}
.dataset-node-list-box {
border: 1px solid var(--tf-border-color);
border-radius: 8px;
background: var(--tf-bg-surface);
overflow: hidden;
}
.dataset-node-search {
width: 100%;
border: 1px solid var(--tf-border-color);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
line-height: 18px;
color: var(--tf-text-primary);
background: var(--tf-bg-surface);
box-sizing: border-box;
}
.dataset-node-search:focus {
outline: none;
border-color: var(--tf-color-primary);
}
</style>
`;
}
function filterTableOptions(options: ManagedDatasetOption[], searchText: string) {
const keyword = searchText.trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter((option) => option.keywords.includes(keyword));
}
function ensureOptionsLoaded(
state: RendererState,
parent: HTMLElement,
node: NodeLike,
flowInstance: RenderContext,
rerender: (parent: HTMLElement, node: NodeLike, flowInstance?: RenderContext) => void,
) {
if (state.loadingOptions || state.optionsLoaded) {
return;
}
state.loadingOptions = true;
loadManagedDatasetOptions()
.then((options) => {
state.options = options;
state.sources = groupManagedDatasetOptions(options);
state.optionsLoaded = true;
})
.finally(() => {
state.loadingOptions = false;
rerender(parent, node, flowInstance);
});
}
function buildPickerListItem(
title: string,
meta: string,
active: boolean,
action: string,
extraAttr: string,
iconHtml: string = '',
) {
return `
<button
type="button"
class="dataset-node-picker-item nopan nodrag nowheel ${active ? 'is-active' : ''}"
data-action="${action}"
${extraAttr}
>
<span class="dataset-node-picker-item-inner">
${iconHtml ? `<span class="dataset-node-brand">${iconHtml}</span>` : ''}
<span class="dataset-node-picker-item-main">
<span class="dataset-node-picker-item-title">${escapeHtml(title)}</span>
${meta ? `<span class="dataset-node-picker-item-meta">${escapeHtml(meta)}</span>` : ''}
</span>
</span>
</button>
`;
}
function buildSourceList(options: ManagedDatasetSourceOption[], activeKey: string, emptyText: string) {
if (!options.length) {
return `<div class="dataset-node-empty">${emptyText}</div>`;
}
return options
.map((option) =>
buildPickerListItem(
option.sourceName,
`${option.tables.length} 张表`,
activeKey === String(option.sourceId),
'select-source',
`data-source-id="${escapeHtml(option.sourceId)}"`,
buildSourceLogo(option.sourceType),
),
)
.join('');
}
function buildTableList(options: ManagedDatasetOption[], activeKey: string, emptyText: string) {
if (!options.length) {
return `<div class="dataset-node-empty">${emptyText}</div>`;
}
return options
.map((option) =>
buildPickerListItem(
option.tableName,
`${option.sourceName} / ${option.catalogName}`,
activeKey === String(option.datasetRef.tableId),
'select-dataset',
`data-table-id="${escapeHtml(option.datasetRef.tableId)}"`,
buildSourceLogo(option.sourceType),
),
)
.join('');
}
function buildSearchSummary(currentSource?: ManagedDatasetSourceOption) {
if (!currentSource) {
return '<span class="dataset-node-picker-placeholder">请选择连接服务</span>';
}
return `
<span class="dataset-node-brand">${buildSourceLogo(currentSource.sourceType)}</span>
<span class="dataset-node-picker-text">
<span class="dataset-node-picker-main">${escapeHtml(currentSource.sourceName)}</span>
</span>
`;
}
function bindInteractiveElements(parent: HTMLElement) {
parent.querySelectorAll<HTMLElement>('button, input, select, textarea').forEach((element) => {
element.onpointerdown = (event) => event.stopPropagation();
element.onmousedown = (event) => event.stopPropagation();
});
}
export function rerenderSearchNode(parent: HTMLElement, node: NodeLike, flowInstance?: RenderContext) {
const state = getState(parent);
const updateNodeData = getUpdateNodeData(parent, flowInstance);
ensureOptionsLoaded(state, parent, node, flowInstance, rerenderSearchNode);
const datasetRef = (node.data?.datasetRef || null) as DatasetRefPayload | null;
const sourceKey = getSourceKey(datasetRef);
const currentSource = state.sources.find((item) => String(item.sourceId) === sourceKey);
parent.innerHTML = `
${buildStyles()}
<div class="dataset-node-section">
<div class="dataset-node-label">连接服务</div>
<div class="dataset-node-picker-anchor">
<button type="button" class="dataset-node-picker-trigger nopan nodrag nowheel ${state.pickerOpen ? 'is-open' : ''}" data-action="toggle-picker">
<span class="dataset-node-picker-value">${buildSearchSummary(currentSource)}</span>
<span class="dataset-node-picker-arrow">
<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>
</span>
</button>
${state.pickerOpen ? `
<div class="dataset-node-picker">
<div class="dataset-node-picker-body">
${state.loadingOptions ? '<div class="dataset-node-empty">正在加载连接服务...</div>' : buildSourceList(state.sources, sourceKey, '暂无可用连接服务')}
</div>
</div>
` : ''}
</div>
</div>
`;
bindInteractiveElements(parent);
parent.querySelector<HTMLElement>('[data-action="toggle-picker"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
state.pickerOpen = !state.pickerOpen;
rerenderSearchNode(parent, node, flowInstance);
});
parent.querySelectorAll<HTMLElement>('[data-action="select-source"]').forEach((element) => {
element.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const sourceId = element.dataset.sourceId;
const source = state.sources.find((item) => String(item.sourceId) === String(sourceId));
if (!source) {
return;
}
state.pickerOpen = false;
const nextDatasetRef = createSourceOnlyDatasetRef(source);
node.data = {
...(node.data || {}),
datasetRef: nextDatasetRef,
sourceName: source.sourceName,
sourceType: source.sourceType,
};
updateNodeData?.(node.id, {
datasetRef: nextDatasetRef,
sourceName: source.sourceName,
sourceType: source.sourceType,
});
rerenderSearchNode(parent, node, flowInstance);
});
});
}
export function rerenderSaveNode(parent: HTMLElement, node: NodeLike, flowInstance?: RenderContext) {
const state = getState(parent);
const updateNodeData = getUpdateNodeData(parent, flowInstance);
ensureOptionsLoaded(state, parent, node, flowInstance, rerenderSaveNode);
const datasetRef = (node.data?.datasetRef || null) as DatasetRefPayload | null;
const activeKey = getDatasetKey(datasetRef);
const currentOption = state.options.find((item) => String(item.datasetRef.tableId) === activeKey);
const filtered = filterTableOptions(state.options, state.tableSearchText);
parent.innerHTML = `
${buildStyles()}
<div class="dataset-node-section">
<div class="dataset-node-label">已接入表</div>
<input class="dataset-node-search nopan nodrag nowheel" data-role="table-search" placeholder="搜索连接 / 库 / 表" value="${escapeHtml(state.tableSearchText)}" />
<div class="dataset-node-list-box">
<div class="dataset-node-list">
${state.loadingOptions ? '<div class="dataset-node-empty">正在加载已接入表...</div>' : buildTableList(filtered, activeKey, '没有匹配的已接入表')}
</div>
</div>
</div>
`;
bindInteractiveElements(parent);
parent.querySelector<HTMLInputElement>('[data-role="table-search"]')?.addEventListener('input', (event) => {
event.stopPropagation();
state.tableSearchText = (event.currentTarget as HTMLInputElement).value || '';
rerenderSaveNode(parent, node, flowInstance);
});
parent.querySelectorAll<HTMLElement>('[data-action="select-dataset"]').forEach((element) => {
element.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const tableId = element.dataset.tableId;
const option = state.options.find((item) => String(item.datasetRef.tableId) === String(tableId));
if (!option) {
return;
}
node.data = {
...(node.data || {}),
datasetRef: option.datasetRef,
sourceName: option.sourceName,
sourceType: option.sourceType,
};
updateNodeData?.(node.id, {
datasetRef: option.datasetRef,
sourceName: option.sourceName,
sourceType: option.sourceType,
});
rerenderSaveNode(parent, node, flowInstance);
});
});
}

View File

@@ -0,0 +1,184 @@
import { api } from '#/api/request';
export interface DatasetRefPayload {
sourceId: number | string | null;
catalogId?: number | string | null;
catalogName?: string;
tableId: number | string | null;
tableName: string;
versionId?: number | string | null;
}
export interface ManagedDatasetFieldOption {
fieldName: string;
fieldDesc?: string;
fieldType?: string;
}
export interface ManagedDatasetSchema {
tableName?: string;
tableDesc?: string;
fields: ManagedDatasetFieldOption[];
}
export interface ManagedDatasetOption {
label: string;
value: number | string;
keywords: string;
sourceName: string;
sourceType?: string;
catalogName: string;
tableName: string;
datasetRef: DatasetRefPayload;
}
export interface ManagedDatasetSourceOption {
sourceId: number | string;
sourceName: string;
sourceType?: string;
label: string;
keywords: string;
tables: ManagedDatasetOption[];
}
const SOURCE_MISSING_MESSAGE = '连接不存在';
const SOURCE_UNAVAILABLE_MESSAGE = '当前连接不可用,请检查连接配置后重试';
function shouldSkipSourceError(error: any) {
const responseData = error?.response?.data ?? {};
const message = String(responseData?.message ?? error?.message ?? '');
return (
message.includes(SOURCE_MISSING_MESSAGE) ||
message.includes(SOURCE_UNAVAILABLE_MESSAGE)
);
}
function dedupeManagedDatasetOptions(options: ManagedDatasetOption[]) {
const uniqueOptions = new Map<string, ManagedDatasetOption>();
for (const option of options || []) {
const key = option.datasetRef?.tableId != null
? String(option.datasetRef.tableId)
: [
option.datasetRef?.sourceId ?? '',
option.datasetRef?.catalogId ?? '',
option.tableName ?? '',
].join(':');
if (!uniqueOptions.has(key)) {
uniqueOptions.set(key, option);
}
}
return Array.from(uniqueOptions.values());
}
export async function loadManagedDatasetOptions(): Promise<ManagedDatasetOption[]> {
const sourceRes = await api.get('/api/v1/datacenterSource/page', {
params: {
pageNumber: 1,
pageSize: 200,
},
});
const sources = sourceRes.data?.records || [];
const options: ManagedDatasetOption[] = [];
for (const source of sources) {
try {
const catalogRes = await api.get('/api/v1/datacenterSource/catalogs', {
params: {
sourceId: source.id,
},
});
const catalogs = catalogRes.data || [];
for (const catalog of catalogs) {
const tableRes = await api.get('/api/v1/datacenterDataset/managedTables', {
params: {
sourceId: source.id,
catalogId: catalog.id,
},
});
const tables = tableRes.data || [];
for (const table of tables) {
const label = `${source.sourceName} / ${catalog.catalogName} / ${table.tableName}`;
options.push({
label,
value: table.id,
keywords: `${source.sourceName} ${catalog.catalogName} ${table.tableName}`.toLowerCase(),
sourceName: source.sourceName,
sourceType: source.sourceType,
catalogName: catalog.catalogName,
tableName: table.tableName,
datasetRef: {
sourceId: source.id,
catalogId: catalog.id,
catalogName: catalog.catalogName,
tableId: table.id,
tableName: table.tableName,
},
});
}
}
} catch (error) {
if (shouldSkipSourceError(error)) {
continue;
}
throw error;
}
}
return dedupeManagedDatasetOptions(options);
}
export function groupManagedDatasetOptions(
options: ManagedDatasetOption[],
): ManagedDatasetSourceOption[] {
const grouped = new Map<number | string, ManagedDatasetSourceOption>();
for (const option of dedupeManagedDatasetOptions(options || [])) {
const sourceId = option.datasetRef?.sourceId;
if (sourceId == null) {
continue;
}
if (!grouped.has(sourceId)) {
grouped.set(sourceId, {
sourceId,
sourceName: option.sourceName,
sourceType: option.sourceType,
label: option.sourceName,
keywords: option.sourceName.toLowerCase(),
tables: [],
});
}
grouped.get(sourceId)!.tables.push(option);
}
return Array.from(grouped.values()).map((item) => ({
...item,
keywords: `${item.sourceName} ${item.tables
.map((table) => `${table.catalogName} ${table.tableName}`)
.join(' ')}`.toLowerCase(),
tables: item.tables.sort((a, b) => a.label.localeCompare(b.label)),
}));
}
export async function loadManagedDatasetSchema(
datasetRef?: DatasetRefPayload | null,
): Promise<ManagedDatasetSchema> {
if (!datasetRef?.tableId) {
return {
tableName: datasetRef?.tableName,
fields: [],
};
}
const res = await api.get('/api/v1/datacenterDataset/schema', {
params: datasetRef,
});
const data = res.data || {};
const fields = Array.isArray(data.fields)
? data.fields.map((field: any) => ({
fieldName: field.fieldName,
fieldDesc: field.fieldDesc,
fieldType: field.jdbcType || field.fieldType,
}))
: [];
return {
tableName: data.table?.tableName || datasetRef.tableName,
tableDesc: data.table?.tableDesc,
fields,
};
}

View File

@@ -3,9 +3,8 @@ import downloadNode from './downloadNode';
import makeFileNode from './makeFileNode';
import nodeNames from './nodeNames';
import { PluginNode } from './pluginNode';
import { SaveToDatacenterNode } from './saveToDatacenter';
import { SearchDatacenterNode } from './searchDatacenter';
import sqlNode from './sqlNode';
import { SaveDatasetNode } from './saveDataset';
import { SearchDatasetNode } from './searchDataset';
import { WorkflowNode } from './workflowNode';
export interface CustomNodeOptions {
@@ -14,16 +13,15 @@ export interface CustomNodeOptions {
export const getCustomNode = async (options: CustomNodeOptions) => {
const pluginNode = PluginNode({ onChosen: options.handleChosen });
const workflowNode = WorkflowNode({ onChosen: options.handleChosen });
const searchDatacenterNode = await SearchDatacenterNode();
const saveToDatacenterNode = await SaveToDatacenterNode();
const searchDatasetNode = await SearchDatasetNode();
const saveDatasetNode = await SaveDatasetNode();
return {
...docNode,
...makeFileNode,
...downloadNode,
...sqlNode,
[nodeNames.pluginNode]: pluginNode,
[nodeNames.workflowNode]: workflowNode,
[nodeNames.searchDatacenterNode]: searchDatacenterNode,
[nodeNames.saveToDatacenterNode]: saveToDatacenterNode,
[nodeNames.searchDatasetNode]: searchDatasetNode,
[nodeNames.saveDatasetNode]: saveDatasetNode,
};
};

View File

@@ -2,9 +2,8 @@ export default {
documentNode: 'document-node',
makeFileNode: 'make-file',
downloadNode: 'download-node',
sqlNode: 'sql-node',
pluginNode: 'plugin-node',
workflowNode: 'workflow-node',
searchDatacenterNode: 'search-datacenter-node',
saveToDatacenterNode: 'save-to-datacenter-node',
searchDatasetNode: 'search-dataset-node',
saveDatasetNode: 'save-dataset-node',
};

View File

@@ -0,0 +1,43 @@
import { $t } from '#/locales';
import { rerenderSaveNode } from './datasetNodeRenderer';
export const SaveDatasetNode = async () => {
return {
title: $t('aiWorkflow.saveDataset'),
group: 'base',
description: $t('aiWorkflow.descriptions.saveDataset'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3ZM7 5V9H17V5H7ZM7 13V19H17V13H7Z"></path></svg>',
sortNo: 812,
parametersAddEnable: false,
outputDefsAddEnable: false,
parameters: [
{
name: 'saveList',
title: $t('aiWorkflow.dataToBeSaved'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.dataToBeSaved'),
deleteDisabled: true,
nameDisabled: true,
},
],
outputDefs: [
{
name: 'successRows',
title: $t('aiWorkflow.successInsertedRecords'),
dataType: 'Number',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.successInsertedRecords'),
deleteDisabled: true,
nameDisabled: true,
},
],
render: rerenderSaveNode,
onUpdate: rerenderSaveNode,
};
};

View File

@@ -1,58 +0,0 @@
import { getOptions } from '@easyflow/utils';
import { api } from '#/api/request';
import { $t } from '#/locales';
export const SaveToDatacenterNode = async () => {
const res = await api.get('/api/v1/datacenterTable/list');
return {
title: $t('aiWorkflow.saveData'),
group: 'base',
description: $t('aiWorkflow.descriptions.saveData'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11 19V9H4V19H11ZM11 7V4C11 3.44772 11.4477 3 12 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H11ZM13 5V19H20V5H13ZM5 16H10V18H5V16ZM14 16H19V18H14V16ZM14 13H19V15H14V13ZM14 10H19V12H14V10ZM5 13H10V15H5V13Z"></path></svg>',
sortNo: 812,
parametersAddEnable: false,
outputDefsAddEnable: false,
parameters: [
{
name: 'saveList',
title: $t('aiWorkflow.dataToBeSaved'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.dataToBeSaved'),
deleteDisabled: true,
nameDisabled: true,
},
],
outputDefs: [
{
name: 'successRows',
title: $t('aiWorkflow.successInsertedRecords'),
dataType: 'Number',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.successInsertedRecords'),
deleteDisabled: true,
nameDisabled: true,
},
],
forms: [
{
type: 'heading',
label: $t('aiWorkflow.dataTable'),
},
{
type: 'select',
label: '',
description: $t('aiWorkflow.descriptions.dataTable'),
name: 'tableId',
defaultValue: '',
options: getOptions('tableName', 'id', res.data),
},
],
};
};

View File

@@ -1,69 +0,0 @@
import { getOptions } from '@easyflow/utils';
import { api } from '#/api/request';
import { $t } from '#/locales';
export const SearchDatacenterNode = async () => {
const res = await api.get('/api/v1/datacenterTable/list');
return {
title: $t('aiWorkflow.queryData'),
group: 'base',
description: $t('aiWorkflow.descriptions.queryData'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11 2C15.968 2 20 6.032 20 11C20 15.968 15.968 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2ZM11 18C14.8675 18 18 14.8675 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18ZM19.4853 18.0711L22.3137 20.8995L20.8995 22.3137L18.0711 19.4853L19.4853 18.0711Z"></path></svg>',
sortNo: 813,
parametersAddEnable: true,
outputDefsAddEnable: false,
parameters: [],
outputDefs: [
{
name: 'rows',
title: $t('aiWorkflow.queryResult'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.queryResult'),
deleteDisabled: true,
nameDisabled: false,
},
],
forms: [
{
type: 'heading',
label: $t('aiWorkflow.dataTable'),
},
{
type: 'select',
label: '',
description: $t('aiWorkflow.descriptions.dataTable'),
name: 'tableId',
defaultValue: '',
options: getOptions('tableName', 'id', res.data),
},
{
type: 'heading',
label: $t('aiWorkflow.filterConditions'),
},
{
type: 'textarea',
templateSupport: true,
label: "如name='张三' and age=21 or field = {{流程变量}}",
description: '',
name: 'where',
defaultValue: '',
},
{
type: 'heading',
label: $t('aiWorkflow.limit'),
},
{
type: 'input',
label: '',
description: '',
name: 'limit',
defaultValue: '10',
},
],
};
};

View File

@@ -0,0 +1,44 @@
import { $t } from '#/locales';
import { rerenderSearchNode } from './datasetNodeRenderer';
export const SearchDatasetNode = async () => {
return {
title: $t('aiWorkflow.queryDataset'),
group: 'base',
description: $t('aiWorkflow.descriptions.queryDataset'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4H20V6H4V4ZM4 9H20V11H4V9ZM4 14H14V16H4V14ZM4 19H14V21H4V19ZM17 14H22V21H17V14Z"></path></svg>',
sortNo: 813,
parametersAddEnable: true,
outputDefsAddEnable: false,
renderFirst: true,
parameters: [],
forms: [
{
name: 'querySql',
type: 'textarea',
templateSupport: true,
label: 'SQL',
placeholder: $t('aiWorkflow.descriptions.enterSQL'),
attrs: {
rows: 6,
},
},
],
outputDefs: [
{
name: 'data',
title: 'data',
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: 'data',
deleteDisabled: true,
nameDisabled: true,
},
],
render: rerenderSearchNode,
onUpdate: rerenderSearchNode,
};
};

View File

@@ -1,38 +0,0 @@
import { $t } from '#/locales';
import nodeNames from './nodeNames';
export default {
[nodeNames.sqlNode]: {
title: $t('aiWorkflow.sqlQuery'),
group: 'base',
description: $t('aiWorkflow.descriptions.sqlQuery'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgba(37,99,235,1)"><path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z"></path></svg>',
sortNo: 803,
parametersAddEnable: true,
outputDefsAddEnable: true,
parameters: [],
forms: [
{
name: 'sql',
type: 'textarea',
templateSupport: true,
label: 'SQL',
placeholder: $t('aiWorkflow.descriptions.enterSQL'),
},
],
outputDefs: [
{
name: 'queryData',
title: $t('aiWorkflow.queryResult'),
dataType: 'Array',
dataTypeDisabled: true,
required: true,
parametersAddEnable: false,
description: $t('aiWorkflow.descriptions.queryResultJson'),
deleteDisabled: true,
nameDisabled: true,
},
],
},
};

View File

@@ -77,6 +77,10 @@
}
}
function isMarkupIcon(icon?: string) {
return typeof icon === 'string' && icon.trim().startsWith('<');
}
</script>
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
@@ -97,7 +101,13 @@
{#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>
<span class="tf-select-model-group-icon">
{#if isMarkupIcon(group.icon)}
{@html group.icon}
{:else}
<img src={group.icon} alt="" />
{/if}
</span>
{/if}
<span>{group.label}</span>
</div>
@@ -105,7 +115,11 @@
<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}
{#if isMarkupIcon(model.icon)}
{@html model.icon}
{:else}
<img src={model.icon} alt="" />
{/if}
{:else}
<div class="tf-select-model-avatar">{group.label ? group.label.toString().charAt(0) : 'M'}</div>
{/if}
@@ -129,7 +143,11 @@
<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}
{#if isMarkupIcon(group.icon)}
{@html group.icon}
{:else}
<img src={group.icon} alt="" />
{/if}
{:else}
<div class="tf-select-model-avatar">{group.label ? group.label.toString().charAt(0) : 'M'}</div>
{/if}
@@ -200,7 +218,11 @@
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{@html item.icon}
{#if isMarkupIcon(item.icon)}
{@html item.icon}
{:else}
<img src={item.icon} alt="" />
{/if}
</span>
{/if}
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
@@ -217,7 +239,11 @@
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{@html item.icon}
{#if isMarkupIcon(item.icon)}
{@html item.icon}
{:else}
<img src={item.icon} alt="" />
{/if}
</span>
{/if}
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
@@ -305,6 +331,12 @@
</div>
<style lang="less">
:global(.tf-select > div:first-child) {
display: flex;
flex: 1;
width: 100%;
min-width: 0;
}
.tf-select-default-wrapper {
display: flex;
@@ -525,13 +557,15 @@
box-sizing: border-box;
max-height: 480px;
z-index: 99999;
margin-top: 5px;
}
.tf-select-list {
display: flex;
flex-direction: column;
padding: 8px;
padding: 6px;
overflow-y: auto;
min-width: 0;
&.tf-select-primary-list {
width: 100%; /* Default fills the wrapper, which is minWidth-constrained by the input */
@@ -543,13 +577,13 @@
.tf-select-wrapper:has(.tf-select-secondary-list) &.tf-select-primary-list {
/* Let it take the width of the input minus borders/paddings if needed, but minWidth handles it mostly */
width: auto;
min-width: 180px;
min-width: 160px;
}
&.tf-select-secondary-list {
min-width: 220px;
min-width: 188px;
background: var(--tf-bg-surface);
padding: 12px;
padding: 10px;
border-left: 1px solid var(--tf-bg-muted);
animation: slideIn 0.2s ease-out;
box-sizing: border-box;
@@ -561,6 +595,8 @@
.tf-select-item-container {
position: relative;
width: 100%;
min-width: 0;
}
.tf-select-item-children {
@@ -646,8 +682,10 @@
.tf-parameter-label-input {
display: flex;
align-items: center;
gap: 6px;
margin-right: 4px;
gap: 4px;
min-width: 0;
flex: 1;
overflow: hidden;
.tf-select-item-icon-input {
width: 18px;
@@ -692,12 +730,16 @@
align-items: center;
width: 100%;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.tf-parameter-name-wrapper {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.tf-parameter-expand-icon {
@@ -709,16 +751,21 @@
.tf-parameter-name {
color: inherit;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tf-parameter-type {
background: var(--tf-bg-tag);
color: var(--tf-text-secondary);
padding: 2px 8px;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
flex-shrink: 0;
}
</style>

View File

@@ -49,9 +49,13 @@
<style lang="less">
.input-container {
display: grid;
grid-template-columns: 40% 50% 10%;
row-gap: 5px;
column-gap: 3px;
grid-template-columns: 124px minmax(0, 1fr) 22px;
row-gap: 6px;
column-gap: 4px;
align-items: center;
width: 100%;
min-width: 318px;
box-sizing: border-box;
.none-params {
font-size: 12px;
@@ -61,16 +65,17 @@
justify-content: center;
align-items: center;
border-radius: 5px;
width: calc(100% - 5px);
width: 100%;
grid-column: 1 / -1; /* 从第一列开始到最后一列结束 */
box-sizing: border-box;
}
.input-header {
font-size: 12px;
color: var(--tf-text-secondary);
min-width: 0;
}
}
</style>

View File

@@ -148,6 +148,8 @@
.input-item {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
}
.input-more-setting {
@@ -171,4 +173,3 @@
}
</style>

View File

@@ -41,9 +41,13 @@
<style lang="less">
.input-container {
display: grid;
grid-template-columns: 40% 50% 10%;
row-gap: 5px;
column-gap: 3px;
grid-template-columns: 124px minmax(0, 1fr) 22px;
row-gap: 6px;
column-gap: 4px;
align-items: center;
width: 100%;
min-width: 318px;
box-sizing: border-box;
.none-params {
font-size: 12px;
@@ -53,16 +57,16 @@
justify-content: center;
align-items: center;
border-radius: 5px;
width: calc(100% - 5px);
width: 100%;
grid-column: 1 / -1; /* 从第一列开始到最后一列结束 */
box-sizing: border-box;
}
.input-header {
font-size: 12px;
color: var(--tf-text-secondary);
min-width: 0;
}
}
</style>

View File

@@ -104,6 +104,10 @@
{/if}
{#if customNode.renderFirst}
<div bind:this={container} style={customNode.rootStyle||""} class={customNode.rootClass}></div>
{/if}
{#if forms}
{#each forms as form}
{#if form.type === 'input'}
@@ -201,7 +205,9 @@
{/if}
<div bind:this={container} style={customNode.rootStyle||""} class={customNode.rootClass}></div>
{#if !customNode.renderFirst}
<div bind:this={container} style={customNode.rootStyle||""} class={customNode.rootClass}></div>
{/if}
{#if customNode.outputDefsEnable !== false}
@@ -279,4 +285,3 @@
cursor: pointer;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
import {Button, FloatingTrigger, Heading, Select} from '../base';
import {MenuButton} from '../base/index.js';
import RefParameterList from '../core/RefParameterList.svelte';
@@ -20,8 +20,42 @@
const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const { nodes } = $derived(useStore());
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
const parameters = [
...(((currentNode?.current?.data?.parameters as Array<any>) || data.parameters || []) as Array<any>)
];
if (queryContextNodeIds.length > 0) {
parameters.push({
id: 'queryDataContext',
name: 'queryDataContext',
dataType: 'String',
description: '数据查询规则与连接表摘要',
required: false,
nameDisabled: true,
dataTypeDisabled: true,
deleteDisabled: true
});
}
return parameters;
});
const queryContextOptions = $derived.by(() => {
return (nodes || [])
.filter((node: any) => node?.id !== currentNodeId && node?.type === 'search-dataset-node')
.map((node: any) => ({
label: node?.data?.title || '查询数据',
value: node.id,
displayLabel: node?.data?.title || '查询数据',
description: node?.data?.sourceName ? '连接服务:' + node.data.sourceName : '未选择连接服务',
}));
});
const queryContextNodeIds = $derived.by(() => {
const ids = (currentNode?.current?.data?.queryContextNodeIds as Array<any>) || data.queryContextNodeIds || [];
return Array.isArray(ids)
? ids
.map((item: any) => (item == null ? '' : String(item)))
.filter((item: string) => item.trim().length > 0)
: [];
});
const options = getOptions();
@@ -29,33 +63,29 @@
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;
const modelName = typeof llm.label === 'string'
? llm.label
: ((llm.displayLabel as string | undefined) || '模型');
if (!grouped.has(brand)) {
grouped.set(brand, []);
}
grouped.get(brand)!.push({
...llm,
label: modelName,
displayLabel: 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);
@@ -72,9 +102,9 @@
children: models
});
}
llmArray.push(...treeArray);
llmArray = treeArray;
} else {
llmArray.push(...(newLLMs || []));
llmArray = [...(newLLMs || [])];
}
});
@@ -121,6 +151,26 @@
}
});
$effect(() => {
const validIds = new Set(queryContextOptions.map((item) => String(item.value)));
const normalized = queryContextNodeIds.filter((item) => validIds.has(item));
if (normalized.length !== queryContextNodeIds.length) {
updateNodeData(currentNodeId, {
queryContextNodeIds: normalized
});
}
});
const toggleQueryContextNode = (nodeId: string) => {
const currentIds = [...queryContextNodeIds];
const exists = currentIds.includes(nodeId);
updateNodeData(currentNodeId, {
queryContextNodeIds: exists
? currentIds.filter((item) => item !== nodeId)
: [...currentIds, nodeId]
});
};
</script>
@@ -159,6 +209,20 @@
<RefParameterList dataKeyName="images" noneParameterText="无图片参数" />
<Heading level={3} mt="10px">查询数据信息</Heading>
<div class="setting-item">
<Select
items={queryContextOptions}
multiple={true}
style="width: 100%"
placeholder="请选择查询数据节点"
onSelect={(item)=>{
toggleQueryContextNode(String(item.value));
}}
value={queryContextNodeIds}
/>
</div>
<Heading level={3} mt="10px">模型设置</Heading>
<div class="setting-title">模型</div>
<div class="setting-item">

View File

@@ -69,6 +69,10 @@
.tf-select {
display: flex;
width: 100%;
min-width: 0;
&-input {
display: flex;
border: 1px solid var(--tf-border-color);
@@ -95,7 +99,8 @@
&-value {
height: 100%;
min-width: 10px;
min-width: 0;
flex: 1;
font-size: 12px;
display: flex;
align-items: center;