- 接入发布快照优先与未发布草稿受控运行 - 支持文本和思考流式输出、循环多输出及实时运行详情 - 完成聊天分享、图片输入、中止与清空重来 - 补充后端与前端定向回归测试
423 lines
15 KiB
Svelte
423 lines
15 KiB
Svelte
<svelte:options customElement={{ props: {} }} />
|
|
|
|
<script lang="ts">
|
|
import NodeWrapper from '../core/NodeWrapper.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';
|
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
|
import {getOptions} from '../utils/NodeUtils';
|
|
import {onMount} from 'svelte';
|
|
import OutputDefList from '../core/OutputDefList.svelte';
|
|
import type {Parameter, SelectItem} from '#types';
|
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
|
import {
|
|
buildEditorReferenceParameters,
|
|
collectDownstreamNodeIds,
|
|
FIELD_BINDING_META_KEY,
|
|
syncManagedParametersForFields,
|
|
updateFieldBindingMeta,
|
|
} from '../../utils/workflowNodeFields';
|
|
|
|
const { data, ...rest }: {
|
|
data: NodeProps['data'],
|
|
[key: string]: any
|
|
} = $props();
|
|
|
|
const currentNodeId = getCurrentNodeId();
|
|
let currentNode = useNodesData(currentNodeId);
|
|
const { addParameter } = useAddParameter();
|
|
const { nodes, edges } = $derived(useStore());
|
|
const downstreamNodeIds = $derived.by(() => {
|
|
return new Set(collectDownstreamNodeIds(currentNodeId, edges || []));
|
|
});
|
|
const editorParameters = $derived.by(() => {
|
|
const parameters = buildEditorReferenceParameters(
|
|
currentNodeId,
|
|
nodes || [],
|
|
edges || [],
|
|
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
|
|
);
|
|
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) =>
|
|
downstreamNodeIds.has(node?.id) && 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();
|
|
|
|
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) {
|
|
let brand = (llm as any).brand || '其他';
|
|
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
|
|
});
|
|
}
|
|
|
|
const treeArray: SelectItem[] = [];
|
|
for (const [brand, models] of grouped) {
|
|
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 = treeArray;
|
|
} else {
|
|
llmArray = [...(newLLMs || [])];
|
|
}
|
|
});
|
|
|
|
const { updateNodeData } = useSvelteFlow();
|
|
const syncFieldValue = (fieldName: 'systemPrompt' | 'userPrompt', nextValue: string) => {
|
|
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
|
|
const nextFieldValues = {
|
|
systemPrompt: fieldName === 'systemPrompt' ? nextValue : (currentData.systemPrompt || ''),
|
|
userPrompt: fieldName === 'userPrompt' ? nextValue : (currentData.userPrompt || ''),
|
|
};
|
|
updateNodeData(currentNodeId, {
|
|
[fieldName]: nextValue,
|
|
parameters: syncManagedParametersForFields(
|
|
(currentData.parameters as Array<Parameter>) || [],
|
|
editorParameters,
|
|
nextFieldValues
|
|
),
|
|
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
|
|
});
|
|
};
|
|
|
|
const setOutType = (value: string) => {
|
|
updateNodeData(currentNodeId, () => {
|
|
return {
|
|
outType: value
|
|
};
|
|
});
|
|
|
|
if (value === 'text') {
|
|
updateNodeData(currentNodeId, {
|
|
outputDefs: [{
|
|
name: 'output',
|
|
dataType: 'String',
|
|
dataTypeDisabled: true,
|
|
deleteDisabled: true
|
|
}]
|
|
});
|
|
} else {
|
|
updateNodeData(currentNodeId, {
|
|
outputDefs: [{
|
|
name: 'root',
|
|
dataType: 'Object',
|
|
dataTypeItems: [
|
|
{
|
|
value: 'Object',
|
|
label: 'Object'
|
|
},
|
|
{
|
|
value: 'Array',
|
|
label: 'Array'
|
|
}],
|
|
deleteDisabled: true
|
|
}]
|
|
});
|
|
}
|
|
};
|
|
|
|
$effect(() => {
|
|
if (!data.outType) {
|
|
setOutType('text');
|
|
}
|
|
});
|
|
|
|
const toggleQueryContextNode = (nodeId: string) => {
|
|
const currentIds = [...queryContextNodeIds];
|
|
const exists = currentIds.includes(nodeId);
|
|
updateNodeData(currentNodeId, {
|
|
queryContextNodeIds: exists
|
|
? currentIds.filter((item) => item !== nodeId)
|
|
: [...currentIds, nodeId]
|
|
});
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<NodeWrapper {data} {...rest} wrapperClass="tf-node-wrapper--llm">
|
|
|
|
{#snippet icon()}
|
|
<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>
|
|
{/snippet}
|
|
|
|
<div class="heading">
|
|
<Heading level={3}>图片输入</Heading>
|
|
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
|
addParameter(currentNodeId, "images")
|
|
}}>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
|
|
<RefParameterList
|
|
dataKeyName="images"
|
|
noneParameterText="无图片参数"
|
|
acceptedContentTypes={["image"]}
|
|
/>
|
|
|
|
{#if queryContextOptions.length > 0}
|
|
<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>
|
|
{/if}
|
|
|
|
<Heading level={3} mt="10px">模型设置</Heading>
|
|
<div class="setting-item">
|
|
<Select items={llmArray} variant="model" style="width: 100%" placeholder="请选择模型" onSelect={(item)=>{
|
|
const newValue = item.value;
|
|
updateNodeData(currentNodeId, ()=>{
|
|
return {
|
|
llmId: newValue
|
|
}
|
|
})
|
|
}} value={data.llmId ? [data.llmId] : []} />
|
|
<FloatingTrigger placement="bottom">
|
|
<MenuButton />
|
|
{#snippet floating()}
|
|
<div class="llm-setting">
|
|
<!-- 在原有采样参数部分添加事件阻止 -->
|
|
<div class="setting-title">采样参数</div>
|
|
<div class="setting-item">
|
|
<div class="slider-container">
|
|
<span>Temperature: {data.temperature ?? 0.7}</span>
|
|
<input
|
|
class="nodrag"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={data.temperature ?? 0.7}
|
|
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat((e.target as HTMLInputElement).value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item">
|
|
<div class="slider-container">
|
|
<span>Top P: {data.topP ?? 0.9}</span>
|
|
<input
|
|
class="nodrag"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={data.topP ?? 0.9}
|
|
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat((e.target as HTMLInputElement).value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item">
|
|
<div class="slider-container">
|
|
<span>Top K: {data.topK ?? 50}</span>
|
|
<input
|
|
class="nodrag"
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
step="1"
|
|
value={data.topK ?? 50}
|
|
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt((e.target as HTMLInputElement).value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
</FloatingTrigger>
|
|
</div>
|
|
|
|
|
|
<div class="setting-title">系统提示词</div>
|
|
<div class="setting-item">
|
|
<ParamTokenEditor
|
|
mode="textarea"
|
|
rows={5}
|
|
placeholder="请输入系统提示词"
|
|
style="width: 100%"
|
|
parameters={editorParameters}
|
|
value={String(data.systemPrompt || '')}
|
|
oninput={(e: any)=>{
|
|
syncFieldValue('systemPrompt', e.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="setting-title">用户提示词</div>
|
|
<div class="setting-item">
|
|
<ParamTokenEditor
|
|
mode="textarea"
|
|
rows={5}
|
|
placeholder="请输入用户提示词"
|
|
style="width: 100%"
|
|
parameters={editorParameters}
|
|
value={String(data.userPrompt || '')}
|
|
oninput={(e: any)=>{
|
|
syncFieldValue('userPrompt', e.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="heading">
|
|
<Heading level={3}>输出参数</Heading>
|
|
<Select items={[{
|
|
label: '文本',
|
|
value: 'text'
|
|
}, {
|
|
label: 'JSON',
|
|
value: 'json'
|
|
}]} style="width: 100px;margin-left: auto" onSelect={(item)=>{
|
|
setOutType(String(item.value));
|
|
}} value={data.outType ? [data.outType] : []} />
|
|
</div>
|
|
<OutputDefList />
|
|
|
|
</NodeWrapper>
|
|
|
|
<style>
|
|
.heading {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.setting-title {
|
|
font-size: 12px;
|
|
color: var(--tf-text-muted);
|
|
margin-bottom: 4px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.setting-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
gap: 10px;
|
|
}
|
|
|
|
/* 新增样式 */
|
|
.slider-container {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.slider-container span {
|
|
font-size: 12px;
|
|
color: var(--tf-text-secondary);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.llm-setting {
|
|
width: 200px;
|
|
background: var(--tf-bg-surface);
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
box-shadow: var(--tf-shadow-medium);
|
|
border: 1px solid var(--tf-border-color-strong);
|
|
|
|
}
|
|
|
|
input[type="range"] {
|
|
width: 100%;
|
|
height: 4px;
|
|
background: var(--tf-slider-track-bg);
|
|
border-radius: 2px;
|
|
outline: none;
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
}
|
|
|
|
input[type="range"]::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
background: var(--tf-slider-thumb-bg);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|