feat(processors): add processor management

This commit is contained in:
shiyu
2025-09-19 18:58:54 +08:00
parent fbeb673126
commit 7da49191aa
12 changed files with 863 additions and 43 deletions

View File

@@ -1,10 +1,17 @@
from fastapi import APIRouter, Depends, Body
from pathlib import Path
from fastapi import APIRouter, Depends, Body, HTTPException
from fastapi.concurrency import run_in_threadpool
from typing import Annotated
from services.processors.registry import get_config_schemas
from services.processors.registry import (
get_config_schemas,
get_module_path,
reload_processors,
)
from services.task_queue import task_queue_service
from services.auth import get_current_active_user, User
from api.response import success
from pydantic import BaseModel
from services.virtual_fs import path_is_directory
router = APIRouter(prefix="/api/processors", tags=["processors"])
@@ -22,6 +29,7 @@ async def list_processors(
"supported_exts": meta.get("supported_exts", []),
"config_schema": meta["config_schema"],
"produces_file": meta.get("produces_file", False),
"module_path": meta.get("module_path"),
})
return success(out)
@@ -34,12 +42,20 @@ class ProcessRequest(BaseModel):
overwrite: bool = False
class UpdateSourceRequest(BaseModel):
source: str
@router.post("/process")
async def process_file_with_processor(
current_user: Annotated[User, Depends(get_current_active_user)],
req: ProcessRequest = Body(...)
):
save_to = req.path if req.overwrite else req.save_to
is_dir = await path_is_directory(req.path)
if is_dir and not req.overwrite:
raise HTTPException(400, detail="Directory processing requires overwrite")
save_to = None if is_dir else (req.path if req.overwrite else req.save_to)
task = await task_queue_service.add_task(
"process_file",
{
@@ -47,6 +63,54 @@ async def process_file_with_processor(
"processor_type": req.processor_type,
"config": req.config,
"save_to": save_to,
"overwrite": req.overwrite,
},
)
return success({"task_id": task.id})
@router.get("/source/{processor_type}")
async def get_processor_source(
processor_type: str,
current_user: Annotated[User, Depends(get_current_active_user)],
):
module_path = get_module_path(processor_type)
if not module_path:
raise HTTPException(404, detail="Processor not found")
path_obj = Path(module_path)
if not path_obj.exists():
raise HTTPException(404, detail="Processor source not found")
try:
content = await run_in_threadpool(path_obj.read_text, encoding='utf-8')
except Exception as exc:
raise HTTPException(500, detail=f"Failed to read source: {exc}")
return success({"source": content, "module_path": str(path_obj)})
@router.put("/source/{processor_type}")
async def update_processor_source(
processor_type: str,
req: UpdateSourceRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
module_path = get_module_path(processor_type)
if not module_path:
raise HTTPException(404, detail="Processor not found")
path_obj = Path(module_path)
if not path_obj.exists():
raise HTTPException(404, detail="Processor source not found")
try:
await run_in_threadpool(path_obj.write_text, req.source, encoding='utf-8')
except Exception as exc:
raise HTTPException(500, detail=f"Failed to write source: {exc}")
return success(True)
@router.post("/reload")
async def reload_processor_modules(
current_user: Annotated[User, Depends(get_current_active_user)],
):
errors = reload_processors()
if errors:
raise HTTPException(500, detail="; ".join(errors))
return success(True)

View File

@@ -1,33 +1,53 @@
import pkgutil
import inspect
from importlib import import_module
from typing import Dict, Callable
import pkgutil
from importlib import import_module, reload
from pathlib import Path
from types import ModuleType
from typing import Callable, Dict, Optional
from .base import BaseProcessor
ProcessorFactory = Callable[[], BaseProcessor]
TYPE_MAP: Dict[str, ProcessorFactory] = {}
CONFIG_SCHEMAS: Dict[str, dict] = {}
MODULE_MAP: Dict[str, ModuleType] = {}
LAST_DISCOVERY_ERRORS: list[str] = []
def discover_processors(force_reload: bool = False) -> list[str]:
"""Discover available processor modules and cache their metadata."""
import services.processors # 延迟导入以避免循环
def discover_processors():
import services.processors
processors_pkg = services.processors
TYPE_MAP.clear()
CONFIG_SCHEMAS.clear()
MODULE_MAP.clear()
global LAST_DISCOVERY_ERRORS
LAST_DISCOVERY_ERRORS = []
for modinfo in pkgutil.iter_modules(processors_pkg.__path__):
if modinfo.name.startswith("_"):
continue
full_name = f"{processors_pkg.__name__}.{modinfo.name}"
try:
module = import_module(full_name)
except Exception:
if force_reload:
module = reload(module)
except Exception as exc:
LAST_DISCOVERY_ERRORS.append(f"Failed to import {full_name}: {exc}")
continue
processor_type = getattr(module, "PROCESSOR_TYPE", None)
processor_name = getattr(module, "PROCESSOR_NAME", None)
supported_exts = getattr(module, "SUPPORTED_EXTS", None)
schema = getattr(module, "CONFIG_SCHEMA", None)
factory = getattr(module, "PROCESSOR_FACTORY", None)
if not processor_type:
continue
if factory is None:
for attr in module.__dict__.values():
if inspect.isclass(attr) and attr.__name__.endswith("Processor"):
@@ -35,31 +55,85 @@ def discover_processors():
return lambda: cls()
factory = _mk()
break
if not callable(factory):
LAST_DISCOVERY_ERRORS.append(f"Processor {full_name} missing factory")
continue
try:
sample = factory()
except Exception as exc:
LAST_DISCOVERY_ERRORS.append(f"Failed to instantiate processor {processor_type}: {exc}")
continue
TYPE_MAP[processor_type] = factory
MODULE_MAP[processor_type] = module
produces_file = getattr(module, "produces_file", None)
if produces_file is None and hasattr(factory(), "produces_file"):
produces_file = getattr(factory(), "produces_file")
if produces_file is None and hasattr(sample, "produces_file"):
produces_file = getattr(sample, "produces_file")
module_file = getattr(module, "__file__", None)
module_path: Optional[str] = None
if module_file:
try:
module_path = str(Path(module_file).resolve())
except Exception:
module_path = module_file
if isinstance(supported_exts, list):
normalized_exts = [str(ext) for ext in supported_exts]
elif supported_exts:
normalized_exts = [str(supported_exts)]
else:
normalized_exts = []
if not normalized_exts and hasattr(sample, "supported_exts"):
sample_exts = getattr(sample, "supported_exts") or []
if isinstance(sample_exts, list):
normalized_exts = [str(ext) for ext in sample_exts]
if isinstance(schema, list):
CONFIG_SCHEMAS[processor_type] = {
"type": processor_type,
"name": processor_name or processor_type,
"supported_exts": supported_exts or [],
"supported_exts": normalized_exts,
"config_schema": schema,
"produces_file": produces_file if produces_file is not None else False
"produces_file": produces_file if produces_file is not None else False,
"module_path": module_path,
}
return LAST_DISCOVERY_ERRORS
def get_config_schemas() -> Dict[str, dict]:
return CONFIG_SCHEMAS
def get_config_schema(processor_type: str):
return CONFIG_SCHEMAS.get(processor_type)
def get(processor_type: str) -> BaseProcessor:
factory = TYPE_MAP.get(processor_type)
if factory:
return factory()
return None
def get_module_path(processor_type: str) -> Optional[str]:
meta = CONFIG_SCHEMAS.get(processor_type)
if not meta:
return None
return meta.get("module_path")
def get_last_discovery_errors() -> list[str]:
return LAST_DISCOVERY_ERRORS
def reload_processors() -> list[str]:
return discover_processors(force_reload=True)
discover_processors()

View File

@@ -54,7 +54,8 @@ class TaskQueueService:
path=params["path"],
processor_type=params["processor_type"],
config=params["config"],
save_to=params["save_to"]
save_to=params.get("save_to"),
overwrite=params.get("overwrite", False),
)
task.result = result
elif task.name == "automation_task":
@@ -119,4 +120,4 @@ class TaskQueueService:
await LogService.info("task_queue", "Task worker has been stopped.")
task_queue_service = TaskQueueService()
task_queue_service = TaskQueueService()

View File

@@ -1,4 +1,4 @@
from typing import Dict, Tuple, Any, Union, AsyncIterator
from typing import Dict, Tuple, Any, Union, AsyncIterator, List
from fastapi import HTTPException
import mimetypes
from fastapi.responses import Response
@@ -59,6 +59,24 @@ async def _ensure_method(adapter: Any, method: str):
return func
async def path_is_directory(path: str) -> bool:
"""判断给定路径是否为目录。"""
adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
rel = rel.rstrip('/')
if rel == '':
return True
stat_func = getattr(adapter_instance, "stat_file", None)
if not callable(stat_func):
raise HTTPException(501, detail="Adapter does not implement stat_file")
try:
info = await stat_func(root, rel)
except FileNotFoundError:
raise HTTPException(404, detail="Path not found")
if isinstance(info, dict):
return bool(info.get("is_dir"))
return False
async def list_virtual_dir(path: str, page_num: int = 1, page_size: int = 50, sort_by: str = "name", sort_order: str = "asc") -> Dict:
norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
adapters = await StorageAdapter.filter(enabled=True)
@@ -476,28 +494,110 @@ async def copy_path(src: str, dst: str, overwrite: bool = False, return_debug: b
return debug_info if return_debug else None
async def process_file(path: str, processor_type: str, config: dict, save_to: str = None):
"""
使用指定处理器处理文件,并可选择保存到新路径
:param path: 源文件路径
:param processor_type: 处理器类型
:param config: 处理器配置
:param save_to: 保存路径(可选),不指定则只返回处理结果
:return: 处理后的文件内容或保存结果
"""
data = await read_file(path)
async def process_file(
path: str,
processor_type: str,
config: dict,
save_to: str | None = None,
overwrite: bool = False,
) -> Any:
"""处理指定路径(文件或目录)。目录会递归处理其下所有文件。"""
processor = get_processor(processor_type)
if not processor:
raise HTTPException(
400, detail=f"Processor {processor_type} not found")
result = await processor.process(data, path, config)
if save_to and getattr(processor, "produces_file", False):
raise HTTPException(400, detail=f"Processor {processor_type} not found")
actual_is_dir = await path_is_directory(path)
supported_exts = getattr(processor, "supported_exts", None) or []
allowed_exts = {
str(ext).lower().lstrip('.')
for ext in supported_exts
if isinstance(ext, str)
}
def matches_extension(rel_path: str) -> bool:
if not allowed_exts:
return True
if '.' not in rel_path:
return '' in allowed_exts
ext = rel_path.rsplit('.', 1)[-1].lower()
return ext in allowed_exts or f'.{ext}' in allowed_exts
def coerce_result_bytes(result: Any) -> bytes:
if isinstance(result, Response):
result_bytes = result.body
else:
result_bytes = result
await write_file(save_to, result_bytes)
return {"saved_to": save_to}
return result.body
if isinstance(result, (bytes, bytearray)):
return bytes(result)
if isinstance(result, str):
return result.encode('utf-8')
raise HTTPException(500, detail="Processor must return bytes/Response when produces_file=True")
def build_absolute_path(mount_path: str, rel_path: str) -> str:
rel_norm = rel_path.lstrip('/')
mount_norm = mount_path.rstrip('/')
if not mount_norm:
return '/' + rel_norm if rel_norm else '/'
return f"{mount_norm}/{rel_norm}" if rel_norm else mount_norm
if actual_is_dir:
if save_to:
raise HTTPException(400, detail="Directory processing does not support custom save_to path")
if not overwrite:
raise HTTPException(400, detail="Directory processing requires overwrite")
adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(path)
rel = rel.rstrip('/')
list_dir = await _ensure_method(adapter_instance, "list_dir")
processed_count = 0
stack: List[str] = [rel]
page_size = 200
while stack:
current = stack.pop()
page = 1
while True:
entries, total = await list_dir(root, current, page, page_size, "name", "asc")
if not entries and (total or 0) == 0:
break
for entry in entries:
name = entry.get("name")
if not name:
continue
child_rel = f"{current}/{name}" if current else name
if entry.get("is_dir"):
stack.append(child_rel)
continue
if not matches_extension(child_rel):
continue
absolute_path = build_absolute_path(adapter_model.path, child_rel)
data = await read_file(absolute_path)
result = await processor.process(data, absolute_path, config)
if getattr(processor, "produces_file", False):
result_bytes = coerce_result_bytes(result)
await write_file(absolute_path, result_bytes)
processed_count += 1
if total is None or page * page_size >= total:
break
page += 1
return {"processed_files": processed_count}
# 单文件处理
data = await read_file(path)
result = await processor.process(data, path, config)
target_path = save_to
if overwrite and not target_path:
target_path = path
if target_path and getattr(processor, "produces_file", False):
result_bytes = coerce_result_bytes(result)
await write_file(target_path, result_bytes)
return {"saved_to": target_path}
return result

View File

@@ -15,7 +15,8 @@ export interface ProcessorTypeMeta {
name: string;
supported_exts: string[];
config_schema: ProcessorTypeField[];
produces_file:boolean;
produces_file: boolean;
module_path?: string | null;
}
export const processorsApi = {
@@ -29,11 +30,21 @@ export const processorsApi = {
save_to?: string;
overwrite?: boolean;
}) =>
request<any>('/processors/process', {
request<{ task_id: string }>('/processors/process', {
method: 'POST',
json: params,
}),
getSource: (type: string) =>
request<{ source: string; module_path: string }>('/processors/source/' + encodeURIComponent(type), {
method: 'GET',
}),
updateSource: (type: string, source: string) =>
request<boolean>('/processors/source/' + encodeURIComponent(type), {
method: 'PUT',
json: { source },
}),
reload: () =>
request<boolean>('/processors/reload', {
method: 'POST',
body: JSON.stringify(params),
headers: {
'Content-Type': 'application/json'
}
}),
};

View File

@@ -40,3 +40,29 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
.fx-grid-item .name { font-weight:600; font-size:13px; }
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
.processors-tabs {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 5px;
}
.processors-tabs .ant-tabs-content-holder,
.processors-tabs .ant-tabs-content {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.processors-tabs .ant-tabs-tabpane {
flex: 1;
height: 100%;
min-height: 0;
display: none;
flex-direction: column;
}
.processors-tabs .ant-tabs-tabpane-active {
display: flex;
}

View File

@@ -342,6 +342,35 @@ export const en = {
// Processor flow
'Processing finished': 'Processing finished',
'Processing failed': 'Processing failed',
'Processors': 'Processors',
'Processor List': 'Processor List',
'Reload': 'Reload',
'Run Processor': 'Run Processor',
'Target Path': 'Target Path',
'Please select a path': 'Please select a path',
'Select Directory': 'Select Directory',
'Overwrite original': 'Overwrite original',
'Save To': 'Save To',
'Optional output path': 'Optional output path',
'Run': 'Run',
'Select a processor': 'Select a processor',
'No module path': 'No module path',
'Source saved': 'Source saved',
'Processors reloaded': 'Processors reloaded',
'Unsaved changes': 'Unsaved changes',
'Switching processor will discard unsaved changes. Continue?': 'Switching processor will discard unsaved changes. Continue?',
'Task submitted': 'Task submitted',
'Supported Extensions': 'Supported Extensions',
'All': 'All',
'Produces File': 'Produces File',
'Yes': 'Yes',
'No': 'No',
'Please select a processor': 'Please select a processor',
'Select a path': 'Select a path',
'Source Editor': 'Source Editor',
'Module Path': 'Module Path',
'Directory processing always overwrites original files': 'Directory processing always overwrites original files',
'No data': 'No data',
// Path selector
'Select File': 'Select File',

View File

@@ -344,6 +344,35 @@ export const zh = {
// Processor flow
'Processing finished': '处理完成',
'Processing failed': '处理失败',
'Processors': '处理器',
'Processor List': '处理器列表',
'Reload': '重载',
'Run Processor': '运行处理器',
'Target Path': '目标路径',
'Please select a path': '请选择路径',
'Select Directory': '选择目录',
'Overwrite original': '覆盖原文件',
'Save To': '保存到',
'Optional output path': '可选输出路径',
'Run': '运行',
'Select a processor': '选择处理器',
'No module path': '未检测到模块路径',
'Source saved': '源码已保存',
'Processors reloaded': '处理器已重载',
'Unsaved changes': '存在未保存的修改',
'Switching processor will discard unsaved changes. Continue?': '切换处理器会丢失未保存的修改,确认继续?',
'Task submitted': '任务已提交',
'Supported Extensions': '支持的扩展名',
'All': '全部',
'Produces File': '生成文件',
'Yes': '是',
'No': '否',
'Please select a processor': '请选择处理器',
'Select a path': '请选择路径',
'Source Editor': '源码编辑',
'Module Path': '模块路径',
'Directory processing always overwrites original files': '选择目录时会强制覆盖原文件',
'No data': '暂无数据',
// Path selector
'Select File': '选择文件',

View File

@@ -9,6 +9,7 @@ import {
BugOutlined,
DatabaseOutlined,
AppstoreOutlined,
CodeOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
@@ -27,6 +28,7 @@ export const navGroups: NavGroup[] = [
key: 'manage',
title: 'Manage',
children: [
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },

View File

@@ -49,8 +49,8 @@ export function useProcessor({ path, processorTypes, refresh }: ProcessorParams)
overwrite: overwrite ? true : undefined,
};
await processorsApi.process(params);
message.success(t('Processing finished'));
const resp = await processorsApi.process(params);
message.success(`${t('Task submitted')}: ${resp.task_id}`);
setModal({ entry: null, visible: false });
if (overwrite || savingPath) refresh();
} catch (e: any) {

View File

@@ -0,0 +1,482 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Card,
Empty,
Flex,
Form,
Input,
message,
Modal,
Space,
Spin,
Switch,
Tabs,
Tag,
Typography,
theme,
} from 'antd';
import Editor from '@monaco-editor/react';
import PageCard from '../components/PageCard';
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
import PathSelectorModal, { type PathSelectorMode } from '../components/PathSelectorModal';
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
import { useI18n } from '../i18n';
const { Text } = Typography;
type TabKey = 'editor' | 'runner';
const ProcessorsPage = memo(function ProcessorsPage() {
const { t } = useI18n();
const { token } = theme.useToken();
const [processors, setProcessors] = useState<ProcessorTypeMeta[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [selectedType, setSelectedType] = useState<string>('');
const [source, setSource] = useState('');
const [initialSource, setInitialSource] = useState('');
const [modulePath, setModulePath] = useState('');
const [sourceLoading, setSourceLoading] = useState(false);
const [savingSource, setSavingSource] = useState(false);
const [reloading, setReloading] = useState(false);
const [form] = Form.useForm();
const [running, setRunning] = useState(false);
const [isDirectory, setIsDirectory] = useState(false);
const [pathModalOpen, setPathModalOpen] = useState(false);
const [pathModalMode, setPathModalMode] = useState<PathSelectorMode>('file');
const [pathModalField, setPathModalField] = useState<'path' | 'save_to'>('path');
const [activeTab, setActiveTab] = useState<TabKey>('editor');
const isDirty = source !== initialSource;
const selectedProcessorMeta = useMemo(
() => processors.find(p => p.type === selectedType),
[processors, selectedType]
);
const loadList = useCallback(async () => {
setLoadingList(true);
try {
const list = await processorsApi.list();
setProcessors(list);
} catch (err: any) {
message.error(err?.message || t('Load failed'));
} finally {
setLoadingList(false);
}
}, [t]);
useEffect(() => {
loadList();
}, [loadList]);
useEffect(() => {
if (!processors.length) {
setSelectedType('');
return;
}
if (!selectedType) {
setSelectedType(processors[0].type);
} else if (!processors.some(p => p.type === selectedType)) {
setSelectedType(processors[0].type);
}
}, [processors, selectedType]);
useEffect(() => {
if (!selectedType) {
setSource('');
setInitialSource('');
setModulePath('');
return;
}
const controller = new AbortController();
setSource('');
setInitialSource('');
setModulePath('');
setSourceLoading(true);
processorsApi.getSource(selectedType)
.then(resp => {
if (controller.signal.aborted) return;
setSource(resp.source ?? '');
setInitialSource(resp.source ?? '');
setModulePath(resp.module_path ?? '');
})
.catch((err: any) => {
if (controller.signal.aborted) return;
message.error(err?.message || t('Load failed'));
setSource('');
setInitialSource('');
setModulePath('');
})
.finally(() => {
if (!controller.signal.aborted) {
setSourceLoading(false);
}
});
return () => controller.abort();
}, [selectedType, t]);
useEffect(() => {
form.resetFields();
const defaults: Record<string, any> = {};
selectedProcessorMeta?.config_schema?.forEach(field => {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
});
form.setFieldsValue({
path: '',
overwrite: !!selectedProcessorMeta?.produces_file,
save_to: undefined,
config: defaults,
});
setIsDirectory(false);
}, [selectedProcessorMeta, form]);
const overwriteValue = Form.useWatch('overwrite', form) ?? false;
useEffect(() => {
if (overwriteValue) {
form.setFieldsValue({ save_to: undefined });
}
}, [overwriteValue, form]);
useEffect(() => {
if (isDirectory) {
form.setFieldsValue({ overwrite: true, save_to: undefined });
}
}, [isDirectory, form]);
const handleSelectProcessor = useCallback((type: string) => {
if (type === selectedType) return;
if (isDirty) {
Modal.confirm({
title: t('Unsaved changes'),
content: t('Switching processor will discard unsaved changes. Continue?'),
okText: t('Confirm'),
cancelText: t('Cancel'),
onOk: () => {
setSelectedType(type);
setActiveTab('editor');
},
});
} else {
setSelectedType(type);
setActiveTab('editor');
}
}, [isDirty, selectedType, t]);
const handleSaveSource = useCallback(async () => {
if (!selectedType) return;
try {
setSavingSource(true);
await processorsApi.updateSource(selectedType, source);
setInitialSource(source);
message.success(t('Source saved'));
} catch (err: any) {
message.error(err?.message || t('Operation failed'));
} finally {
setSavingSource(false);
}
}, [selectedType, source, t]);
const handleReloadProcessors = useCallback(async () => {
try {
setReloading(true);
await processorsApi.reload();
message.success(t('Processors reloaded'));
await loadList();
} catch (err: any) {
message.error(err?.message || t('Operation failed'));
} finally {
setReloading(false);
}
}, [loadList, t]);
const openPathSelector = useCallback((field: 'path' | 'save_to', mode: PathSelectorMode) => {
setPathModalField(field);
setPathModalMode(mode);
setPathModalOpen(true);
}, []);
const handlePathSelected = useCallback((selectedPath: string) => {
if (pathModalField === 'path') {
form.setFieldsValue({ path: selectedPath });
setIsDirectory(pathModalMode === 'directory');
} else {
form.setFieldsValue({ save_to: selectedPath });
}
setPathModalOpen(false);
}, [form, pathModalField, pathModalMode]);
const handleRun = useCallback(async () => {
if (!selectedType) {
message.warning(t('Please select a processor'));
return;
}
try {
const values = await form.validateFields();
const schema = selectedProcessorMeta?.config_schema || [];
const finalConfig: Record<string, any> = {};
schema.forEach(field => {
const value = values.config?.[field.key];
if (value === undefined) {
finalConfig[field.key] = field.default;
} else {
finalConfig[field.key] = value;
}
});
setRunning(true);
const payload: any = {
path: values.path,
processor_type: selectedType,
config: finalConfig,
overwrite: !!values.overwrite,
};
if (values.save_to && !values.overwrite) {
payload.save_to = values.save_to;
}
const resp = await processorsApi.process(payload);
message.success(`${t('Task submitted')}: ${resp.task_id}`);
} catch (err: any) {
if (err?.errorFields) {
return;
}
message.error(err?.message || t('Operation failed'));
} finally {
setRunning(false);
}
}, [form, selectedProcessorMeta, selectedType, t]);
const selectedConfigPath = pathModalField === 'path'
? form.getFieldValue('path') || '/'
: form.getFieldValue('save_to') || '/';
const renderProcessorList = () => {
if (loadingList) {
return (
<Flex align="center" justify="center" style={{ height: '100%' }}>
<Spin />
</Flex>
);
}
if (!processors.length) {
return (
<Flex align="center" justify="center" style={{ height: '100%' }}>
<Empty description={t('No data')} />
</Flex>
);
}
return (
<div style={{ padding: 8, overflowY: 'auto', height: '100%' }}>
{processors.map(item => {
const selected = item.type === selectedType;
const onClick = () => handleSelectProcessor(item.type);
return (
<div
key={item.type}
onClick={onClick}
style={{
border: `1px solid ${selected ? token.colorPrimary : token.colorBorderSecondary}`,
background: selected ? token.colorPrimaryBg : token.colorBgContainer,
borderRadius: 10,
padding: 12,
marginBottom: 8,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
>
<Flex justify="space-between" align="center">
<Space size={8} align="center">
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: selected ? token.colorPrimary : token.colorBorderSecondary,
display: 'inline-block',
}}
/>
<Text strong>{item.name}</Text>
</Space>
<Tag color={selected ? token.colorPrimary : token.colorBorderSecondary}>{item.type}</Tag>
</Flex>
<Space direction="vertical" size={6} style={{ marginTop: 8 }}>
<div>
<Text type="secondary" style={{ marginRight: 8 }}>{t('Supported Extensions')}:</Text>
{item.supported_exts?.length ? (
<Space wrap size={[4, 4]}>
{item.supported_exts.map(ext => (
<Tag key={ext}>{ext}</Tag>
))}
</Space>
) : (
<Tag>{t('All')}</Tag>
)}
</div>
<Text type="secondary">
{t('Produces File')}: {item.produces_file ? t('Yes') : t('No')}
</Text>
</Space>
</div>
);
})}
</div>
);
};
const tabs = [
{
key: 'editor',
label: t('Source Editor'),
children: selectedType ? (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
{modulePath ? (
<Space size={8}>
<Text type="secondary">{t('Module Path')}:</Text>
<Text code>{modulePath}</Text>
</Space>
) : (
<Text type="secondary">{t('No module path')}</Text>
)}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
{sourceLoading ? (
<Flex align="center" justify="center" style={{ height: '100%' }}>
<Spin />
</Flex>
) : (
<Editor
language="python"
value={source}
onChange={(val) => setSource(val ?? '')}
height="100%"
options={{
automaticLayout: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
}}
/>
)}
</div>
</div>
) : (
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
),
},
{
key: 'runner',
label: t('Run Processor'),
children: selectedType ? (
<Form form={form} layout="vertical" disabled={!selectedType} style={{ padding: '12px 0' }}>
{isDirectory && (
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
{t('Directory processing always overwrites original files')}
</Text>
)}
<Form.Item
name="path"
label={t('Target Path')}
rules={[{ required: true, message: t('Please select a path') }]}
>
<Flex gap={8} align="center">
<Input placeholder={t('Select a path')} style={{ flex: 1 }} />
<Button onClick={() => openPathSelector('path', 'file')}>{t('Select File')}</Button>
<Button onClick={() => openPathSelector('path', 'directory')}>{t('Select Directory')}</Button>
</Flex>
</Form.Item>
<Form.Item
name="overwrite"
label={t('Overwrite original')}
valuePropName="checked"
>
<Switch disabled={isDirectory} />
</Form.Item>
{selectedProcessorMeta?.produces_file && !overwriteValue && (
<Form.Item
name="save_to"
label={t('Save To')}
>
<Flex gap={8} align="center">
<Input placeholder={t('Optional output path')} style={{ flex: 1 }} />
<Button onClick={() => openPathSelector('save_to', 'any')}>{t('Select')}</Button>
</Flex>
</Form.Item>
)}
<ProcessorConfigForm
processorMeta={selectedProcessorMeta}
form={form}
configPath={['config']}
/>
<Form.Item>
<Button type="primary" onClick={handleRun} loading={running} disabled={!selectedType}>
{t('Run')}
</Button>
</Form.Item>
</Form>
) : (
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
),
},
];
return (
<PageCard title={t('Processors')}>
<Flex gap={16} style={{ height: '100%' }}>
<Card
style={{ flex: '0 0 320px', minWidth: 280, display: 'flex', flexDirection: 'column' }}
title={t('Processor List')}
extra={
<Space size={8}>
<Button size="small" onClick={loadList} loading={loadingList}>{t('Refresh')}</Button>
<Button size="small" onClick={handleReloadProcessors} loading={reloading}>{t('Reload')}</Button>
</Space>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex' } }}
>
{renderProcessorList()}
</Card>
<Card
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}
title={selectedProcessorMeta ? `${selectedProcessorMeta.name} (${selectedProcessorMeta.type})` : t('Select a processor')}
extra={
<Space size={8}>
<Button size="small" onClick={handleSaveSource} loading={savingSource} disabled={!selectedType || !isDirty}>
{t('Save')}
</Button>
<Button size="small" onClick={handleReloadProcessors} loading={reloading} disabled={!selectedType}>
{t('Reload')}
</Button>
</Space>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
>
<Tabs
activeKey={activeTab}
onChange={key => setActiveTab(key as TabKey)}
items={tabs as any}
className="processors-tabs"
tabBarGutter={32}
/>
</Card>
</Flex>
<PathSelectorModal
open={pathModalOpen}
mode={pathModalMode}
initialPath={selectedConfigPath}
onOk={handlePathSelected}
onCancel={() => setPathModalOpen(false)}
/>
</PageCard>
);
});
export default ProcessorsPage;

View File

@@ -7,6 +7,7 @@ import FileExplorerPage from '../pages/FileExplorerPage/FileExplorerPage.tsx';
import AdaptersPage from '../pages/AdaptersPage.tsx';
import SharePage from '../pages/SharePage.tsx';
import TasksPage from '../pages/TasksPage.tsx';
import ProcessorsPage from '../pages/ProcessorsPage.tsx';
import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
import LogsPage from '../pages/LogsPage.tsx';
@@ -37,6 +38,7 @@ const ShellBody = memo(function ShellBody() {
{navKey === 'files' && <FileExplorerPage />}
{navKey === 'share' && <SharePage />}
{navKey === 'tasks' && <TasksPage />}
{navKey === 'processors' && <ProcessorsPage />}
{navKey === 'offline' && <OfflineDownloadPage />}
{navKey === 'plugins' && <PluginsPage />}
{navKey === 'settings' && <SystemSettingsPage />}