Pārlūkot izejas kodu

feat: 新增知识检索节点

jiaxing.liao 2 nedēļas atpakaļ
vecāks
revīzija
44a59187a4

+ 29 - 0
apps/web/src/i18n/locales/en-us.ts

@@ -1494,6 +1494,27 @@ export default {
 			keyPlaceholder: 'Enter a specified id',
 			pathRequired: 'Please enter the basic dataset path'
 		},
+		knowledgeRetrievalSetter: {
+			queryVariable: 'Query Variable',
+			queryVariablePlaceholder: 'Select a query text variable',
+			queryVariableTip:
+				'The query text used for knowledge retrieval, usually from user input or upstream output.',
+			queryDescribe: 'Query text',
+			knowledgeBases: 'Knowledge Base',
+			knowledgeBasesPlaceholder: 'Enter a knowledge base and press Enter',
+			knowledgeBasesTip:
+				'Multiple knowledge bases will be searched together. If knowledge is also set, it takes precedence.',
+			knowledgeIds: 'Knowledge',
+			knowledgeIdsPlaceholder: 'Enter a knowledge item and press Enter',
+			knowledgeIdsTip:
+				'Used to limit the search to specific knowledge records or files. If empty, search runs across selected knowledge bases.',
+			topK: 'Top K',
+			scoreThreshold: 'Score Threshold',
+			outputs: 'Outputs',
+			queryRequired: 'Please select a query variable',
+			knowledgeRequired:
+				'At least one of knowledge_base_ids or knowledge_ids must be specified'
+		},
 		workflowApprovalSetter: {
 			basicConfig: 'Approval Config',
 			usn: 'User Account',
@@ -1631,6 +1652,10 @@ export default {
 				displayName: 'Basic Dataset',
 				description: 'Read data from a configured basic dataset'
 			},
+			'knowledge-retrieval': {
+				displayName: 'Knowledge Retrieval',
+				description: 'Retrieve relevant text chunks from knowledge bases or specific knowledge files'
+			},
 			'view-data': {
 				displayName: 'View Data',
 				description: 'Read data from a configured view'
@@ -1685,6 +1710,10 @@ export default {
 				viewName: 'View name',
 				totalCount: 'Total count'
 			},
+			'knowledge-retrieval': {
+				result: 'Matched text chunks',
+				content: 'Concatenated retrieval content'
+			},
 			list: {
 				result: 'Filtered results',
 				firstRecord: 'First record',

+ 25 - 0
apps/web/src/i18n/locales/zh-cn.ts

@@ -1372,6 +1372,23 @@ export default {
 			keyPlaceholder: '请输入指定的 ID',
 			pathRequired: '请输入基础数据集路径'
 		},
+		knowledgeRetrievalSetter: {
+			queryVariable: '查询变量',
+			queryVariablePlaceholder: '请选择查询文本变量',
+			queryVariableTip: '用于知识检索的查询文本,通常来自用户输入或上游节点输出。',
+			queryDescribe: '查询文本',
+			knowledgeBases: '知识库',
+			knowledgeBasesPlaceholder: '输入知识库后按回车',
+			knowledgeBasesTip: '多个知识库会跨库检索;如果同时指定知识,将优先按知识检索。',
+			knowledgeIds: '知识',
+			knowledgeIdsPlaceholder: '输入知识后按回车',
+			knowledgeIdsTip: '用于限定到指定知识或文件;为空时在选定知识库范围内检索。',
+			topK: 'Top K',
+			scoreThreshold: 'Score 阈值',
+			outputs: '输出变量',
+			queryRequired: '请选择查询变量',
+			knowledgeRequired: '必须指定 knowledge_base_ids 或 knowledge_ids 中的至少一个'
+		},
 		workflowApprovalSetter: {
 			basicConfig: '审批配置',
 			usn: '用户账号',
@@ -1511,6 +1528,10 @@ export default {
 		meta: {
 			'module-invoke': { displayName: '模块调用', description: '通过接口代码调用模块能力' },
 			'basic-dataset': { displayName: '基础数据集', description: '从配置好的基础数据集中读取数据' },
+			'knowledge-retrieval': {
+				displayName: '知识检索',
+				description: '从知识库或指定知识文件中检索相关文本片段'
+			},
 			'view-data': { displayName: '视图数据', description: '从配置好的视图中读取数据' },
 			start: { displayName: '用户输入', description: '用户输入节点,用于接收用户输入' },
 			end: { displayName: '输出', description: '流程结束并输出节点' },
@@ -1556,6 +1577,10 @@ export default {
 				viewName: '视图名称',
 				totalCount: '总数量'
 			},
+			'knowledge-retrieval': {
+				result: '检索命中的文本片段列表',
+				content: '拼接后的检索内容'
+			},
 			list: {
 				result: '过滤结果',
 				firstRecord: '第一条记录',

+ 1 - 0
apps/web/src/nodes/i18n.ts

@@ -21,6 +21,7 @@ const NODE_GROUP_KEYS: Record<string, 'start' | 'logic' | 'data' | 'tool' | 'oth
 	'mail-sender': 'tool',
 	'sms-sender': 'tool',
 	'workflow-approval': 'logic',
+	'knowledge-retrieval': 'data',
 	'loop-start': 'logic',
 	'iteration-start': 'logic',
 	stickyNote: 'other',

+ 3 - 1
apps/web/src/nodes/src/index.ts

@@ -18,6 +18,7 @@ import { smsSenderNode } from './sms-sender'
 import { mailSenderNode } from './mail-sender'
 import { workflowApprovalNode } from './workflow-approval'
 import { llmNode } from './llm'
+import { knowledgeRetrievalNode } from './knowledge-retrieval'
 
 import { getNodeDisplayName } from '@/nodes/i18n'
 import type { INodeType } from '../Interface'
@@ -128,7 +129,8 @@ const baseNodes = [
 	viewDataNode,
 	smsSenderNode,
 	mailSenderNode,
-	workflowApprovalNode
+	workflowApprovalNode,
+	knowledgeRetrievalNode
 ]
 
 const nodes = baseNodes.map((node) => withFailBranchOutput(node))

+ 70 - 0
apps/web/src/nodes/src/knowledge-retrieval/index.ts

@@ -0,0 +1,70 @@
+import {
+	NodeConnectionTypes,
+	type INodeDataBaseSchema,
+	type INodeType,
+	type NodeVariable
+} from '../../Interface'
+import Setter from './setter.vue'
+import i18n from '@/i18n'
+import { getNodeDescription, getNodeDisplayName } from '@/nodes/i18n'
+
+export type KnowledgeRetrievalData = INodeDataBaseSchema & {
+	query_variable: NodeVariable
+	top_k: number
+	knowledge_base_ids: string[]
+	knowledge_ids: string[]
+	score_threshold_enable: boolean
+	score_threshold: number
+}
+
+export const createDefaultQueryVariable = (): NodeVariable => ({
+	name: 'query',
+	describe: '查询文本',
+	type: 'string',
+	value: ''
+})
+
+export const knowledgeRetrievalNode: INodeType = {
+	version: ['1'],
+	displayName: getNodeDisplayName('knowledge-retrieval'),
+	name: 'knowledge-retrieval',
+	Setter,
+	description: getNodeDescription('knowledge-retrieval'),
+	group: 'data',
+	icon: 'lucide:book-open',
+	iconColor: '#2e90fa',
+	inputs: [NodeConnectionTypes.main],
+	outputs: [NodeConnectionTypes.main],
+	validate: (data: KnowledgeRetrievalData) => {
+		if (!data?.query_variable?.value?.trim()) {
+			return i18n.t('pages.knowledgeRetrievalSetter.queryRequired')
+		}
+
+		const hasKnowledgeBase = data?.knowledge_base_ids?.some((id) => id?.trim())
+		const hasKnowledge = data?.knowledge_ids?.some((id) => id?.trim())
+		return hasKnowledgeBase || hasKnowledge
+			? false
+			: i18n.t('pages.knowledgeRetrievalSetter.knowledgeRequired')
+	},
+	getSubtitle: (data: KnowledgeRetrievalData) => {
+		const knowledgeCount = data?.knowledge_ids?.length || 0
+		const baseCount = data?.knowledge_base_ids?.length || 0
+		if (knowledgeCount) return `${knowledgeCount} knowledge`
+		if (baseCount) return `${baseCount} base`
+		return ''
+	},
+	schema: {
+		appAgentId: '',
+		parentId: '',
+		position: {
+			x: 20,
+			y: 30
+		},
+		width: 96,
+		height: 96,
+		selected: false,
+		nodeType: 'knowledge-retrieval',
+		zIndex: 1,
+		data: {}
+	}
+}

+ 459 - 0
apps/web/src/nodes/src/knowledge-retrieval/setter.vue

@@ -0,0 +1,459 @@
+<template>
+	<el-scrollbar class="w-full box-border p-12px">
+		<div class="knowledge-retrieval-setter">
+			<section class="section-block">
+				<div class="section-title-row">
+					<label class="section-title">{{ texts.queryVariable }}</label>
+				</div>
+				<VarSelect
+					v-model="formData.query_variable.value"
+					class="w-full"
+					:var-type="formData.query_variable.type"
+					:placeholder="texts.queryVariablePlaceholder"
+					@change="handleQueryVariableChange"
+					@clear="handleQueryVariableClear"
+				/>
+				<div class="field-hint">{{ texts.queryVariableTip }}</div>
+			</section>
+
+			<section class="section-block">
+				<div class="section-title-row">
+					<label class="section-title">{{ texts.knowledgeBases }}</label>
+				</div>
+				<el-select
+					v-model="formData.knowledge_base_ids"
+					multiple
+					filterable
+					allow-create
+					default-first-option
+					class="id-tag-input"
+					:placeholder="texts.knowledgeBasesPlaceholder"
+					:aria-label="texts.knowledgeBasesPlaceholder"
+					:loading="knowledgeBaseLoading"
+					clearable
+				>
+					<el-option
+						v-for="item in knowledgeBaseOptions"
+						:key="item.value"
+						:label="item.label"
+						:value="item.value"
+					/>
+				</el-select>
+				<div class="field-hint">{{ texts.knowledgeBasesTip }}</div>
+			</section>
+
+			<section class="section-block">
+				<div class="section-title-row">
+					<label class="section-title">{{ texts.knowledgeIds }}</label>
+				</div>
+				<el-select
+					v-model="formData.knowledge_ids"
+					multiple
+					filterable
+					class="id-tag-input"
+					:placeholder="texts.knowledgeIdsPlaceholder"
+					:aria-label="texts.knowledgeIdsPlaceholder"
+					:loading="knowledgeLoading"
+					clearable
+				>
+					<el-option
+						v-for="item in knowledgeOptions"
+						:key="item.value"
+						:label="item.label"
+						:value="item.value"
+					/>
+				</el-select>
+				<div class="field-hint">{{ texts.knowledgeIdsTip }}</div>
+			</section>
+
+			<section class="section-block">
+				<div class="grid-two">
+					<div class="field-block">
+						<div class="section-title-row section-title-row--compact">
+							<label class="section-title">{{ texts.topK }}</label>
+						</div>
+						<el-input-number v-model="formData.top_k" :min="1" :max="100" class="w-full" />
+					</div>
+					<div class="field-block">
+						<div class="section-title-row section-title-row--compact">
+							<label class="section-title">{{ texts.scoreThreshold }}</label>
+							<el-switch v-model="formData.score_threshold_enable" />
+						</div>
+						<el-input-number
+							v-model="formData.score_threshold"
+							:min="0"
+							:max="1"
+							:step="0.05"
+							:disabled="!formData.score_threshold_enable"
+							:precision="2"
+							class="w-full"
+						/>
+					</div>
+				</div>
+			</section>
+
+			<section class="section-block">
+				<el-collapse>
+					<el-collapse-item title="" name="2">
+						<template #title>
+							<div class="flex items-center justify-between beautify">
+								<label class="text-14px font-bold text-gray-700">输出变量</label>
+							</div>
+						</template>
+						<ul>
+							<li v-for="output in formData.outputs" :key="output.name">
+								<div>
+									<span class="text-#333">{{ output.name }}</span>
+									<span class="text-#999 ml-8px">{{ output.type }}</span>
+								</div>
+								<div class="text-#666">{{ output.describe }}</div>
+							</li>
+						</ul>
+					</el-collapse-item>
+				</el-collapse>
+			</section>
+
+			<NodeRuntimeConfig v-model="formData" />
+		</div>
+	</el-scrollbar>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+import { knowledge } from '@repo/api-service'
+
+import NodeRuntimeConfig from '@/nodes/_base/NodeRuntimeConfig.vue'
+import VarSelect from '@/nodes/_base/VarSelect.vue'
+import { useI18n } from '@/composables/useI18n'
+import { useSetterModel } from '../_shared/useSetterModel'
+
+import { createDefaultQueryVariable, type KnowledgeRetrievalData } from './index'
+import type { NodeVariableType } from '@/nodes/Interface'
+
+type SelectOption = {
+	label: string
+	value: string
+	raw?: any
+}
+
+const props = defineProps<{
+	data: KnowledgeRetrievalData
+}>()
+
+const emit = defineEmits<{
+	(e: 'update', value: KnowledgeRetrievalData): void
+}>()
+
+const { t } = useI18n()
+const formData = useSetterModel<KnowledgeRetrievalData>(props, emit)
+const knowledgeBaseLoading = ref(false)
+const knowledgeLoading = ref(false)
+const knowledgeBaseOptions = ref<SelectOption[]>([])
+const knowledgeOptions = ref<SelectOption[]>([])
+const knowledgePageSize = 20
+
+const texts = computed(() => ({
+	queryVariable: t('pages.knowledgeRetrievalSetter.queryVariable'),
+	queryVariablePlaceholder: t('pages.knowledgeRetrievalSetter.queryVariablePlaceholder'),
+	queryVariableTip: t('pages.knowledgeRetrievalSetter.queryVariableTip'),
+	knowledgeBases: t('pages.knowledgeRetrievalSetter.knowledgeBases'),
+	knowledgeBasesPlaceholder: t('pages.knowledgeRetrievalSetter.knowledgeBasesPlaceholder'),
+	knowledgeBasesTip: t('pages.knowledgeRetrievalSetter.knowledgeBasesTip'),
+	knowledgeIds: t('pages.knowledgeRetrievalSetter.knowledgeIds'),
+	knowledgeIdsPlaceholder: t('pages.knowledgeRetrievalSetter.knowledgeIdsPlaceholder'),
+	knowledgeIdsTip: t('pages.knowledgeRetrievalSetter.knowledgeIdsTip'),
+	topK: t('pages.knowledgeRetrievalSetter.topK'),
+	scoreThreshold: t('pages.knowledgeRetrievalSetter.scoreThreshold'),
+	outputs: t('pages.knowledgeRetrievalSetter.outputs')
+}))
+
+const normalizeOptionLabel = (item: {
+	id?: string
+	knowledge_id?: string
+	name?: string
+	title?: string
+	file_name?: string
+	knowledge_filename?: string
+	knowledge_title?: string
+}) =>
+	item.title ||
+	item.name ||
+	item.file_name ||
+	item.knowledge_filename ||
+	item.knowledge_title ||
+	item.id ||
+	item.knowledge_id ||
+	''
+
+const normalizeOptionValue = (item: { id?: string; knowledge_id?: string; file_id?: string }) =>
+	item.id || item.knowledge_id || item.file_id || ''
+
+const mergeSelectedOptions = (options: SelectOption[], selectedValues: string[]) => {
+	const optionMap = new Map<string, SelectOption>()
+	options.forEach((item) => {
+		if (item.value) optionMap.set(item.value, item)
+	})
+	selectedValues.forEach((value) => {
+		if (value && !optionMap.has(value)) {
+			optionMap.set(value, {
+				label: value,
+				value
+			})
+		}
+	})
+	return Array.from(optionMap.values())
+}
+
+const fetchKnowledgeBaseOptions = async () => {
+	knowledgeBaseLoading.value = true
+	try {
+		const res = await knowledge.postKnowledgeBasePageList({
+			keyword: '',
+			type: '',
+			pageIndex: 1,
+			pageSize: 20
+		})
+		if (!res?.isSuccess) return
+
+		const list = (res.result?.model || []).map((item) => ({
+			label: normalizeOptionLabel(item),
+			value: normalizeOptionValue(item),
+			raw: item
+		}))
+		knowledgeBaseOptions.value = mergeSelectedOptions(
+			list.filter((item) => item.value),
+			formData.value.knowledge_base_ids
+		)
+
+		if (!formData.value.knowledge_base_ids?.length && list[0]?.value) {
+			formData.value.knowledge_base_ids = [list[0].value]
+		}
+
+		return list
+	} finally {
+		knowledgeBaseLoading.value = false
+	}
+}
+
+const fetchKnowledgeOptions = async (baseIdsInput?: string[]) => {
+	const baseIds = Array.from(
+		new Set((baseIdsInput || formData.value.knowledge_base_ids || []).filter(Boolean))
+	)
+	if (!baseIds.length) {
+		knowledgeOptions.value = mergeSelectedOptions([], formData.value.knowledge_ids)
+		return
+	}
+
+	knowledgeLoading.value = true
+	try {
+		const results = await Promise.all(
+			baseIds.map((knowledgeBaseId) =>
+				knowledge.postKnowledgePageList({
+					knowledge_base_id: knowledgeBaseId,
+					title: '',
+					file_type: '',
+					pageIndex: 1,
+					pageSize: knowledgePageSize
+				})
+			)
+		)
+
+		const optionMap = new Map<string, SelectOption>()
+		results.forEach((res) => {
+			if (!res?.isSuccess) return
+			console.log(res.result)
+			;(res.result?.model || []).forEach((item) => {
+				if (!item.id) return
+				optionMap.set(item.id, {
+					label: normalizeOptionLabel(item),
+					value: item.id,
+					raw: item
+				})
+			})
+		})
+
+		knowledgeOptions.value = mergeSelectedOptions(
+			Array.from(optionMap.values()),
+			formData.value.knowledge_ids
+		)
+	} finally {
+		knowledgeLoading.value = false
+	}
+}
+
+const ensureDefaults = () => {
+	formData.value.title ||= t('nodes.meta.knowledge-retrieval.displayName')
+	formData.value.type ||= 'knowledge-retrieval'
+	formData.value.query_variable ||= createDefaultQueryVariable()
+	formData.value.query_variable.describe ||= t('pages.knowledgeRetrievalSetter.queryDescribe')
+	formData.value.query_variable.name ||= 'query'
+	formData.value.query_variable.type ||= 'string'
+	formData.value.query_variable.value ||= ''
+	formData.value.top_k = typeof formData.value.top_k === 'number' ? formData.value.top_k : 10
+	formData.value.knowledge_base_ids ||= []
+	formData.value.knowledge_ids ||= []
+	formData.value.score_threshold_enable =
+		typeof formData.value.score_threshold_enable === 'boolean'
+			? formData.value.score_threshold_enable
+			: false
+	formData.value.score_threshold =
+		typeof formData.value.score_threshold === 'number' ? formData.value.score_threshold : 0.8
+	if (!formData.value.outputs?.length) {
+		formData.value.outputs = [
+			{
+				name: 'result',
+				describe: t('nodes.outputs.knowledge-retrieval.result'),
+				type: 'array[object]'
+			},
+			{
+				name: 'content',
+				describe: t('nodes.outputs.knowledge-retrieval.content'),
+				type: 'string'
+			}
+		]
+	}
+}
+
+const syncSelectableLists = async () => {
+	const baseList = await fetchKnowledgeBaseOptions()
+	const baseIds = formData.value.knowledge_base_ids?.length
+		? [...formData.value.knowledge_base_ids]
+		: baseList?.[0]?.value
+			? [baseList[0].value]
+			: []
+
+	if (!formData.value.knowledge_base_ids?.length && baseIds.length) {
+		formData.value.knowledge_base_ids = [...baseIds]
+	}
+
+	await fetchKnowledgeOptions(baseIds)
+}
+
+watch(
+	formData,
+	() => {
+		ensureDefaults()
+	},
+	{ deep: true, immediate: true }
+)
+
+watch(
+	() => [...formData.value.knowledge_base_ids],
+	(baseIds) => {
+		void fetchKnowledgeOptions(baseIds)
+	},
+	{ deep: true }
+)
+
+onMounted(() => {
+	void syncSelectableLists()
+})
+
+const handleQueryVariableChange = (val: { value: string; type: NodeVariableType }) => {
+	formData.value.query_variable = {
+		...formData.value.query_variable,
+		name: 'query',
+		describe: t('pages.knowledgeRetrievalSetter.queryDescribe'),
+		value: val.value,
+		type: val.type || 'string'
+	}
+}
+
+const handleQueryVariableClear = () => {
+	formData.value.query_variable = createDefaultQueryVariable()
+	formData.value.query_variable.describe = t('pages.knowledgeRetrievalSetter.queryDescribe')
+}
+</script>
+
+<style scoped lang="less">
+.knowledge-retrieval-setter {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+}
+
+.section-block {
+	padding-bottom: 16px;
+	border-bottom: 1px solid #eef2f7;
+}
+
+.section-title-row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	margin-bottom: 10px;
+}
+
+.section-title {
+	font-size: 14px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.field-hint {
+	margin-top: 8px;
+	font-size: 12px;
+	line-height: 1.5;
+	color: #667085;
+}
+
+.grid-two {
+	display: grid;
+	grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+	gap: 16px;
+}
+
+.field-block {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.section-title-row--compact {
+	min-height: 32px;
+	margin-bottom: 0;
+}
+
+.id-tag-input {
+	width: 100%;
+}
+
+.output-list {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.output-item {
+	padding: 10px 12px;
+	border: 1px solid #eaecf0;
+	border-radius: 10px;
+	background: #f9fafb;
+}
+
+.output-item__main {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	flex-wrap: wrap;
+}
+
+.output-item__name {
+	font-size: 13px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.output-item__type {
+	font-size: 12px;
+	color: #667085;
+}
+
+.output-item__desc {
+	margin-top: 4px;
+	font-size: 12px;
+	line-height: 1.5;
+	color: #667085;
+}
+</style>

+ 47 - 1
packages/api-client/src/request.ts

@@ -157,7 +157,53 @@ class HttpClient {
 
 	private getRequestKey(config: AxiosRequestConfig): RequestKey | undefined {
 		if (!config.url) return undefined
-		return `${config.method?.toUpperCase()}-${config.url}`
+		const method = config.method?.toUpperCase() || 'GET'
+		const paramsKey = this.serializeRequestPart(config.params)
+		const dataKey = this.serializeRequestPart(config.data)
+		return `${method}-${config.url}-${paramsKey}-${dataKey}`
+	}
+
+	private serializeRequestPart(value: unknown): string {
+		if (value === undefined || value === null || value === '') return ''
+		if (value instanceof URLSearchParams) return value.toString()
+		if (typeof FormData !== 'undefined' && value instanceof FormData) {
+			return Array.from(value.entries())
+				.map(([key, item]) => `${key}:${this.serializeRequestPart(item)}`)
+				.sort()
+				.join('&')
+		}
+		if (typeof Blob !== 'undefined' && value instanceof Blob) {
+			return `${value.type}:${value.size}`
+		}
+		if (typeof value !== 'object') return String(value)
+		return JSON.stringify(this.sortRequestPart(value))
+	}
+
+	private sortRequestPart(value: unknown): unknown {
+		if (Array.isArray(value)) {
+			return value.map((item) => this.sortRequestPart(item))
+		}
+		if (!value || typeof value !== 'object') return value
+		if (value instanceof Date) return value.toISOString()
+		if (value instanceof URLSearchParams) return value.toString()
+		if (typeof FormData !== 'undefined' && value instanceof FormData) {
+			return Array.from(value.entries())
+				.map(([key, item]) => [key, this.sortRequestPart(item)])
+				.sort(([left], [right]) => String(left).localeCompare(String(right)))
+		}
+		if (typeof Blob !== 'undefined' && value instanceof Blob) {
+			return `${value.type}:${value.size}`
+		}
+
+		return Object.keys(value as Record<string, unknown>)
+			.sort()
+			.reduce(
+				(acc, key) => {
+					acc[key] = this.sortRequestPart((value as Record<string, unknown>)[key])
+					return acc
+				},
+				{} as Record<string, unknown>
+			)
 	}
 
 	private setupCancelController(