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 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.task_queue import task_queue_service
from services.auth import get_current_active_user, User from services.auth import get_current_active_user, User
from api.response import success from api.response import success
from pydantic import BaseModel from pydantic import BaseModel
from services.virtual_fs import path_is_directory
router = APIRouter(prefix="/api/processors", tags=["processors"]) router = APIRouter(prefix="/api/processors", tags=["processors"])
@@ -22,6 +29,7 @@ async def list_processors(
"supported_exts": meta.get("supported_exts", []), "supported_exts": meta.get("supported_exts", []),
"config_schema": meta["config_schema"], "config_schema": meta["config_schema"],
"produces_file": meta.get("produces_file", False), "produces_file": meta.get("produces_file", False),
"module_path": meta.get("module_path"),
}) })
return success(out) return success(out)
@@ -34,12 +42,20 @@ class ProcessRequest(BaseModel):
overwrite: bool = False overwrite: bool = False
class UpdateSourceRequest(BaseModel):
source: str
@router.post("/process") @router.post("/process")
async def process_file_with_processor( async def process_file_with_processor(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
req: ProcessRequest = Body(...) 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( task = await task_queue_service.add_task(
"process_file", "process_file",
{ {
@@ -47,6 +63,54 @@ async def process_file_with_processor(
"processor_type": req.processor_type, "processor_type": req.processor_type,
"config": req.config, "config": req.config,
"save_to": save_to, "save_to": save_to,
"overwrite": req.overwrite,
}, },
) )
return success({"task_id": task.id}) 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 import inspect
from importlib import import_module import pkgutil
from typing import Dict, Callable from importlib import import_module, reload
from pathlib import Path
from types import ModuleType
from typing import Callable, Dict, Optional
from .base import BaseProcessor from .base import BaseProcessor
ProcessorFactory = Callable[[], BaseProcessor] ProcessorFactory = Callable[[], BaseProcessor]
TYPE_MAP: Dict[str, ProcessorFactory] = {} TYPE_MAP: Dict[str, ProcessorFactory] = {}
CONFIG_SCHEMAS: Dict[str, dict] = {} 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 processors_pkg = services.processors
TYPE_MAP.clear() TYPE_MAP.clear()
CONFIG_SCHEMAS.clear() CONFIG_SCHEMAS.clear()
MODULE_MAP.clear()
global LAST_DISCOVERY_ERRORS
LAST_DISCOVERY_ERRORS = []
for modinfo in pkgutil.iter_modules(processors_pkg.__path__): for modinfo in pkgutil.iter_modules(processors_pkg.__path__):
if modinfo.name.startswith("_"): if modinfo.name.startswith("_"):
continue continue
full_name = f"{processors_pkg.__name__}.{modinfo.name}" full_name = f"{processors_pkg.__name__}.{modinfo.name}"
try: try:
module = import_module(full_name) 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 continue
processor_type = getattr(module, "PROCESSOR_TYPE", None) processor_type = getattr(module, "PROCESSOR_TYPE", None)
processor_name = getattr(module, "PROCESSOR_NAME", None) processor_name = getattr(module, "PROCESSOR_NAME", None)
supported_exts = getattr(module, "SUPPORTED_EXTS", None) supported_exts = getattr(module, "SUPPORTED_EXTS", None)
schema = getattr(module, "CONFIG_SCHEMA", None) schema = getattr(module, "CONFIG_SCHEMA", None)
factory = getattr(module, "PROCESSOR_FACTORY", None) factory = getattr(module, "PROCESSOR_FACTORY", None)
if not processor_type: if not processor_type:
continue continue
if factory is None: if factory is None:
for attr in module.__dict__.values(): for attr in module.__dict__.values():
if inspect.isclass(attr) and attr.__name__.endswith("Processor"): if inspect.isclass(attr) and attr.__name__.endswith("Processor"):
@@ -35,31 +55,85 @@ def discover_processors():
return lambda: cls() return lambda: cls()
factory = _mk() factory = _mk()
break break
if not callable(factory): if not callable(factory):
LAST_DISCOVERY_ERRORS.append(f"Processor {full_name} missing factory")
continue 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 TYPE_MAP[processor_type] = factory
MODULE_MAP[processor_type] = module
produces_file = getattr(module, "produces_file", None) produces_file = getattr(module, "produces_file", None)
if produces_file is None and hasattr(factory(), "produces_file"): if produces_file is None and hasattr(sample, "produces_file"):
produces_file = getattr(factory(), "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): if isinstance(schema, list):
CONFIG_SCHEMAS[processor_type] = { CONFIG_SCHEMAS[processor_type] = {
"type": processor_type, "type": processor_type,
"name": processor_name or processor_type, "name": processor_name or processor_type,
"supported_exts": supported_exts or [], "supported_exts": normalized_exts,
"config_schema": schema, "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]: def get_config_schemas() -> Dict[str, dict]:
return CONFIG_SCHEMAS return CONFIG_SCHEMAS
def get_config_schema(processor_type: str): def get_config_schema(processor_type: str):
return CONFIG_SCHEMAS.get(processor_type) return CONFIG_SCHEMAS.get(processor_type)
def get(processor_type: str) -> BaseProcessor: def get(processor_type: str) -> BaseProcessor:
factory = TYPE_MAP.get(processor_type) factory = TYPE_MAP.get(processor_type)
if factory: if factory:
return factory() return factory()
return None 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() discover_processors()

View File

@@ -54,7 +54,8 @@ class TaskQueueService:
path=params["path"], path=params["path"],
processor_type=params["processor_type"], processor_type=params["processor_type"],
config=params["config"], config=params["config"],
save_to=params["save_to"] save_to=params.get("save_to"),
overwrite=params.get("overwrite", False),
) )
task.result = result task.result = result
elif task.name == "automation_task": elif task.name == "automation_task":
@@ -119,4 +120,4 @@ class TaskQueueService:
await LogService.info("task_queue", "Task worker has been stopped.") 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 from fastapi import HTTPException
import mimetypes import mimetypes
from fastapi.responses import Response from fastapi.responses import Response
@@ -59,6 +59,24 @@ async def _ensure_method(adapter: Any, method: str):
return func 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: 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 '/' norm = (path if path.startswith('/') else '/' + path).rstrip('/') or '/'
adapters = await StorageAdapter.filter(enabled=True) 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 return debug_info if return_debug else None
async def process_file(path: str, processor_type: str, config: dict, save_to: str = None): async def process_file(
""" path: str,
使用指定处理器处理文件,并可选择保存到新路径 processor_type: str,
:param path: 源文件路径 config: dict,
:param processor_type: 处理器类型 save_to: str | None = None,
:param config: 处理器配置 overwrite: bool = False,
:param save_to: 保存路径(可选),不指定则只返回处理结果 ) -> Any:
:return: 处理后的文件内容或保存结果 """处理指定路径(文件或目录)。目录会递归处理其下所有文件。"""
"""
data = await read_file(path)
processor = get_processor(processor_type) processor = get_processor(processor_type)
if not processor: if not processor:
raise HTTPException( raise HTTPException(400, detail=f"Processor {processor_type} not found")
400, detail=f"Processor {processor_type} not found")
result = await processor.process(data, path, config) actual_is_dir = await path_is_directory(path)
if save_to and getattr(processor, "produces_file", False):
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): if isinstance(result, Response):
result_bytes = result.body return result.body
else: if isinstance(result, (bytes, bytearray)):
result_bytes = result return bytes(result)
await write_file(save_to, result_bytes) if isinstance(result, str):
return {"saved_to": save_to} 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 return result

View File

@@ -15,7 +15,8 @@ export interface ProcessorTypeMeta {
name: string; name: string;
supported_exts: string[]; supported_exts: string[];
config_schema: ProcessorTypeField[]; config_schema: ProcessorTypeField[];
produces_file:boolean; produces_file: boolean;
module_path?: string | null;
} }
export const processorsApi = { export const processorsApi = {
@@ -29,11 +30,21 @@ export const processorsApi = {
save_to?: string; save_to?: string;
overwrite?: boolean; 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', 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 .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; } .fx-grid-item .name { font-weight:600; font-size:13px; }
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } .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 // Processor flow
'Processing finished': 'Processing finished', 'Processing finished': 'Processing finished',
'Processing failed': 'Processing failed', '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 // Path selector
'Select File': 'Select File', 'Select File': 'Select File',

View File

@@ -344,6 +344,35 @@ export const zh = {
// Processor flow // Processor flow
'Processing finished': '处理完成', 'Processing finished': '处理完成',
'Processing failed': '处理失败', '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 // Path selector
'Select File': '选择文件', 'Select File': '选择文件',

View File

@@ -9,6 +9,7 @@ import {
BugOutlined, BugOutlined,
DatabaseOutlined, DatabaseOutlined,
AppstoreOutlined, AppstoreOutlined,
CodeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -27,6 +28,7 @@ export const navGroups: NavGroup[] = [
key: 'manage', key: 'manage',
title: 'Manage', title: 'Manage',
children: [ children: [
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' }, { key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' }, { key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' }, { 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, overwrite: overwrite ? true : undefined,
}; };
await processorsApi.process(params); const resp = await processorsApi.process(params);
message.success(t('Processing finished')); message.success(`${t('Task submitted')}: ${resp.task_id}`);
setModal({ entry: null, visible: false }); setModal({ entry: null, visible: false });
if (overwrite || savingPath) refresh(); if (overwrite || savingPath) refresh();
} catch (e: any) { } 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 AdaptersPage from '../pages/AdaptersPage.tsx';
import SharePage from '../pages/SharePage.tsx'; import SharePage from '../pages/SharePage.tsx';
import TasksPage from '../pages/TasksPage.tsx'; import TasksPage from '../pages/TasksPage.tsx';
import ProcessorsPage from '../pages/ProcessorsPage.tsx';
import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx'; import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx'; import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
import LogsPage from '../pages/LogsPage.tsx'; import LogsPage from '../pages/LogsPage.tsx';
@@ -37,6 +38,7 @@ const ShellBody = memo(function ShellBody() {
{navKey === 'files' && <FileExplorerPage />} {navKey === 'files' && <FileExplorerPage />}
{navKey === 'share' && <SharePage />} {navKey === 'share' && <SharePage />}
{navKey === 'tasks' && <TasksPage />} {navKey === 'tasks' && <TasksPage />}
{navKey === 'processors' && <ProcessorsPage />}
{navKey === 'offline' && <OfflineDownloadPage />} {navKey === 'offline' && <OfflineDownloadPage />}
{navKey === 'plugins' && <PluginsPage />} {navKey === 'plugins' && <PluginsPage />}
{navKey === 'settings' && <SystemSettingsPage />} {navKey === 'settings' && <SystemSettingsPage />}