Compare commits

...

6 Commits

Author SHA1 Message Date
bymyself
b3126fde24 update node schema 2025-05-30 02:25:48 -07:00
bymyself
1989561fb3 [refactor] Improve text upload widget type safety and naming conventions
- Use proper discriminated unions instead of manual type checking
- Replace 'any' types with 'unknown' for better type safety
- Update naming: TEXTUPLOAD → TEXT_FILE_UPLOAD, text_upload → text_file_upload
- Extract constants for maintainability (SUPPORTED_FILE_TYPES, TEXT_FILE_UPLOAD)
- Convert to V2 widget pattern with ComfyWidgetConstructorV2
- Self-documenting function names replacing comments
- Clean error handling with proper type guards

Addresses all PR review feedback for improved code quality and consistency.
2025-05-30 02:19:39 -07:00
Christian Byrne
6f1f489e1a Update src/composables/widgets/useTextUploadWidget.ts
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-29 20:34:23 -07:00
github-actions
ce16b3a4eb Update locales [skip ci] 2025-05-29 20:34:23 -07:00
bymyself
6fd2051f5c [Feature] Add batch upload support to text upload widget
Adds allow_batch configuration support to text upload widgets, enabling multiple file uploads when specified. Changes include:
- Support for allow_batch input specification
- Batch file processing with uploadTextFiles function
- Value transform logic for single vs array outputs
- Dynamic button text (file/files) based on batch mode
- Translation keys for plural file selection
2025-05-29 20:34:23 -07:00
bymyself
06a36d6869 [feature] Add text and PDF file upload support 2025-05-29 20:34:23 -07:00
12 changed files with 295 additions and 2 deletions

View File

@@ -0,0 +1,218 @@
import { isComboWidget } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type {
CustomInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useToastStore } from '@/stores/toastStore'
import { addToComboValues } from '@/utils/litegraphUtil'
const SUPPORTED_FILE_TYPES = ['text/plain', 'application/pdf', '.txt', '.pdf']
const TEXT_FILE_UPLOAD = 'TEXT_FILE_UPLOAD'
const isTextFile = (file: File): boolean =>
file.type === 'text/plain' || file.name.toLowerCase().endsWith('.txt')
const isPdfFile = (file: File): boolean =>
file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
const isSupportedFile = (file: File): boolean =>
isTextFile(file) || isPdfFile(file)
/**
* Upload a text file and return the file path
* @param file - The file to upload
* @returns The file path or null if the upload fails
*/
async function uploadTextFile(file: File): Promise<string | null> {
try {
const body = new FormData()
body.append('image', file) // Using standard field name for compatibility
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
let path = data.name
if (data.subfolder) {
path = data.subfolder + '/' + path
}
return path
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
useToastStore().addAlert(String(error))
}
return null
}
/**
* Upload multiple text files and return array of file paths
* @param files - The files to upload
* @returns The file paths or an empty array if the upload fails
*/
async function uploadTextFiles(files: File[]): Promise<string[]> {
const uploadPromises = files.map(uploadTextFile)
const results = await Promise.all(uploadPromises)
return results.filter((path) => path !== null)
}
interface TextUploadInputSpec extends CustomInputSpec {
type: 'TEXT_FILE_UPLOAD'
textInputName: string
allow_batch?: boolean
}
export const isTextUploadInputSpec = (
inputSpec: InputSpec
): inputSpec is TextUploadInputSpec => {
return (
inputSpec.type === TEXT_FILE_UPLOAD &&
'textInputName' in inputSpec &&
typeof inputSpec.textInputName === 'string'
)
}
/**
* Creates a disabled button widget when text upload configuration is invalid
*/
const createDisabledUploadButton = (node: any, inputName: string) =>
node.addWidget('button', inputName, '', () => {})
/**
* Create a text upload widget using V2 input spec
*/
export const useTextUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node,
inputSpec: InputSpec
) => {
if (!isTextUploadInputSpec(inputSpec)) {
throw new Error(`Invalid input spec for text upload: ${inputSpec}`)
}
// Get configuration from input spec
const textInputName = inputSpec.textInputName
const allow_batch = inputSpec.allow_batch === true
// Find the combo widget that will store the file path(s)
const foundWidget = node.widgets?.find((w) => w.name === textInputName)
if (!foundWidget || !isComboWidget(foundWidget)) {
console.error(
`Text widget with name "${textInputName}" not found or is not a combo widget`
)
return createDisabledUploadButton(node, inputSpec.name)
}
const textWidget = foundWidget
// Ensure options and values are initialized
if (!textWidget.options) {
textWidget.options = { values: [] }
} else if (!textWidget.options.values) {
textWidget.options.values = []
}
// Types for internal handling
type InternalValue = string | string[] | null | undefined
type ExposedValue = string | string[]
const initialFile = allow_batch ? [] : ''
// Transform function to handle batch vs single file outputs
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch ? internalValue : internalValue[0] || ''
return internalValue
}
// Set up value transform on the widget
Object.defineProperty(
textWidget,
'value',
useValueTransform(transform, initialFile)
)
// Handle the file upload
const handleFileUpload = async (files: File[]): Promise<File[]> => {
if (!files.length) return files
// Filter supported files
const supportedFiles = files.filter(isSupportedFile)
if (!supportedFiles.length) {
useToastStore().addAlert(t('toastMessages.invalidFileType'))
return files
}
if (!allow_batch && supportedFiles.length > 1) {
useToastStore().addAlert('Only single file upload is allowed')
return files
}
const filesToUpload = allow_batch ? supportedFiles : [supportedFiles[0]]
const paths = await uploadTextFiles(filesToUpload)
if (paths.length && textWidget) {
paths.forEach((path) => addToComboValues(textWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
textWidget.value = allow_batch ? paths : paths[0]
if (textWidget.callback) {
textWidget.callback(allow_batch ? paths : paths[0])
}
}
return files
}
// Set up file input for upload button
const { openFileSelection } = useNodeFileInput(node, {
accept: SUPPORTED_FILE_TYPES.join(','),
allow_batch,
onSelect: handleFileUpload
})
// Set up drag and drop
useNodeDragAndDrop(node, {
fileFilter: isSupportedFile,
onDrop: handleFileUpload
})
// Set up paste
useNodePaste(node, {
fileFilter: isSupportedFile,
allow_batch,
onPaste: handleFileUpload
})
// Create upload button widget
const uploadWidget = node.addWidget(
'button',
inputSpec.name,
'',
openFileSelection,
{ serialize: false }
)
uploadWidget.label = allow_batch
? t('g.choose_files_to_upload')
: t('g.choose_file_to_upload')
return uploadWidget
}
return widgetConstructor
}

View File

@@ -18,5 +18,6 @@ import './simpleTouchSupport'
import './slotDefaults'
import './uploadAudio'
import './uploadImage'
import './uploadText'
import './webcamCapture'
import './widgetInputs'

View File

@@ -0,0 +1,50 @@
import {
ComfyNodeDef,
InputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
import { app } from '../../scripts/app'
// Adds an upload button to text/pdf nodes
const isTextUploadComboInput = (inputSpec: InputSpec) => {
const [inputName, inputOptions] = inputSpec
if (!inputOptions) return false
const isUploadInput = inputOptions['text_file_upload'] === true
return (
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
)
}
const createUploadInput = (
textInputName: string,
textInputOptions: InputSpec
): InputSpec => [
'TEXT_FILE_UPLOAD',
{
...textInputOptions[1],
textInputName
}
]
app.registerExtension({
name: 'Comfy.UploadText',
beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
const { input } = nodeData ?? {}
const { required } = input ?? {}
if (!required) return
const found = Object.entries(required).find(([_, input]) =>
isTextUploadComboInput(input)
)
// If text/pdf combo input found, attach upload input
if (found) {
const [inputName, inputSpec] = found
required.upload = createUploadInput(inputName, inputSpec)
}
}
})

View File

@@ -86,6 +86,7 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"choose_files_to_upload": "choose files to upload",
"capture": "capture",
"nodes": "Nodes",
"community": "Community",
@@ -1274,7 +1275,9 @@
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected"
"nothingSelected": "Nothing selected",
"invalidFileType": "Invalid file type",
"onlySingleFile": "Only single file upload is allowed"
},
"auth": {
"apiKey": {

View File

@@ -254,6 +254,7 @@
"capture": "captura",
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"choose_files_to_upload": "elige archivos para subir",
"clear": "Limpiar",
"close": "Cerrar",
"color": "Color",
@@ -1349,6 +1350,7 @@
"fileLoadError": "No se puede encontrar el flujo de trabajo en {fileName}",
"fileUploadFailed": "Error al subir el archivo",
"interrupted": "La ejecución ha sido interrumpida",
"invalidFileType": "Tipo de archivo no válido",
"migrateToLitegraphReroute": "Los nodos de reroute se eliminarán en futuras versiones. Haz clic para migrar a reroute nativo de litegraph.",
"no3dScene": "No hay escena 3D para aplicar textura",
"no3dSceneToExport": "No hay escena 3D para exportar",
@@ -1357,6 +1359,7 @@
"nothingSelected": "Nada seleccionado",
"nothingToGroup": "Nada para agrupar",
"nothingToQueue": "Nada para poner en cola",
"onlySingleFile": "Solo se permite subir un archivo",
"pendingTasksDeleted": "Tareas pendientes eliminadas",
"pleaseSelectNodesToGroup": "Por favor, seleccione los nodos (u otros grupos) para crear un grupo para",
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",

View File

@@ -254,6 +254,7 @@
"capture": "capture",
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"choose_files_to_upload": "choisissez des fichiers à télécharger",
"clear": "Effacer",
"close": "Fermer",
"color": "Couleur",
@@ -1349,6 +1350,7 @@
"fileLoadError": "Impossible de trouver le flux de travail dans {fileName}",
"fileUploadFailed": "Échec du téléchargement du fichier",
"interrupted": "L'exécution a été interrompue",
"invalidFileType": "Type de fichier invalide",
"migrateToLitegraphReroute": "Les nœuds de reroute seront supprimés dans les futures versions. Cliquez pour migrer vers le reroute natif de litegraph.",
"no3dScene": "Aucune scène 3D pour appliquer la texture",
"no3dSceneToExport": "Aucune scène 3D à exporter",
@@ -1357,6 +1359,7 @@
"nothingSelected": "Aucune sélection",
"nothingToGroup": "Rien à regrouper",
"nothingToQueue": "Rien à ajouter à la file dattente",
"onlySingleFile": "Le téléchargement dun seul fichier est autorisé",
"pendingTasksDeleted": "Tâches en attente supprimées",
"pleaseSelectNodesToGroup": "Veuillez sélectionner les nœuds (ou autres groupes) pour créer un groupe pour",
"pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie",

View File

@@ -254,6 +254,7 @@
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"choose_files_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"close": "閉じる",
"color": "色",
@@ -1349,6 +1350,7 @@
"fileLoadError": "{fileName}でワークフローが見つかりません",
"fileUploadFailed": "ファイルのアップロードに失敗しました",
"interrupted": "実行が中断されました",
"invalidFileType": "無効なファイルタイプです",
"migrateToLitegraphReroute": "将来のバージョンではRerouteードが削除されます。litegraph-native rerouteに移行するにはクリックしてください。",
"no3dScene": "テクスチャを適用する3Dシーンがありません",
"no3dSceneToExport": "エクスポートする3Dシーンがありません",
@@ -1357,6 +1359,7 @@
"nothingSelected": "選択されていません",
"nothingToGroup": "グループ化するものがありません",
"nothingToQueue": "キューに追加する項目がありません",
"onlySingleFile": "ファイルは1つだけアップロードできます",
"pendingTasksDeleted": "保留中のタスクが削除されました",
"pleaseSelectNodesToGroup": "グループを作成するためのノード(または他のグループ)を選択してください",
"pleaseSelectOutputNodes": "出力ノードを選択してください",

View File

@@ -254,6 +254,7 @@
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"choose_files_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"close": "닫기",
"color": "색상",
@@ -1349,6 +1350,7 @@
"fileLoadError": "{fileName}에서 워크플로우를 찾을 수 없습니다",
"fileUploadFailed": "파일 업로드에 실패했습니다",
"interrupted": "실행이 중단되었습니다",
"invalidFileType": "잘못된 파일 형식입니다",
"migrateToLitegraphReroute": "향후 버전에서는 Reroute 노드가 제거됩니다. LiteGraph 에서 자체 제공하는 경유점으로 변환하려면 클릭하세요.",
"no3dScene": "텍스처를 적용할 3D 장면이 없습니다",
"no3dSceneToExport": "내보낼 3D 장면이 없습니다",
@@ -1357,6 +1359,7 @@
"nothingSelected": "선택된 항목이 없습니다",
"nothingToGroup": "그룹화할 항목이 없습니다",
"nothingToQueue": "대기열에 추가할 항목이 없습니다",
"onlySingleFile": "파일은 하나만 업로드할 수 있습니다",
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
"pleaseSelectNodesToGroup": "그룹을 만들기 위해 노드(또는 다른 그룹)를 선택해 주세요",
"pleaseSelectOutputNodes": "출력 노드를 선택해 주세요",

View File

@@ -254,6 +254,7 @@
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"choose_files_to_upload": "выберите файлы для загрузки",
"clear": "Очистить",
"close": "Закрыть",
"color": "Цвет",
@@ -1349,6 +1350,7 @@
"fileLoadError": "Не удалось найти рабочий процесс в {fileName}",
"fileUploadFailed": "Не удалось загрузить файл",
"interrupted": "Выполнение было прервано",
"invalidFileType": "Недопустимый тип файла",
"migrateToLitegraphReroute": "Узлы перенаправления будут удалены в будущих версиях. Нажмите, чтобы перейти на litegraph-native reroute.",
"no3dScene": "Нет 3D сцены для применения текстуры",
"no3dSceneToExport": "Нет 3D сцены для экспорта",
@@ -1357,6 +1359,7 @@
"nothingSelected": "Ничего не выбрано",
"nothingToGroup": "Нечего группировать",
"nothingToQueue": "Нет заданий в очереди",
"onlySingleFile": "Разрешена загрузка только одного файла",
"pendingTasksDeleted": "Ожидающие задачи удалены",
"pleaseSelectNodesToGroup": "Пожалуйста, выберите узлы (или другие группы) для создания группы",
"pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы",

View File

@@ -254,6 +254,7 @@
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"choose_files_to_upload": "选择要上传的文件",
"clear": "清除",
"close": "关闭",
"color": "颜色",
@@ -1349,6 +1350,7 @@
"fileLoadError": "无法在 {fileName} 中找到工作流",
"fileUploadFailed": "文件上传失败",
"interrupted": "执行已被中断",
"invalidFileType": "文件类型无效",
"migrateToLitegraphReroute": "将来的版本中将删除重定向节点。点击以迁移到litegraph-native重定向。",
"no3dScene": "没有3D场景可以应用纹理",
"no3dSceneToExport": "没有3D场景可以导出",
@@ -1357,6 +1359,7 @@
"nothingSelected": "未选择任何内容",
"nothingToGroup": "没有可分组的内容",
"nothingToQueue": "没有可加入队列的内容",
"onlySingleFile": "只允许上传单个文件",
"pendingTasksDeleted": "待处理任务已删除",
"pleaseSelectNodesToGroup": "请选取节点(或其他组)以创建分组",
"pleaseSelectOutputNodes": "请选择输出节点",

View File

@@ -76,6 +76,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
text_file_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */

View File

@@ -12,6 +12,7 @@ import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget
import { useIntWidget } from '@/composables/widgets/useIntWidget'
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { useTextUploadWidget } from '@/composables/widgets/useTextUploadWidget'
import { t } from '@/i18n'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -289,5 +290,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
IMAGEUPLOAD: useImageUploadWidget()
IMAGEUPLOAD: useImageUploadWidget(),
TEXT_FILE_UPLOAD: transformWidgetConstructorV2ToV1(useTextUploadWidget())
}