Repository: ConardLi/easy-dataset Branch: main Commit: 75bfca751400 Files: 516 Total size: 2.7 MB Directory structure: gitextract_cydaglf7/ ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature-or-enhancement-.md │ │ └── question.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ └── docker-build.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .npmrc ├── .prettierrc.js ├── .windsurfrules ├── AGENTS.md ├── ARCHITECTURE.md ├── Dockerfile ├── LICENSE ├── README.md ├── README.tr.md ├── README.zh-CN.md ├── app/ │ ├── api/ │ │ ├── check-update/ │ │ │ └── route.js │ │ ├── llm/ │ │ │ ├── fetch-models/ │ │ │ │ └── route.js │ │ │ ├── model/ │ │ │ │ └── route.js │ │ │ ├── ollama/ │ │ │ │ └── models/ │ │ │ │ └── route.js │ │ │ └── providers/ │ │ │ └── route.js │ │ ├── monitoring/ │ │ │ ├── logs/ │ │ │ │ └── route.js │ │ │ ├── stats/ │ │ │ │ └── route.js │ │ │ └── summary/ │ │ │ └── route.js │ │ ├── projects/ │ │ │ ├── [projectId]/ │ │ │ │ ├── batch-add-manual-ga/ │ │ │ │ │ └── route.js │ │ │ │ ├── batch-delete-files/ │ │ │ │ │ └── route.js │ │ │ │ ├── batch-generateGA/ │ │ │ │ │ └── route.js │ │ │ │ ├── blind-test-tasks/ │ │ │ │ │ ├── [taskId]/ │ │ │ │ │ │ ├── current/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── question/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── route.js │ │ │ │ │ │ ├── stream/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── stream-model/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ └── vote/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── chunks/ │ │ │ │ │ ├── [chunkId]/ │ │ │ │ │ │ ├── clean/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── eval-questions/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── questions/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── batch-content/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── batch-edit/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── name/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── config/ │ │ │ │ │ └── route.js │ │ │ │ ├── custom-prompts/ │ │ │ │ │ └── route.js │ │ │ │ ├── custom-split/ │ │ │ │ │ └── route.js │ │ │ │ ├── dataset-conversations/ │ │ │ │ │ ├── [conversationId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── tags/ │ │ │ │ │ └── route.js │ │ │ │ ├── datasets/ │ │ │ │ │ ├── [datasetId]/ │ │ │ │ │ │ ├── copy-to-eval/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── evaluate/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ ├── route.js │ │ │ │ │ │ └── token-count/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── batch-evaluate/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── generate-eval-variant/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── import/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── optimize/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── tags/ │ │ │ │ │ └── route.js │ │ │ │ ├── default-prompts/ │ │ │ │ │ └── route.js │ │ │ │ ├── distill/ │ │ │ │ │ ├── questions/ │ │ │ │ │ │ ├── by-tag/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ └── route.js │ │ │ │ │ └── tags/ │ │ │ │ │ ├── [tagId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── all/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── eval-datasets/ │ │ │ │ │ ├── [evalId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── import/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── route.js │ │ │ │ │ ├── sample/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── tags/ │ │ │ │ │ └── route.js │ │ │ │ ├── eval-tasks/ │ │ │ │ │ ├── [taskId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── files/ │ │ │ │ │ ├── [fileId]/ │ │ │ │ │ │ └── ga-pairs/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── generate-questions/ │ │ │ │ │ └── route.js │ │ │ │ ├── huggingface/ │ │ │ │ │ └── upload/ │ │ │ │ │ └── route.js │ │ │ │ ├── image-datasets/ │ │ │ │ │ ├── [datasetId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── export-zip/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── tags/ │ │ │ │ │ └── route.js │ │ │ │ ├── images/ │ │ │ │ │ ├── [imageId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── annotations/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── datasets/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── next-unanswered/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── pdf-convert/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── questions/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── zip-import/ │ │ │ │ │ └── route.js │ │ │ │ ├── llamaFactory/ │ │ │ │ │ ├── checkConfig/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── generate/ │ │ │ │ │ └── route.js │ │ │ │ ├── model-config/ │ │ │ │ │ ├── [modelConfigId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── models/ │ │ │ │ │ ├── [modelId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ └── route.js │ │ │ │ ├── playground/ │ │ │ │ │ └── chat/ │ │ │ │ │ ├── route.js │ │ │ │ │ └── stream/ │ │ │ │ │ └── route.js │ │ │ │ ├── preview/ │ │ │ │ │ └── [fileId]/ │ │ │ │ │ └── route.js │ │ │ │ ├── questions/ │ │ │ │ │ ├── [questionId]/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── batch-delete/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.js │ │ │ │ │ ├── route.js │ │ │ │ │ ├── templates/ │ │ │ │ │ │ ├── [templateId]/ │ │ │ │ │ │ │ └── route.js │ │ │ │ │ │ └── route.js │ │ │ │ │ └── tree/ │ │ │ │ │ └── route.js │ │ │ │ ├── route.js │ │ │ │ ├── split/ │ │ │ │ │ └── route.js │ │ │ │ ├── tags/ │ │ │ │ │ └── route.js │ │ │ │ └── tasks/ │ │ │ │ ├── [taskId]/ │ │ │ │ │ └── route.js │ │ │ │ ├── list/ │ │ │ │ │ └── route.js │ │ │ │ └── route.js │ │ │ ├── delete-directory/ │ │ │ │ └── route.js │ │ │ ├── migrate/ │ │ │ │ └── route.js │ │ │ ├── open-directory/ │ │ │ │ └── route.js │ │ │ ├── route.js │ │ │ └── unmigrated/ │ │ │ └── route.js │ │ └── update/ │ │ └── route.js │ ├── dataset-square/ │ │ └── page.js │ ├── globals.css │ ├── layout.js │ ├── monitoring/ │ │ ├── components/ │ │ │ ├── Charts.js │ │ │ ├── StatsCards.js │ │ │ └── UsageTable.js │ │ ├── hooks/ │ │ │ └── useMonitoringData.js │ │ └── page.js │ ├── page.js │ └── projects/ │ └── [projectId]/ │ ├── blind-test-tasks/ │ │ ├── [taskId]/ │ │ │ └── page.js │ │ ├── components/ │ │ │ ├── BlindTestHeader.js │ │ │ ├── BlindTestInProgress.js │ │ │ ├── BlindTestTaskCard.js │ │ │ ├── CreateBlindTestDialog.js │ │ │ ├── ResultDetailList.js │ │ │ └── ResultSummary.js │ │ ├── hooks/ │ │ │ ├── useBlindTestDetail.js │ │ │ └── useBlindTestTasks.js │ │ └── page.js │ ├── datasets/ │ │ ├── [datasetId]/ │ │ │ ├── page.js │ │ │ └── useDatasetDetails.js │ │ ├── components/ │ │ │ ├── ActionBar.js │ │ │ ├── DatasetList.js │ │ │ ├── DeleteConfirmDialog.js │ │ │ ├── FilterDialog.js │ │ │ └── SearchBar.js │ │ ├── hooks/ │ │ │ ├── useDatasetEvaluation.js │ │ │ ├── useDatasetExport.js │ │ │ └── useDatasetFilters.js │ │ └── page.js │ ├── distill/ │ │ ├── autoDistillService.js │ │ └── page.js │ ├── eval-datasets/ │ │ ├── [evalId]/ │ │ │ ├── page.js │ │ │ └── useEvalDatasetDetails.js │ │ ├── components/ │ │ │ ├── BuiltinDatasetDialog.js │ │ │ ├── EvalDatasetCard.js │ │ │ ├── EvalDatasetHeader.js │ │ │ ├── EvalDatasetList.js │ │ │ ├── EvalEditableField.js │ │ │ ├── EvalToolbar.js │ │ │ ├── EvalToolbar.styles.js │ │ │ ├── ExportEvalDialog.js │ │ │ ├── ImportDialog.js │ │ │ └── ImportDialog.styles.js │ │ ├── constants.js │ │ ├── hooks/ │ │ │ ├── useEvalDatasets.js │ │ │ └── useExportEvalDatasets.js │ │ └── page.js │ ├── eval-tasks/ │ │ ├── [taskId]/ │ │ │ ├── components/ │ │ │ │ ├── EvalHeader.js │ │ │ │ ├── EvalStats.js │ │ │ │ └── QuestionCard.js │ │ │ ├── detailStyles.js │ │ │ └── page.js │ │ ├── components/ │ │ │ ├── CreateEvalTaskDialog.js │ │ │ ├── EvalTaskCard.js │ │ │ ├── ModelSelector.js │ │ │ ├── QuestionFilter.js │ │ │ └── ScoreAnchorsForm.js │ │ ├── hooks/ │ │ │ ├── useEvalTaskDetail.js │ │ │ ├── useEvalTaskForm.js │ │ │ └── useEvalTasks.js │ │ ├── page.js │ │ └── styles.js │ ├── image-datasets/ │ │ ├── [datasetId]/ │ │ │ └── page.js │ │ ├── components/ │ │ │ ├── DatasetContent.js │ │ │ ├── DatasetSidebar.js │ │ │ ├── EmptyState.js │ │ │ ├── ExportImageDatasetDialog.js │ │ │ ├── ImageDatasetCard.js │ │ │ ├── ImageDatasetFilterDialog.js │ │ │ ├── ImageDatasetFilters.js │ │ │ ├── ImageDatasetHeader.js │ │ │ ├── MetadataEditor.js │ │ │ └── MetadataInfo.js │ │ ├── hooks/ │ │ │ ├── useImageDatasetDetail.js │ │ │ ├── useImageDatasetDetails.js │ │ │ ├── useImageDatasetExport.js │ │ │ ├── useImageDatasetFilters.js │ │ │ └── useImageDatasets.js │ │ ├── page.js │ │ └── styles/ │ │ └── imageDatasetStyles.js │ ├── images/ │ │ ├── components/ │ │ │ ├── DatasetDialog.js │ │ │ ├── ImageFilters.js │ │ │ ├── ImageGrid.js │ │ │ ├── ImageList.js │ │ │ ├── ImportDialog.js │ │ │ ├── QuestionDialog.js │ │ │ └── annotation/ │ │ │ ├── AIGenerateButton.js │ │ │ ├── AnnotationDialog.js │ │ │ ├── AnswerInput.js │ │ │ └── QuestionSelector.js │ │ ├── hooks/ │ │ │ └── useAnnotation.js │ │ ├── page.js │ │ └── styles/ │ │ └── imageStyles.js │ ├── layout.js │ ├── multi-turn/ │ │ ├── [conversationId]/ │ │ │ ├── page.js │ │ │ └── useConversationDetails.js │ │ ├── components/ │ │ │ ├── ConversationTable.js │ │ │ ├── FilterDialog.js │ │ │ ├── RatingChip.js │ │ │ └── SearchBar.js │ │ ├── hooks/ │ │ │ └── useMultiTurnData.js │ │ └── page.js │ ├── page.js │ ├── playground/ │ │ └── page.js │ ├── questions/ │ │ ├── components/ │ │ │ ├── ConfirmDialog.js │ │ │ ├── ExportQuestionsDialog.js │ │ │ ├── QuestionEditDialog.js │ │ │ ├── QuestionsFilter.js │ │ │ ├── QuestionsPageHeader.js │ │ │ ├── TemplateListView.js │ │ │ └── template/ │ │ │ ├── TemplateFormDialog.js │ │ │ └── TemplateManagementDialog.js │ │ ├── hooks/ │ │ │ ├── useQuestionDelete.js │ │ │ ├── useQuestionEdit.js │ │ │ ├── useQuestionExport.js │ │ │ ├── useQuestionGeneration.js │ │ │ ├── useQuestionTemplates.js │ │ │ └── useQuestionsFilter.js │ │ └── page.js │ ├── settings/ │ │ ├── components/ │ │ │ ├── CategoryTabs.js │ │ │ ├── PromptDetail.js │ │ │ ├── PromptEditDialog.js │ │ │ ├── PromptList.js │ │ │ ├── PromptSettings.js │ │ │ └── promptUtils.js │ │ └── page.js │ ├── tasks/ │ │ └── page.js │ └── text-split/ │ ├── page.js │ ├── useChunks.js │ ├── useDataCleaning.js │ ├── useEvalGeneration.js │ ├── useFileProcessing.js │ └── useQuestionGeneration.js ├── commitlint.config.mjs ├── components/ │ ├── ExportDatasetDialog.js │ ├── ExportProgressDialog.js │ ├── I18nProvider.js │ ├── LanguageSwitcher.js │ ├── ModelSelect.js │ ├── Navbar/ │ │ ├── ActionButtons.js │ │ ├── ContextBar.js │ │ ├── DesktopMenus.js │ │ ├── Logo.js │ │ ├── MobileDrawer.js │ │ ├── NavigationTabs.js │ │ ├── contextBarStyles.js │ │ ├── index.js │ │ └── styles.js │ ├── TaskIcon.js │ ├── ThemeRegistry.js │ ├── UpdateChecker.js │ ├── common/ │ │ └── MessageAlert.js │ ├── conversations/ │ │ ├── ConversationContent.js │ │ ├── ConversationHeader.js │ │ ├── ConversationMetadata.js │ │ └── ConversationRatingSection.js │ ├── dataset-square/ │ │ ├── DatasetSearchBar.js │ │ ├── DatasetSiteCard.js │ │ └── DatasetSiteList.js │ ├── datasets/ │ │ ├── DatasetHeader.js │ │ ├── DatasetMetadata.js │ │ ├── DatasetRatingSection.js │ │ ├── EditableField.js │ │ ├── EvalVariantDialog.js │ │ ├── ImportDatasetDialog.js │ │ ├── NoteInput.js │ │ ├── OptimizeDialog.js │ │ ├── StarRating.js │ │ ├── TagSelector.js │ │ ├── import/ │ │ │ ├── FieldMappingStep.js │ │ │ ├── FileUploadStep.js │ │ │ └── ImportProgressStep.js │ │ └── utils/ │ │ └── ratingUtils.js │ ├── distill/ │ │ ├── AutoDistillDialog.js │ │ ├── AutoDistillProgress.js │ │ ├── ConfirmDialog.js │ │ ├── DistillTreeView.js │ │ ├── QuestionGenerationDialog.js │ │ ├── QuestionListItem.js │ │ ├── TagEditDialog.js │ │ ├── TagGenerationDialog.js │ │ ├── TagMenu.js │ │ ├── TagTreeItem.js │ │ └── utils.js │ ├── export/ │ │ ├── HuggingFaceTab.js │ │ ├── LlamaFactoryTab.js │ │ └── LocalExportTab.js │ ├── home/ │ │ ├── CreateProjectDialog.js │ │ ├── HeroSection.js │ │ ├── MigrationDialog.js │ │ ├── ParticleBackground.js │ │ ├── ProjectCard.js │ │ ├── ProjectList.js │ │ └── StatsCard.js │ ├── mga/ │ │ ├── GaPairsIndicator.js │ │ └── GaPairsManager.js │ ├── playground/ │ │ ├── ChatArea.js │ │ ├── ChatMessage.js │ │ ├── MessageInput.js │ │ ├── ModelSelector.js │ │ └── PlaygroundHeader.js │ ├── questions/ │ │ ├── QuestionListView.js │ │ └── QuestionTreeView.js │ ├── settings/ │ │ ├── BasicSettings.js │ │ ├── ModelSettings.js │ │ └── TaskSettings.js │ ├── tasks/ │ │ ├── TaskActions.js │ │ ├── TaskFilters.js │ │ ├── TaskProgress.js │ │ ├── TaskStatusChip.js │ │ └── TasksTable.js │ └── text-split/ │ ├── BatchEditChunkDialog.js │ ├── ChunkBatchDeleteDialog.js │ ├── ChunkCard.js │ ├── ChunkDeleteDialog.js │ ├── ChunkFilterDialog.js │ ├── ChunkList.js │ ├── ChunkListHeader.js │ ├── ChunkViewDialog.js │ ├── DomainAnalysis.js │ ├── FileUploader.js │ ├── LoadingBackdrop.js │ ├── MarkdownViewDialog.js │ ├── PdfSettings.js │ └── components/ │ ├── DeleteConfirmDialog.js │ ├── DirectoryView.js │ ├── DomainTreeActionDialog.js │ ├── DomainTreeView.js │ ├── FileList.js │ ├── FileLoadingProgress.js │ ├── PdfProcessingDialog.js │ ├── TabPanel.js │ └── UploadArea.js ├── constant/ │ ├── index.js │ ├── model.js │ ├── setting.js │ └── sites.json ├── docker-compose.yml ├── docker-entrypoint.sh ├── electron/ │ ├── entitlements.mac.plist │ ├── loading.html │ ├── main.js │ ├── modules/ │ │ ├── cache.js │ │ ├── database.js │ │ ├── db-updater.js │ │ ├── ipc-handlers.js │ │ ├── logger.js │ │ ├── menu.js │ │ ├── server.js │ │ ├── updater.js │ │ └── window-manager.js │ ├── preload.js │ └── util.js ├── hooks/ │ ├── useDebounce.js │ ├── useFileProcessingStatus.js │ ├── useGenerateDataset.js │ ├── useModelPlayground.js │ ├── useSnackbar.js │ └── useTaskSettings.js ├── jsconfig.json ├── lib/ │ ├── api/ │ │ ├── chunk.js │ │ ├── file.js │ │ ├── index.js │ │ └── task.js │ ├── db/ │ │ ├── base.js │ │ ├── chunks.js │ │ ├── custom-prompts.js │ │ ├── dataset-conversations.js │ │ ├── datasets.js │ │ ├── evalDatasets.js │ │ ├── evalResults.js │ │ ├── fileToDb.js │ │ ├── files.js │ │ ├── ga-pairs.js │ │ ├── imageDatasets.js │ │ ├── images.js │ │ ├── index.js │ │ ├── llm-models.js │ │ ├── llm-providers.js │ │ ├── model-config.js │ │ ├── projects.js │ │ ├── questionTemplates.js │ │ ├── questions.js │ │ ├── tags.js │ │ ├── texts.js │ │ └── upload-files.js │ ├── file/ │ │ ├── file-process/ │ │ │ ├── check-file.js │ │ │ ├── epub/ │ │ │ │ └── index.js │ │ │ ├── get-content.js │ │ │ ├── index.js │ │ │ ├── pdf/ │ │ │ │ ├── default.js │ │ │ │ ├── index.js │ │ │ │ ├── mineru-local.js │ │ │ │ ├── mineru.js │ │ │ │ ├── prompt/ │ │ │ │ │ ├── optimalTitle.js │ │ │ │ │ ├── optimalTitleEn.js │ │ │ │ │ ├── pdfToMarkdown.js │ │ │ │ │ └── pdfToMarkdownEn.js │ │ │ │ ├── util.js │ │ │ │ └── vision.js │ │ │ └── utils.js │ │ ├── split-markdown/ │ │ │ ├── core/ │ │ │ │ ├── parser.js │ │ │ │ ├── splitter.js │ │ │ │ ├── summary.js │ │ │ │ └── toc.js │ │ │ ├── index.js │ │ │ ├── output/ │ │ │ │ ├── fileWriter.js │ │ │ │ └── formatter.js │ │ │ └── utils/ │ │ │ └── common.js │ │ └── text-splitter.js │ ├── i18n.js │ ├── llm/ │ │ ├── common/ │ │ │ ├── prompt-loader.js │ │ │ ├── question-template.js │ │ │ └── util.js │ │ ├── core/ │ │ │ ├── index.js │ │ │ └── providers/ │ │ │ ├── alibailian.js │ │ │ ├── base.js │ │ │ ├── ollama.js │ │ │ ├── openai.js │ │ │ ├── openrouter.js │ │ │ └── zhipu.js │ │ ├── prompts/ │ │ │ ├── addLabel.js │ │ │ ├── answer.js │ │ │ ├── dataClean.js │ │ │ ├── datasetEvaluation.js │ │ │ ├── distillQuestions.js │ │ │ ├── distillTags.js │ │ │ ├── enhancedAnswer.js │ │ │ ├── evalQuestion.js │ │ │ ├── ga-generation.js │ │ │ ├── imageAnswer.js │ │ │ ├── imageQuestion.js │ │ │ ├── label.js │ │ │ ├── labelRevise.js │ │ │ ├── llmJudge.js │ │ │ ├── modelEvaluation.js │ │ │ ├── multiTurnConversation.js │ │ │ ├── newAnswer.js │ │ │ ├── optimizeCot.js │ │ │ └── question.js │ │ └── usageLogger.js │ ├── services/ │ │ ├── clean.js │ │ ├── datasets/ │ │ │ ├── evaluation.js │ │ │ └── index.js │ │ ├── eval/ │ │ │ └── index.js │ │ ├── evaluation/ │ │ │ └── index.js │ │ ├── ga/ │ │ │ ├── ga-generation.js │ │ │ └── ga-pairs.js │ │ ├── images/ │ │ │ └── index.js │ │ ├── models.js │ │ ├── multi-turn/ │ │ │ └── index.js │ │ ├── questions/ │ │ │ ├── index.js │ │ │ └── template.js │ │ └── tasks/ │ │ ├── answer-generation.js │ │ ├── data-cleaning.js │ │ ├── data-distillation.js │ │ ├── dataset-evaluation.js │ │ ├── eval-generation.js │ │ ├── file-processing.js │ │ ├── image-dataset-generation.js │ │ ├── image-question-generation.js │ │ ├── index.js │ │ ├── model-evaluation.js │ │ ├── multi-turn-generation.js │ │ ├── question-generation.js │ │ └── recovery.js │ ├── store.js │ └── util/ │ ├── async.js │ ├── domain-tree.js │ ├── file.js │ ├── image.js │ ├── logger.js │ ├── modelIcon.js │ ├── processInParallel.js │ ├── providerLogo.js │ └── request.js ├── locales/ │ ├── en/ │ │ └── translation.json │ ├── pt-BR/ │ │ └── translation.json │ ├── tr/ │ │ └── translation.json │ └── zh-CN/ │ └── translation.json ├── next.config.js ├── package.json ├── prisma/ │ ├── generate-template.js │ ├── schema.prisma │ └── sql.json ├── public/ │ └── imgs/ │ ├── logo.icns │ └── logo_old.icns └── styles/ ├── blindTest.js ├── globals.css ├── home.js └── playground.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules .next .git .github README.md README.zh-CN.md .gitignore .env.local .env.development.local .env.test.local .env.production.local /test /local-db /video /prisma/*.sqlite /prisma/*.sqlite-* ================================================ FILE: .gitattributes ================================================ # Ensure shell scripts always use LF line endings *.sh text eol=lf docker-entrypoint.sh text eol=lf # Ensure Dockerfile uses LF Dockerfile text eol=lf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '[Bug]' labels: bug assignees: '' --- **注意:请务必按照此模版填写 ISSUES 信息,否则 ISSUE 将不会得到回复** **问题描述** 清晰、简洁地描述该问题的具体情况。 **桌面设备(请完善以下信息)** - 操作系统:[例如:、Window、MAC] - 浏览器:[例如:谷歌浏览器(Chrome),苹果浏览器(Safari)] - Easy Dataset 版本:[例如:1.2.2] **使用模型** - 模型提供商:例如火山引擎 - 模型名称:例如 DeepSeek R1 **复现步骤** 重现该问题的操作步骤: 1. 进入“……”页面。 2. 点击“……”。 3. 向下滚动到“……”。 4. 这时会看到错误提示。 **预期结果** 清晰、简洁地描述你原本期望出现的情况。 **截图** 如果有必要,请附上截图,以便更好地说明你的问题。 **其他相关信息** 在此处添加关于该问题的其他任何相关背景信息。 ================================================ FILE: .github/ISSUE_TEMPLATE/feature-or-enhancement-.md ================================================ --- name: 'Feature or enhancement ' about: Suggest an idea for this project title: '[Feature]' labels: enhancement assignees: '' --- **你的功能请求是否与某个问题相关?请描述。** 清晰、简洁地描述一下存在的问题是什么。例如:当我[具体情况]时,我总是感到很沮丧。 **描述你期望的解决方案** 清晰、简洁地描述你希望实现的情况。 **描述你考虑过的替代方案** 清晰、简洁地描述你所考虑过的任何其他解决方案或功能。 **其他相关信息** 在此处添加与该功能请求相关的其他任何背景信息或截图。 ================================================ FILE: .github/ISSUE_TEMPLATE/question.md ================================================ --- name: Question about: Ask questions you want to know title: '[Question]' labels: question assignees: '' --- **注意:请务必按照此模版填写 ISSUES 信息,否则 ISSUE 将不会得到回复** **问题描述** 清晰、简洁地描述该问题的具体情况。 **桌面设备(请完善以下信息)** - 操作系统:[例如:、Window、MAC] - 浏览器:[例如:谷歌浏览器(Chrome),苹果浏览器(Safari)] - Easy Dataset 版本:[例如:1.2.2] **使用模型** - 模型提供商:例如火山引擎 - 模型名称:例如 DeepSeek R1 **复现步骤** 重现该问题的操作步骤: 1. 进入“……”页面。 2. 点击“……”。 3. 向下滚动到“……”。 4. 这时会看到错误提示。 **预期结果** 清晰、简洁地描述你原本期望出现的情况。 **截图** 如果有必要,请附上截图,以便更好地说明你的问题。 **其他相关信息** 在此处添加关于该问题的其他任何相关背景信息。 ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### 变更类型- [ ] 新功能(feat) - [ ] 修复(fix) - [ ] 文档(docs) - [ ] 重构(refactor) ### 变更描述- 简要说明修改内容(关联Issue:#123) ### 文档更新- [ ] README.md - [ ] 贡献指南 - [ ] 接口文档(如有) ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Build and Push Docker image on Tag on: push: tags: - '*' jobs: docker-image-release: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository_owner }}/easy-dataset tags: | type=ref,event=tag type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .gitignore ================================================ node_modules build .vscode website-local.json ai-local.json .next .DS_Store tsconfig.tsbuildinfo mock-login-callback.ts .env.local /src/test/crawler /src/test/mock /test /dist /prisma/*.sqlite .idea !local-db/empty.txt /local-db prisma/local-db/db.sqlite /local-db2 .trae opencode.json ================================================ FILE: .husky/commit-msg ================================================ #!/usr/bin/env sh npx commitlint --edit "$1" ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .npmrc ================================================ # 国内用户可使用淘宝源加速 (Chinese users can use Taobao registry for faster downloads) # registry=https://registry.npmmirror.com registry=https://registry.npmjs.org ================================================ FILE: .prettierrc.js ================================================ module.exports = { semi: true, trailingComma: 'none', singleQuote: true, tabWidth: 2, useTabs: false, bracketSpacing: true, arrowParens: 'avoid', proseWrap: 'preserve', jsxBracketSameLine: true, printWidth: 120, endOfLine: 'auto' }; ================================================ FILE: .windsurfrules ================================================ # Easy DataSet 项目架构设计 ## 项目概述 Easy DataSet 是一个用于创建大模型微调数据集的应用程序。用户可以上传文本文件,系统会自动分割文本并生成问题,最终生成用于微调的数据集。 ## 技术栈 - **前端框架**: Next.js 14 (App Router) - **UI 框架**: Material-UI (MUI) - **数据存储**: fs 文件系统模拟数据库 - **开发语言**: JavaScript - **依赖管理**: pnpm ## 目录结构 ``` easy-dataset/ ├── app/ # Next.js 应用目录 │ ├── api/ # API 路由 │ │ └── projects/ # 项目相关 API │ ├── projects/ # 项目相关页面 │ │ ├── [projectId]/ # 项目详情页面 │ └── page.js # 主页 ├── components/ # React 组件 │ ├── home/ # 主页相关组件 │ │ ├── HeroSection.js │ │ ├── ProjectList.js │ │ └── StatsCard.js │ ├── Navbar.js # 导航栏组件 │ └── CreateProjectDialog.js ├── lib/ # 工具库 │ └── db/ # 数据库模块 │ ├── base.js # 基础工具函数 │ ├── projects.js # 项目管理 │ ├── texts.js # 文本处理 │ ├── datasets.js # 数据集管理 │ └── index.js # 模块导出 ├── styles/ # 样式文件 │ └── home.js # 主页样式 └── local-db/ # 本地数据库目录 ``` ## 核心模块设计 ### 1. 数据库模块 (`lib/db/`) #### base.js - 提供基础的文件操作功能 - 确保数据库目录存在 - 读写 JSON 文件的工具函数 #### projects.js - 项目的 CRUD 操作 - 项目配置管理 - 项目目录结构维护 #### texts.js - 文献处理功能 - 文本片段存储和检索 - 文件上传处理 #### datasets.js - 数据集生成和管理 - 问题列表管理 - 标签树管理 ### 2. 前端组件 (`components/`) #### Navbar.js - 顶部导航栏 - 项目切换 - 模型选择 - 主题切换 #### home/ 目录组件 - HeroSection.js: 主页顶部展示区 - ProjectList.js: 项目列表展示 - StatsCard.js: 数据统计展示 - CreateProjectDialog.js: 创建项目的对话框 ### 3. 页面路由 (`app/`) #### 主页 (`page.js`) - 项目列表展示 - 创建项目入口 - 数据统计展示 #### 项目详情页 (`projects/[projectId]/`) - text-split/: 文献处理页面 - questions/: 问题列表页面 - datasets/: 数据集页面 - settings/: 项目设置页面 #### API 路由 (`api/`) - projects/: 项目管理 API - texts/: 文本处理 API - questions/: 问题生成 API - datasets/: 数据集管理 API ## 数据流设计 ### 项目创建流程 1. 用户通过主页或导航栏创建新项目 2. 填写项目基本信息(名称、描述) 3. 系统创建项目目录和初始配置文件 4. 重定向到项目详情页 ### 文献处理流程 1. 用户上传 Markdown 文件 2. 系统保存原始文件到项目目录 3. 调用文本分割服务,生成片段和目录结构 4. 展示分割结果和提取的目录 ### 问题生成流程 1. 用户选择需要生成问题的文本片段 2. 系统调用大模型API生成问题 3. 保存问题到问题列表和标签树 ### 数据集生成流程 1. 用户选择需要生成答案的问题 2. 系统调用大模型API生成答案 3. 保存数据集结果 4. 提供导出功能 ================================================ FILE: AGENTS.md ================================================ # Easy Dataset Agent 指南 ## 项目概述 Easy Dataset 是一个专为大型语言模型(LLM)微调数据集创建而设计的应用程序。它提供完整的workflow,从文档处理到数据集导出,支持多种文件格式和AI模型。 ## 技术栈 - **前端**: Next.js 14 (App Router), React 18, Material-UI v5 - **后端**: Node.js, Prisma ORM, SQLite - **AI集成**: OpenAI API, Ollama, 智谱AI, OpenRouter - **桌面应用**: Electron - **国际化**: i18next - **构建工具**: npm/pnpm, Electron Builder ## 核心架构 ### 1. 数据流架构 ``` 文档上传 → 文本分割 → 问题生成 → 答案生成 → 数据集导出 ↓ ↓ ↓ ↓ ↓ 文件处理 智能分块 LLM生成 LLM生成 格式转换 ``` ### 2. 模块结构 ``` lib/ ├── api/ # API接口层 ├── db/ # 数据访问层 ├── file/ # 文件处理模块 ├── llm/ # AI模型集成 ├── services/ # 业务逻辑层 └── util/ # 工具函数 ``` ## 开发指南 ### 环境设置 ```bash # 安装依赖 npm install # 数据库初始化 npm run db:push # 开发模式 npm run dev # 构建 npm run build ``` ### 代码规范 - 使用ES6+语法 - 模块化开发 - 异步操作使用async/await - 错误处理使用try/catch - 注释使用JSDoc格式 ### 重要文件路径 - **主入口**: `app/page.js` - **项目路由**: `app/projects/[projectId]/` - **API路由**: `app/api/` - **LLM核心**: `lib/llm/core/index.js` - **任务处理**: `lib/services/tasks/` ## 功能模块详解 ### 1. 文档处理模块 (`lib/file/`) - **支持的格式**: PDF, Markdown, DOCX, EPUB, TXT - **核心功能**: - 智能文本分割 - 目录结构提取 - 自定义分隔符分块 - 多语言支持 ### 2. AI模型集成 (`lib/llm/`) - **支持的提供商**: - OpenAI (GPT系列) - Ollama (本地模型) - 智谱AI (GLM系列) - OpenRouter (多模型聚合) - **功能特性**: - 统一API接口 - 流式输出支持 - 多语言提示词 - 错误重试机制 ### 3. 任务系统 (`lib/services/tasks/`) - **任务类型**: - 文件处理任务 - 问题生成任务 - 答案生成任务 - 数据清洗任务 - **状态管理**: 待处理、处理中、完成、失败 ### 4. 数据管理 (`lib/db/`) - **数据模型**: - Project (项目) - Text/Chunk (文本块) - Question (问题) - Dataset (数据集) - Tag (标签) ## 常用开发任务 ### 添加新的AI模型提供商 1. 在 `lib/llm/core/providers/` 创建新的provider文件 2. 实现基础接口 (generate, streamGenerate) 3. 在 `lib/llm/core/index.js` 中注册provider 4. 更新配置文件和UI界面 ### 添加新的文件格式支持 1. 在 `lib/file/file-process/` 创建格式处理器 2. 实现内容提取和文本转换逻辑 3. 更新文件类型检测和验证 4. 添加相应的UI组件 ### 自定义提示词模板 1. 在 `lib/llm/prompts/` 创建新的提示词文件 2. 使用i18n支持多语言 3. 在设置界面添加配置选项 4. 测试不同模型的效果 ### 添加新的导出格式 1. 在 `components/export/` 创建新的导出组件 2. 实现数据格式转换逻辑 3. 更新导出对话框界面 4. 添加格式验证和错误处理 ## 调试技巧 ### 1. 数据库调试 ```bash # 打开Prisma Studio npm run db:studio # 查看数据库文件 sqlite3 prisma/db.sqlite ``` ### 2. LLM API调试 ```javascript // 在lib/llm/core/index.js中添加日志 console.log('LLM Request:', { provider, model, prompt }); console.log('LLM Response:', response); ``` ### 3. 文件处理调试 ```javascript // 在lib/file/中添加调试信息 console.log('File processing:', fileName, fileType); console.log('Text chunks:', chunks.length, chunks[0]); ``` ## 性能优化建议 ### 1. 文件处理优化 - 大文件分片处理 - 异步并发处理 - 内存使用监控 - 进度条显示 ### 2. LLM调用优化 - 请求缓存机制 - 批量处理请求 - 重试策略优化 - 并发数控制 ### 3. 前端性能优化 - 组件懒加载 - 虚拟滚动列表 - 图片懒加载 - 代码分割 ## 常见问题解决 ### 1. 数据库相关问题 - **问题**: 数据库连接失败 - **解决**: 检查prisma配置,确保数据库文件存在 ### 2. LLM API相关问题 - **问题**: API调用超时 - **解决**: 调整超时时间,检查网络连接,增加重试机制 ### 3. 文件处理问题 - **问题**: 大文件处理内存溢出 - **解决**: 使用流式处理,分块读取,增加内存限制 ### 4. Electron打包问题 - **问题**: 打包后应用无法启动 - **解决**: 检查依赖项配置,确保native模块正确打包 ## 部署指南 ### Docker部署 ```bash # 构建镜像 docker build -t easy-dataset . # 运行容器 docker run -d -p 1717:1717 -v ./local-db:/app/local-db easy-dataset ``` ### 桌面应用构建 ```bash # 构建各平台安装包 npm run electron-build-mac # macOS npm run electron-build-win # Windows npm run electron-build-linux # Linux ``` ## 贡献指南 ### 提交规范 - 使用conventional commits格式 - 提交前运行lint检查 - 更新相关文档 - 添加测试用例 ### 分支策略 - `main`: 主分支,稳定版本 - `dev`: 开发分支,集成新功能 - `feature/*`: 功能分支 - `fix/*`: 修复分支 --- ================================================ FILE: ARCHITECTURE.md ================================================ # Easy DataSet 项目架构设计 ## 项目概述 Easy DataSet 是一个用于创建大模型微调数据集的应用程序。用户可以上传文本文件,系统会自动分割文本并生成问题,最终生成用于微调的数据集。 ## 技术栈 - **前端框架**: Next.js 14 (App Router) - **UI 框架**: Material-UI (MUI) - **数据存储**: fs 文件系统模拟数据库 - **开发语言**: JavaScript ## 目录结构 ``` easy-dataset/ ├── app/ # Next.js 应用目录 │ ├── api/ # API 路由 │ │ └── projects/ # 项目相关 API │ ├── projects/ # 项目相关页面 │ │ ├── [projectId]/ # 项目详情页面 │ └── page.js # 主页 ├── components/ # React 组件 │ ├── home/ # 主页相关组件 │ │ ├── HeroSection.js │ │ ├── ProjectList.js │ │ └── StatsCard.js │ ├── Navbar.js # 导航栏组件 │ └── CreateProjectDialog.js ├── lib/ # 工具库 │ └── db/ # 数据库模块 │ ├── base.js # 基础工具函数 │ ├── projects.js # 项目管理 │ ├── texts.js # 文本处理 │ ├── datasets.js # 数据集管理 │ └── index.js # 模块导出 ├── styles/ # 样式文件 │ └── home.js # 主页样式 └── local-db/ # 本地数据库目录 ``` ## 核心模块设计 ### 1. 数据库模块 (`lib/db/`) #### base.js - 提供基础的文件操作功能 - 确保数据库目录存在 - 读写 JSON 文件的工具函数 #### projects.js - 项目的 CRUD 操作 - 项目配置管理 - 项目目录结构维护 #### texts.js - 文献处理功能 - 文本片段存储和检索 - 文件上传处理 #### datasets.js - 数据集生成和管理 - 问题列表管理 - 标签树管理 ### 2. 前端组件 (`components/`) #### Navbar.js - 顶部导航栏 - 项目切换 - 模型选择 - 主题切换 #### home/ 目录组件 - HeroSection.js: 主页顶部展示区 - ProjectList.js: 项目列表展示 - StatsCard.js: 数据统计展示 - CreateProjectDialog.js: 创建项目的对话框 ### 3. 页面路由 (`app/`) #### 主页 (`page.js`) - 项目列表展示 - 创建项目入口 - 数据统计展示 #### 项目详情页 (`projects/[projectId]/`) - text-split/: 文献处理页面 - questions/: 问题列表页面 - datasets/: 数据集页面 - settings/: 项目设置页面 #### API 路由 (`api/`) - projects/: 项目管理 API - texts/: 文本处理 API - questions/: 问题生成 API - datasets/: 数据集管理 API ## 数据流设计 ### 项目创建流程 1. 用户通过主页或导航栏创建新项目 2. 填写项目基本信息(名称、描述) 3. 系统创建项目目录和初始配置文件 4. 重定向到项目详情页 ### 文献处理流程 1. 用户上传 Markdown 文件 2. 系统保存原始文件到项目目录 3. 调用文本分割服务,生成片段和目录结构 4. 展示分割结果和提取的目录 ### 问题生成流程 1. 用户选择需要生成问题的文本片段 2. 系统调用大模型API生成问题 3. 保存问题到问题列表和标签树 ### 数据集生成流程 1. 用户选择需要生成答案的问题 2. 系统调用大模型API生成答案 3. 保存数据集结果 4. 提供导出功能 ## 模型配置 支持多种大模型提供商配置: - Ollama - OpenAI - 硅基流动 - 深度求索 - 智谱AI 每个提供商支持配置: - API 地址 - API 密钥 - 模型名称 ## 未来扩展方向 1. 支持更多文件格式(PDF、DOC等) 2. 增加数据集质量评估功能 3. 添加数据集版本管理 4. 实现团队协作功能 5. 增加更多数据集导出格式 ## 国际化处理 ### 技术选型 - **国际化库**: i18next + react-i18next - **语言检测**: i18next-browser-languagedetector - **支持语言**: 英文(en)、简体中文(zh-CN) ### 目录结构 ``` easy-dataset/ ├── locales/ # 国际化资源目录 │ ├── en/ # 英文翻译 │ │ └── translation.json │ ├── zh-CN/ # 中文翻译 │ │ └── translation.json │ └── pt-BR/ # 中文翻译 │ └── translation.json ├── lib/ │ └── i18n.js # i18next 配置 ``` ================================================ FILE: Dockerfile ================================================ # 创建包含pnpm的基础镜像 FROM node:20-alpine AS pnpm-base RUN npm install -g pnpm@9 # 构建阶段 FROM pnpm-base AS builder WORKDIR /app # 添加构建参数,用于识别目标平台 ARG TARGETPLATFORM # 安装构建依赖 RUN apk add --no-cache --virtual .build-deps \ python3 \ make \ g++ \ cairo-dev \ pango-dev \ jpeg-dev \ giflib-dev \ librsvg-dev \ build-base \ pixman-dev \ pkgconfig # 复制依赖文件和npm配置并安装(.npmrc中可配置国内源加速) COPY package.json pnpm-lock.yaml .npmrc ./ RUN pnpm install # 复制源代码 COPY . . # 根据目标平台设置Prisma二进制目标并构建应用 RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ echo "Configuring for ARM64 platform"; \ sed -i 's/binaryTargets = \[.*\]/binaryTargets = \["linux-musl-arm64-openssl-3.0.x"\]/' prisma/schema.prisma; \ PRISMA_CLI_BINARY_TARGETS="linux-musl-arm64-openssl-3.0.x" pnpm build; \ else \ echo "Configuring for AMD64 platform (default)"; \ sed -i 's/binaryTargets = \[.*\]/binaryTargets = \["linux-musl-openssl-3.0.x"\]/' prisma/schema.prisma; \ PRISMA_CLI_BINARY_TARGETS="linux-musl-openssl-3.0.x" pnpm build; \ fi # 构建完成后移除开发依赖,只保留生产依赖 RUN pnpm prune --prod # 运行阶段 FROM pnpm-base AS runner WORKDIR /app # 只安装运行时依赖 RUN apk add --no-cache \ cairo \ pango \ jpeg \ giflib \ librsvg \ pixman # 复制package.json和.env文件 COPY package.json .env ./ # 从构建阶段复制精简后的node_modules(只包含生产依赖) COPY --from=builder /app/node_modules ./node_modules # 从构建阶段复制构建产物 COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/electron ./electron # 复制 prisma 到模板目录(用于自动初始化) COPY --from=builder /app/prisma /app/prisma-template # 复制并设置 entrypoint 脚本(sed 去除 Windows 换行符 \r,防止 CRLF 导致 "no such file or directory") COPY docker-entrypoint.sh /usr/local/bin/ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \ chmod +x /usr/local/bin/docker-entrypoint.sh # 设置生产环境 ENV NODE_ENV=production EXPOSE 1717 # 使用 entrypoint 脚本 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["pnpm", "start"] ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2025 Easy Dataset Project This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/. Additional Terms for Easy Dataset: 1. Contact Information If you wish to use Easy Dataset under different terms, please contact the copyright holders at: 1009903985@qq.com 2. Branding Restrictions You may not use the names "Easy Dataset" or "EasyDataset" to endorse or promote products derived from this software without prior written permission. 3. Disclaimer of Warranty The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software. 4. Compliance with Laws You are responsible for ensuring your use of the software complies with all applicable laws, including but not limited to export control regulations. ================================================ FILE: README.md ================================================
![](./public//imgs/bg2.png) GitHub Repo stars GitHub Downloads (all assets, all releases) GitHub Release AGPL 3.0 License GitHub contributors GitHub last commit arXiv:2507.04009 ConardLi%2Feasy-dataset | Trendshift **A powerful tool for creating fine-tuning datasets for Large Language Models** [简体中文](./README.zh-CN.md) | [English](./README.md) | [Türkçe](./README.tr.md) [Features](#features) • [Quick Start](#local-run) • [Documentation](https://docs.easy-dataset.com/ed/en) • [Contributing](#contributing) • [License](#license) If you like this project, please give it a Star⭐️, or buy the author a coffee => [Donate](./public/imgs/aw.jpg) ❤️!
## Overview Easy Dataset is an application specifically designed for building large language model (LLM) datasets. It features an intuitive interface, along with built-in powerful document parsing tools, intelligent segmentation algorithms, data cleaning and augmentation capabilities. The application can convert domain-specific documents in various formats into high-quality structured datasets, which are applicable to scenarios such as model fine-tuning, retrieval-augmented generation (RAG), and model performance evaluation. ![](./public/imgs/arc3.png) ## News 🎉🎉 Easy Dataset Version 1.7.0 launches brand-new evaluation capabilities! You can effortlessly convert domain-specific documents into evaluation datasets (test sets) and automatically run multi-dimensional evaluation tasks. Additionally, it comes with a human blind test system, enabling you to easily meet needs such as vertical domain model evaluation, post-fine-tuning model performance assessment, and RAG recall rate evaluation. Tutorial: [https://www.bilibili.com/video/BV1CRrVB7Eb4/](https://www.bilibili.com/video/BV1CRrVB7Eb4/) ## Features ### 📄 Document Processing & Data Generation - **Intelligent Document Processing**: Supports PDF, Markdown, DOCX, TXT, EPUB and more formats with intelligent recognition - **Intelligent Text Splitting**: Multiple splitting algorithms (Markdown structure, recursive separators, fixed length, code-aware chunking), with customizable visual segmentation - **Intelligent Question Generation**: Auto-extract relevant questions from text segments, with question templates and batch generation - **Domain Label Tree**: Intelligently builds global domain label trees based on document structure, with auto-tagging capabilities - **Answer Generation**: Uses LLM API to generate comprehensive answers and Chain of Thought (COT), with AI optimization - **Data Cleaning**: Intelligent text cleaning to remove noise and improve data quality ### 🔄 Multiple Dataset Types - **Single-Turn QA Datasets**: Standard question-answer pairs for basic fine-tuning - **Multi-Turn Dialogue Datasets**: Customizable roles and scenarios for conversational format - **Image QA Datasets**: Generate visual QA data from images, with multiple import methods (directory, PDF, ZIP) - **Data Distillation**: Generate label trees and questions directly from domain topics without uploading documents ### 📊 Model Evaluation System - **Evaluation Datasets**: Generate true/false, single-choice, multiple-choice, short-answer, and open-ended questions - **Automated Model Evaluation**: Use Judge Model to automatically evaluate model answer quality with customizable scoring rules - **Human Blind Test (Arena)**: Double-blind comparison of two models' answers for unbiased evaluation - **AI Quality Assessment**: Automatic quality scoring and filtering of generated datasets ### 🛠️ Advanced Features - **Custom Prompts**: Project-level customization of all prompt templates (question generation, answer generation, data cleaning, etc.) - **GA Pair Generation**: Genre-Audience pair generation to enrich data diversity - **Task Management Center**: Background batch task processing with monitoring and interruption support - **Resource Monitoring Dashboard**: Token consumption statistics, API call tracking, model performance analysis - **Model Testing Playground**: Compare up to 3 models simultaneously ### 📤 Export & Integration - **Multiple Export Formats**: Alpaca, ShareGPT, Multilingual-Thinking formats with JSON/JSONL file types - **Balanced Export**: Configure export counts per tag for dataset balancing - **LLaMA Factory Integration**: One-click LLaMA Factory configuration file generation - **Hugging Face Upload**: Direct upload datasets to Hugging Face Hub ### 🤖 Model Support - **Wide Model Compatibility**: Compatible with all LLM APIs that follow the OpenAI format - **Multi-Provider Support**: OpenAI, Ollama (local models), Zhipu AI, Alibaba Bailian, OpenRouter, and more - **Vision Models**: Support Gemini, Claude, etc. for PDF parsing and image QA ### 🌐 User Experience - **User-Friendly Interface**: Modern, intuitive UI designed for both technical and non-technical users - **Multi-Language Support**: Complete Chinese, English, Turkish and Portuguese language support 🇹🇷 - **Dataset Square**: Discover and explore public dataset resources - **Desktop Clients**: Available for Windows, macOS, and Linux ## Quick Demo https://github.com/user-attachments/assets/6ddb1225-3d1b-4695-90cd-aa4cb01376a8 ## Local Run ### Download Client
Windows MacOS Linux

Setup.exe

Intel

M

AppImage
### Install with NPM 1. Clone the repository: ```bash git clone https://github.com/ConardLi/easy-dataset.git cd easy-dataset ``` 2. Install dependencies: ```bash npm install ``` 3. Start the development server: ```bash npm run build npm run start ``` 4. Open your browser and visit `http://localhost:1717` ### Using the Official Docker Image 1. Clone the repository: ```bash git clone https://github.com/ConardLi/easy-dataset.git cd easy-dataset ``` 2. Modify the `docker-compose.yml` file: ```yml services: easy-dataset: image: ghcr.io/conardli/easy-dataset container_name: easy-dataset ports: - '1717:1717' volumes: - ./local-db:/app/local-db - ./prisma:/app/prisma restart: unless-stopped ``` > **Note:** It is recommended to use the `local-db` and `prisma` folders in the current code repository directory as mount paths to maintain consistency with the database paths when starting via NPM. > **Note:** The database file will be automatically initialized on first startup, no need to manually run `npm run db:push`. 3. Start with docker-compose: ```bash docker-compose up -d ``` 4. Open a browser and visit `http://localhost:1717` ### Building with a Local Dockerfile If you want to build the image yourself, use the Dockerfile in the project root directory: 1. Clone the repository: ```bash git clone https://github.com/ConardLi/easy-dataset.git cd easy-dataset ``` 2. Build the Docker image: ```bash docker build -t easy-dataset . ``` 3. Run the container: ```bash docker run -d \ -p 1717:1717 \ -v ./local-db:/app/local-db \ -v ./prisma:/app/prisma \ --name easy-dataset \ easy-dataset ``` > **Note:** It is recommended to use the `local-db` and `prisma` folders in the current code repository directory as mount paths to maintain consistency with the database paths when starting via NPM. > **Note:** The database file will be automatically initialized on first startup, no need to manually run `npm run db:push`. 4. Open a browser and visit `http://localhost:1717` ## Documentation - View the demo video of this project: [Easy Dataset Demo Video](https://www.bilibili.com/video/BV1y8QpYGE57/) - For detailed documentation on all features and APIs, visit our [Documentation Site](https://docs.easy-dataset.com/ed/en) - View the paper of this project: [Easy Dataset: A Unified and Extensible Framework for Synthesizing LLM Fine-Tuning Data from Unstructured Documents](https://arxiv.org/abs/2507.04009v1) ## Community Practice - [Complete test set generation and model evaluation with Easy Dataset](https://www.bilibili.com/video/BV1CRrVB7Eb4/) - [Easy Dataset × LLaMA Factory: Enabling LLMs to Efficiently Learn Domain Knowledge](https://buaa-act.feishu.cn/wiki/GVzlwYcRFiR8OLkHbL6cQpYin7g) - [Easy Dataset Practical Guide: How to Build High-Quality Datasets?](https://www.bilibili.com/video/BV1MRMnz1EGW) - [Interpretation of Key Feature Updates in Easy Dataset](https://www.bilibili.com/video/BV1fyJhzHEb7/) - [Foundation Models Fine-tuning Datasets: Basic Knowledge Popularization](https://docs.easy-dataset.com/zhi-shi-ke-pu) ## Contributing We welcome contributions from the community! If you'd like to contribute to Easy Dataset, please follow these steps: 1. Fork the repository 2. Create a new branch (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Commit your changes (`git commit -m 'Add some amazing feature'`) 5. Push to the branch (`git push origin feature/amazing-feature`) 6. Open a Pull Request (submit to the DEV branch) Please ensure that tests are appropriately updated and adhere to the existing coding style. ## Join Discussion Group & Contact the Author https://docs.easy-dataset.com/geng-duo/lian-xi-wo-men ## License This project is licensed under the AGPL 3.0 License - see the [LICENSE](LICENSE) file for details. ## Citation If this work is helpful, please kindly cite as: ```bibtex @misc{miao2025easydataset, title={Easy Dataset: A Unified and Extensible Framework for Synthesizing LLM Fine-Tuning Data from Unstructured Documents}, author={Ziyang Miao and Qiyu Sun and Jingyuan Wang and Yuchen Gong and Yaowei Zheng and Shiqi Li and Richong Zhang}, year={2025}, eprint={2507.04009}, archivePrefix={arXiv}, primaryClass={cs.CL}, url={https://arxiv.org/abs/2507.04009} } ``` ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=ConardLi/easy-dataset&type=Date)](https://www.star-history.com/#ConardLi/easy-dataset&Date)
Built with ❤️ by ConardLi • Follow me: WeChat Official AccountBilibiliJuejinZhihuYoutube
================================================ FILE: README.tr.md ================================================
![](./public//imgs/bg2.png) GitHub Repo stars GitHub Downloads (all assets, all releases) GitHub Release AGPL 3.0 License GitHub contributors GitHub last commit arXiv:2507.04009 ConardLi%2Feasy-dataset | Trendshift **Büyük Dil Modelleri için ince ayar veri setleri oluşturmak için güçlü bir araç** [简体中文](./README.zh-CN.md) | [English](./README.md) | [Türkçe](./README.tr.md) [Özellikler](#özellikler) • [Hızlı Başlangıç](#yerel-çalıştırma) • [Dokümantasyon](https://docs.easy-dataset.com/ed/en) • [Katkıda Bulunma](#katkıda-bulunma) • [Lisans](#lisans) Bu projeyi beğendiyseniz, lütfen bir Yıldız⭐️ verin veya yazara bir kahve ısmarlayın => [Bağış](./public/imgs/aw.jpg) ❤️!
## Genel Bakış Easy Dataset, Büyük Dil Modelleri (LLM'ler) için özel olarak tasarlanmış ince ayar veri setleri oluşturmak için bir uygulamadır. Alana özgü dosyaları yüklemek, içeriği akıllıca bölmek, sorular oluşturmak ve model ince ayarı için yüksek kaliteli eğitim verileri üretmek için sezgisel bir arayüz sağlar. Easy Dataset ile alan bilgisini yapılandırılmış veri setlerine dönüştürebilir, OpenAI formatını takip eden tüm LLM API'leriyle uyumlu çalışabilir ve ince ayar sürecini basit ve verimli hale getirebilirsiniz. ![](./public/imgs/arc3.png) ## Özellikler - **Akıllı Belge İşleme**: PDF, Markdown, DOCX dahil birden fazla formatın akıllı tanınması ve işlenmesi desteği - **Akıllı Metin Bölme**: Birden fazla akıllı metin bölme algoritması ve özelleştirilebilir görsel segmentasyon desteği - **Akıllı Soru Üretimi**: Her metin bölümünden ilgili soruları çıkarır - **Alan Etiketleri**: Veri setleri için global alan etiketlerini akıllıca oluşturur, küresel anlama yeteneklerine sahiptir - **Cevap Üretimi**: Kapsamlı cevaplar ve Düşünce Zinciri (COT) oluşturmak için LLM API kullanır - **Esnek Düzenleme**: Sürecin herhangi bir aşamasında soruları, cevapları ve veri setlerini düzenleyin - **Çoklu Dışa Aktarma Formatları**: Veri setlerini çeşitli formatlarda (Alpaca, ShareGPT, çok dilli düşünme) ve dosya türlerinde (JSON, JSONL) dışa aktarın - **Geniş Model Desteği**: OpenAI formatını takip eden tüm LLM API'leriyle uyumlu - **Tam Türkçe Dil Desteği**: Tüm arayüz ve AI işlemleri için eksiksiz Türkçe çeviriler 🇹🇷 - **Kullanıcı Dostu Arayüz**: Hem teknik hem de teknik olmayan kullanıcılar için tasarlanmış sezgisel kullanıcı arayüzü - **Özel Sistem İstemleri**: Model yanıtlarını yönlendirmek için özel sistem istemleri ekleyin ## Hızlı Demo https://github.com/user-attachments/assets/6ddb1225-3d1b-4695-90cd-aa4cb01376a8 ## Yerel Çalıştırma ### İstemciyi İndirin
Windows MacOS Linux

Setup.exe

Intel

M

AppImage
### NPM ile Kurulum ```bash npm install npm run db:push npm run dev ``` ### Docker ile Kurulum ```bash docker-compose up -d ``` Ardından `http://localhost:1717` adresine gidin. ## Desteklenen AI Sağlayıcıları Easy Dataset, aşağıdakiler dahil olmak üzere birden fazla AI sağlayıcısını destekler: - **OpenAI**: GPT-4, GPT-3.5-turbo ve diğer modeller - **Ollama**: Yerel model çalıştırma - **智谱AI (GLM)**: Çince modeller - **OpenRouter**: Çoklu model aggregatör - **Özel API Uç Noktaları**: OpenAI formatını takip eden herhangi bir API ## Proje Yapısı ``` easy-dataset/ ├── app/ # Next.js uygulama yönlendiricisi │ ├── api/ # API rotaları │ ├── projects/ # Proje sayfaları │ └── dataset-square/ # Veri seti galerisi ├── components/ # React bileşenleri ├── lib/ # Temel kütüphaneler │ ├── llm/ # LLM entegrasyonu │ ├── db/ # Veritabanı erişimi │ ├── file/ # Dosya işleme │ └── services/ # İş mantığı ├── locales/ # i18n çevirileri │ ├── en/ # İngilizce │ ├── zh-CN/ # Basitleştirilmiş Çince │ └── tr/ # Türkçe ├── prisma/ # Veritabanı şeması └── electron/ # Electron masaüstü uygulaması ``` ## Kullanım Rehberi ### 1. Proje Oluşturma İlk olarak, yeni bir proje oluşturun ve proje adını, açıklamasını ve diğer temel bilgileri yapılandırın. ### 2. Dosya Yükleme Alana özgü belgelerinizi yükleyin. Desteklenen formatlar: - PDF - Markdown (.md) - Microsoft Word (.docx) - EPUB - Düz metin (.txt) ### 3. Metin Bölme Dosyalar aşağıdaki yöntemlerle akıllıca bölünebilir: - Doğal dil işleme tabanlı semantik bölme - Özel ayırıcılara dayalı bölme - Karakter sayısına dayalı sabit boyutlu bölme - Manuel görsel bölme ### 4. Alan Etiketleri Oluşturma Sistem, belge içeriğine dayalı olarak otomatik olarak hiyerarşik alan etiketleri oluşturabilir ve iki seviyeyi destekler. ### 5. Soru Üretimi Her metin bloğu için sistem: - İçeriğe dayalı alakalı sorular oluşturur - Tür ve hedef kitle perspektifi sorgulamayı destekler - Soru sayısını özelleştirme seçeneği sunar ### 6. Cevap Üretimi Yapılandırılmış LLM API'si kullanarak: - Her soru için kapsamlı cevaplar oluşturur - Düşünce Zinciri (COT) üretimini destekler - Farklı cevap şablonları destekler ### 7. Veri Seti Dışa Aktarma Veri setinizi çeşitli formatlarda dışa aktarın: - **Alpaca Format**: Basit talimat-takip formatı - **ShareGPT Format**: Çok turlu konuşma formatı - **Çok Dilli Düşünme**: COT ile genişletilmiş format - **Özel Format**: Kendi JSON yapınızı tanımlayın Dışa aktarma hedefleri: - Yerel dosya sistemi - Hugging Face Hub - LLaMA Factory uyumluluğu ## Gelişmiş Özellikler ### Veri Damıtma Mevcut veri setlerinden yeni eğitim örnekleri oluşturun: - Soru damıtma: Mevcut soru-cevap çiftlerinden yeni sorular oluşturun - Etiket damıtma: Otomatik etiket ve kategorizasyon oluşturma ### Tür-Hedef Kitle (GA) Çiftleri Spesifik içerik stilleri ve hedef kitleler için veri setlerini uyarlayın: - Tür: Akademik, teknik, yaratıcı yazma, vb. - Hedef Kitle: Yeni başlayanlar, uzmanlar, öğrenciler, vb. ### Toplu İşlemler Birden fazla öğeye verimli bir şekilde işlem: - Toplu soru üretimi - Toplu cevap üretimi - Toplu veri seti dışa aktarma ### Görev Yönetimi Tüm arka plan görevlerini izleyin ve yönetin: - Dosya işleme görevleri - Soru üretim görevleri - Cevap üretim görevleri - Dışa aktarma görevleri ## Yapılandırma ### LLM API Yapılandırması Ayarlar sayfasında LLM API'nizi yapılandırın: 1. **Sağlayıcı**: OpenAI, Ollama, 智谱AI veya özel seçin 2. **API Anahtarı**: API anahtarınızı girin (gerekirse) 3. **Model**: Kullanılacak modeli seçin 4. **Temel URL**: Özel API'ler için temel URL'yi ayarlayın ### Görev Ayarları Görev yürütme parametrelerini özelleştirin: - Soru üretimi için eşzamanlılık - Cevap üretimi için eşzamanlılık - Varsayılan soru sayısı - Varsayılan cevap şablonu ### Özel İstemler Her görev türü için özel sistem istemleri ekleyin: - Soru üretim istemi - Cevap üretim istemi - Etiket üretim istemi - Damıtma istemi ## Katkıda Bulunma Katkılara hoş geldiniz! Lütfen şu adımları izleyin: 1. Repo'yu fork edin 2. Bir özellik dalı oluşturun (`git checkout -b feature/amazing-feature`) 3. Değişikliklerinizi commit edin (`git commit -m 'Add some amazing feature'`) 4. Dala push edin (`git push origin feature/amazing-feature`) 5. Bir Pull Request açın ## Lisans Bu proje AGPL-3.0 Lisansı altında lisanslanmıştır. Detaylar için [LICENSE](./LICENSE) dosyasına bakın. ## İletişim - **GitHub Issues**: [Yeni bir sorun oluşturun](https://github.com/ConardLi/easy-dataset/issues) - **Email**: lhj19950927@gmail.com - **WeChat Grubu**: README'deki QR koduna bakın ## Alıntı Bu aracı araştırmanızda kullanırsanız, lütfen şu şekilde alıntı yapın: ```bibtex @misc{easy-dataset-2025, title={Easy Dataset: A Tool for Creating Fine-tuning Datasets for Large Language Models}, author={Conard Li}, year={2025}, publisher={GitHub}, howpublished={\url{https://github.com/ConardLi/easy-dataset}} } ``` ## Teşekkürler Bu proje aşağıdaki harika açık kaynak projelerini kullanır: - [Next.js](https://nextjs.org/) - [React](https://reactjs.org/) - [Material-UI](https://mui.com/) - [Prisma](https://www.prisma.io/) - [Electron](https://www.electronjs.org/) ---
⭐️ Bu projeyi beğendiyseniz, lütfen bir yıldız verin! ⭐️
================================================ FILE: README.zh-CN.md ================================================
![](./public//imgs/bg2.png) GitHub Repo stars GitHub Downloads (all assets, all releases) GitHub Release AGPL 3.0 License GitHub contributors GitHub last commit arXiv:2507.04009 ConardLi%2Feasy-dataset | Trendshift **一个强大的大型语言模型微调数据集创建工具** [简体中文](./README.zh-CN.md) | [English](./README.md) [功能特点](#功能特点) • [快速开始](#本地运行) • [使用文档](https://docs.easy-dataset.com/) • [贡献](#贡献) • [许可证](#许可证) 如果喜欢本项目,请给本项目留下 Star⭐️,或者请作者喝杯咖啡呀 => [打赏作者](./public/imgs/aw.jpg) ❤️!
## 概述 Easy Dataset 是一个专为创建大型语言模型数据集而设计的应用程序。它提供了直观的界面,内置了强大的文档解析工具、智能分割算法、数据清洗和数据增强能力,可以将各种格式的领域文献转化为高质量结构化数据集,可用于模型微调、RAG、模型效果评估等场景。 ![Easy Dataset 产品架构图](./public/imgs/arc3.png) ## 新闻 🎉🎉 Easy Dataset 1.7.0 版本上线全新的评估能力,你可以轻松将领域文献转换为评估数据集(测试集),并且可以自动执行多维度评估任务,另外还配备人工盲测系统,可以轻松助你完成垂直领域模型评估、模型微调后效果评估、RAG 召回率评估等需求,使用教程: [https://www.bilibili.com/video/BV1CRrVB7Eb4/](https://www.bilibili.com/video/BV1CRrVB7Eb4/) ## 功能特点 ### 📄 文档处理与数据生成 - **智能文档处理**:支持 PDF、Markdown、DOCX、TXT、EPUB 等多种格式智能识别和处理 - **智能文本分割**:支持多种智能文本分割算法(Markdown 结构、递归分隔符、固定长度、代码智能分块等),支持自定义可视化分段 - **智能问题生成**:从每个文本片段中自动提取相关问题,支持问题模板和批量生成 - **领域标签树**:基于文档目录智能构建全局领域标签树,具备全局理解和自动打标能力 - **答案生成**:使用 LLM API 为每个问题生成全面的答案和思维链(COT),支持 AI 智能优化 - **数据清洗**:智能清洗文本块内容,去除噪音数据,提升数据质量 ### 🔄 多种数据集类型 - **单轮问答数据集**:标准的问答对格式,适合基础微调 - **多轮对话数据集**:支持自定义角色和场景的多轮对话格式 - **图片问答数据集**:基于图片生成视觉问答数据,支持多种导入方式(目录、PDF、压缩包) - **数据蒸馏**:无需上传文档,直接从领域主题自动生成标签树和问题 ### 📊 模型评估体系 - **评估数据集**:支持生成判断题、单选题、多选题、简答题、开放题等多种题型的评估测试集 - **模型自动评估**:使用教师模型(Judge Model)自动评估模型回答质量,支持自定义评分规则 - **人工盲测 (Arena)**:双盲对比两个模型的回答质量,消除偏见进行公正评判 - **AI 质量评估**:对生成的数据集进行自动质量评分和筛选 ### 🛠️ 高级功能 - **自定义提示词**:项目级自定义各类提示词模板(问题生成、答案生成、数据清洗等) - **GA 组合生成**:文体-受众对生成,丰富数据多样性 - **任务管理中心**:后台批量任务处理,支持任务监控和中断 - **资源监控看板**:Token 消耗统计、调用次数追踪、模型性能分析 - **模型测试 Playground**:支持最多 3 个模型同时对比测试 ### 📤 导出与集成 - **多种导出格式**:支持 Alpaca、ShareGPT、Multilingual-Thinking 等格式,JSON/JSONL 文件类型 - **平衡导出**:按标签配置导出数量,实现数据集均衡 - **LLaMA Factory 集成**:一键生成 LLaMA Factory 配置文件 - **Hugging Face 上传**:直接将数据集上传至 Hugging Face Hub ### 🤖 模型支持 - **广泛的模型兼容**:兼容所有遵循 OpenAI 格式的 LLM API - **多提供商支持**:OpenAI、Ollama(本地模型)、智谱 AI、阿里百炼、OpenRouter 等 - **视觉模型**:支持 Gemini、Claude 等视觉模型用于 PDF 解析和图片问答 ### 🌐 用户体验 - **用户友好界面**:为技术和非技术用户设计的现代化直观 UI - **多语言支持**:完整的中英文界面支持 - **数据集广场**:发现和探索各种公开数据集资源 - **桌面客户端**:提供 Windows、macOS、Linux 桌面应用 ## 快速演示 https://github.com/user-attachments/assets/6ddb1225-3d1b-4695-90cd-aa4cb01376a8 ## 本地运行 ### 下载客户端
Windows MacOS Linux

Setup.exe

Intel

M

AppImage
### 使用 NPM 安装 1. 克隆仓库: ```bash git clone https://github.com/ConardLi/easy-dataset.git cd easy-dataset ``` 2. 安装依赖: ```bash npm install ``` 3. 启动开发服务器: ```bash npm run build npm run start ``` 4. 打开浏览器并访问 `http://localhost:1717` ### 使用官方 Docker 镜像 1. 克隆仓库: ```bash git clone https://github.com/ConardLi/easy-dataset.git cd easy-dataset ``` 2. 更改 `docker-compose.yml` 文件: ```yml services: easy-dataset: image: ghcr.io/conardli/easy-dataset container_name: easy-dataset ports: - '1717:1717' volumes: - ./local-db:/app/local-db - ./prisma:/app/prisma restart: unless-stopped ``` > **注意:** 建议直接使用当前代码仓库目录下的 `local-db` 和 `prisma` 文件夹作为挂载路径,这样可以和 NPM 启动时的数据库路径保持一致。 > **注意:** 数据库文件会在首次启动时自动初始化,无需手动执行 `npm run db:push`。 3. 使用 docker-compose 启动 ```bash docker-compose up -d ``` 4. 打开浏览器并访问 `http://localhost:1717` ### 使用本地 Dockerfile 构建 如果你想自行构建镜像,可以使用项目根目录中的 Dockerfile: 1. 克隆仓库: ```bash git clone https://github.com/ConardLi/easy-dataset.git cd easy-dataset ``` 2. 构建 Docker 镜像: ```bash docker build -t easy-dataset . ``` 3. 运行容器: ```bash docker run -d \ -p 1717:1717 \ -v ./local-db:/app/local-db \ -v ./prisma:/app/prisma \ --name easy-dataset \ easy-dataset ``` > **注意:** 建议直接使用当前代码仓库目录下的 `local-db` 和 `prisma` 文件夹作为挂载路径,这样可以和 NPM 启动时的数据库路径保持一致。 > **注意:** 数据库文件会在首次启动时自动初始化,无需手动执行 `npm run db:push`。 4. 打开浏览器,访问 `http://localhost:1717` ## 文档 - 有关所有功能和 API 的详细文档,请访问我们的 [文档站点](https://docs.easy-dataset.com/) - 查看本项目的演示视频:[Easy Dataset 演示视频](https://www.bilibili.com/video/BV1y8QpYGE57/) - 查看本项目的论文:[Easy Dataset: A Unified and Extensible Framework for Synthesizing LLM Fine-Tuning Data from Unstructured Documents](https://arxiv.org/abs/2507.04009v1) ## 社区教程 - [使用 Easy Dataset 完成测试集生成和模型评估](https://www.bilibili.com/video/BV1CRrVB7Eb4/) - [Easy Dataset × LLaMA Factory: 让大模型高效学习领域知识](https://buaa-act.feishu.cn/wiki/KY9xwTGs1iqHrRkjXBwcZP9WnL9) - [Easy Dataset 使用实战: 如何构建高质量数据集?](https://www.bilibili.com/video/BV1MRMnz1EGW) - [Easy Dataset 1.4 重点功能更新解读](https://www.bilibili.com/video/BV1fyJhzHEb7/) - [Easy Dataset 1.6 重点功能更新解读](https://www.bilibili.com/video/BV1Rq1hBtEJa/) - [大模型微调数据集: 基础知识科普](https://docs.easy-dataset.com/zhi-shi-ke-pu) - [实战案例1:生成汽车图片识别数据集](https://docs.easy-dataset.com/bo-ke/shi-zhan-an-li/an-li-1-sheng-cheng-qi-che-tu-pian-shi-bie-shu-ju-ji) - [实战案例2:评论情感分类数据集](https://docs.easy-dataset.com/bo-ke/shi-zhan-an-li/an-li-2-ping-lun-qing-gan-fen-lei-shu-ju-ji) - [实战案例3:物理学多轮对话数据集](https://docs.easy-dataset.com/bo-ke/shi-zhan-an-li/an-li-3-wu-li-xue-duo-lun-dui-hua-shu-ju-ji) - [实战案例4:AI 智能体安全数据集](https://docs.easy-dataset.com/bo-ke/shi-zhan-an-li/an-li-4ai-zhi-neng-ti-an-quan-shu-ju-ji) - [实战案例5:从图文 PPT 中提取数据集](https://docs.easy-dataset.com/bo-ke/shi-zhan-an-li/an-li-5-cong-tu-wen-ppt-zhong-ti-qu-shu-ju-ji) ## 贡献 我们欢迎社区的贡献!如果您想为 Easy Dataset 做出贡献,请按照以下步骤操作: 1. Fork 仓库 2. 创建新分支(`git checkout -b feature/amazing-feature`) 3. 进行更改 4. 提交更改(`git commit -m '添加一些惊人的功能'`) 5. 推送到分支(`git push origin feature/amazing-feature`) 6. 打开 Pull Request(提交至 DEV 分支) 请确保适当更新测试并遵守现有的编码风格。 ## 加交流群 & 联系作者 https://docs.easy-dataset.com/geng-duo/lian-xi-wo-men ## 许可证 本项目采用 AGPL 3.0 许可证 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。 ## 引用 如果您觉得此项目有帮助,请考虑以下列格式引用 ```bibtex @misc{miao2025easydataset, title={Easy Dataset: A Unified and Extensible Framework for Synthesizing LLM Fine-Tuning Data from Unstructured Documents}, author={Ziyang Miao and Qiyu Sun and Jingyuan Wang and Yuchen Gong and Yaowei Zheng and Shiqi Li and Richong Zhang}, year={2025}, eprint={2507.04009}, archivePrefix={arXiv}, primaryClass={cs.CL}, url={https://arxiv.org/abs/2507.04009} } ``` ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=ConardLi/easy-dataset&type=Date)](https://www.star-history.com/#ConardLi/easy-dataset&Date)
ConardLi 用 ❤️ 构建 • 关注我:公众号B站掘金知乎Youtube
================================================ FILE: app/api/check-update/route.js ================================================ import { NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs'; // Get current version function getCurrentVersion() { try { const packageJsonPath = path.join(process.cwd(), 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.version; } catch (error) { console.error('Failed to read version from package.json:', String(error)); return '1.0.0'; } } // Get latest version from GitHub async function getLatestVersion() { try { const owner = 'ConardLi'; const repo = 'easy-dataset'; const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`); if (!response.ok) { throw new Error(`GitHub API request failed: ${response.status}`); } const data = await response.json(); return data.tag_name.replace('v', ''); } catch (error) { console.error('Failed to fetch latest version:', String(error)); return null; } } // Check for updates export async function GET() { try { const currentVersion = getCurrentVersion(); const latestVersion = await getLatestVersion(); if (!latestVersion) { return NextResponse.json({ hasUpdate: false, currentVersion, latestVersion: null, error: 'Failed to fetch latest version' }); } // Simple semver-like comparison const hasUpdate = compareVersions(latestVersion, currentVersion) > 0; return NextResponse.json({ hasUpdate, currentVersion, latestVersion, releaseUrl: hasUpdate ? `https://github.com/ConardLi/easy-dataset/releases/tag/v${latestVersion}` : null }); } catch (error) { console.error('Failed to check for updates:', String(error)); return NextResponse.json( { hasUpdate: false, error: 'Failed to check for updates' }, { status: 500 } ); } } // Simple version comparison function compareVersions(a, b) { const partsA = a.split('.').map(Number); const partsB = b.split('.').map(Number); for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { const numA = i < partsA.length ? partsA[i] : 0; const numB = i < partsB.length ? partsB[i] : 0; if (numA > numB) return 1; if (numA < numB) return -1; } return 0; } ================================================ FILE: app/api/llm/fetch-models/route.js ================================================ import { NextResponse } from 'next/server'; import axios from 'axios'; // Fetch model list from provider export async function POST(request) { try { const { endpoint, providerId, apiKey } = await request.json(); if (!endpoint) { return NextResponse.json({ error: 'Missing required parameter: endpoint' }, { status: 400 }); } let url = endpoint.replace(/\/$/, ''); // Remove trailing slash // Handle Ollama endpoint if (providerId === 'ollama') { // Remove possible /v1 or other version suffix url = url.replace(/\/v\d+$/, ''); // Append /api if missing if (!url.includes('/api')) { url += '/api'; } url += '/tags'; } else { url += '/models'; } const headers = {}; if (apiKey) { headers.Authorization = `Bearer ${apiKey}`; } const response = await axios.get(url, { headers }); // Format response per provider let formattedModels = []; if (providerId === 'ollama') { // Ollama /api/tags format: { models: [{ name: 'model-name', ... }] } if (response.data.models && Array.isArray(response.data.models)) { formattedModels = response.data.models.map(item => ({ modelId: item.name, modelName: item.name, providerId })); } } else { // Default handling (OpenAI-compatible) if (response.data.data && Array.isArray(response.data.data)) { formattedModels = response.data.data.map(item => ({ modelId: item.id, modelName: item.id, providerId })); } } return NextResponse.json(formattedModels); } catch (error) { console.error('Failed to fetch model list:', String(error)); // Handle known error shapes if (error.response) { if (error.response.status === 401) { return NextResponse.json({ error: 'Invalid API key' }, { status: 401 }); } return NextResponse.json( { error: `Failed to fetch model list: ${error.response.statusText}` }, { status: error.response.status } ); } return NextResponse.json({ error: `Failed to fetch model list: ${error.message}` }, { status: 500 }); } } ================================================ FILE: app/api/llm/model/route.js ================================================ import { NextResponse } from 'next/server'; import { getLlmModelsByProviderId } from '@/lib/db/llm-models'; // Get LLM models export async function GET(request) { try { const searchParams = request.nextUrl.searchParams; let providerId = searchParams.get('providerId'); if (!providerId) { return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 }); } const models = await getLlmModelsByProviderId(providerId); if (!models) { return NextResponse.json({ error: 'LLM provider not found' }, { status: 404 }); } return NextResponse.json(models); } catch (error) { console.error('Database query error:', String(error)); return NextResponse.json({ error: 'Database query failed' }, { status: 500 }); } } // Sync latest model list export async function POST(request) { try { const { newModels, providerId } = await request.json(); const models = await getLlmModelsByProviderId(providerId); const existingModelIds = models.map(model => model.modelId); const diffModels = newModels.filter(item => !existingModelIds.includes(item.modelId)); if (diffModels.length > 0) { // return NextResponse.json(await createLlmModels(diffModels)); return NextResponse.json({ message: 'No new models to insert' }, { status: 200 }); } else { return NextResponse.json({ message: 'No new models to insert' }, { status: 200 }); } } catch (error) { return NextResponse.json({ error: 'Database insert failed' }, { status: 500 }); } } ================================================ FILE: app/api/llm/ollama/models/route.js ================================================ import { NextResponse } from 'next/server'; const OllamaClient = require('@/lib/llm/core/providers/ollama'); // Force dynamic route to prevent static generation export const dynamic = 'force-dynamic'; export async function GET(request) { try { // Read host and port from query params const { searchParams } = new URL(request.url); const host = searchParams.get('host') || '127.0.0.1'; const port = searchParams.get('port') || '11434'; // Create Ollama API client const ollama = new OllamaClient({ endpoint: `http://${host}:${port}/api` }); // Fetch model list const models = await ollama.getModels(); return NextResponse.json(models); } catch (error) { // console.error('fetch Ollama models error:', error); return NextResponse.json({ error: 'fetch Models failed' }, { status: 500 }); } } ================================================ FILE: app/api/llm/providers/route.js ================================================ import { NextResponse } from 'next/server'; import { getLlmProviders } from '@/lib/db/llm-providers'; import { sortProvidersByPriority } from '@/lib/util/providerLogo'; // Get LLM provider data export async function GET() { try { const result = await getLlmProviders(); return NextResponse.json(sortProvidersByPriority(result, item => item.id)); } catch (error) { console.error('Database query error:', String(error)); return NextResponse.json({ error: 'Database query failed' }, { status: 500 }); } } ================================================ FILE: app/api/monitoring/logs/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export const dynamic = 'force-dynamic'; export async function GET(request) { try { const { searchParams } = new URL(request.url); const timeRange = searchParams.get('timeRange') || '7d'; const projectId = searchParams.get('projectId'); const provider = searchParams.get('provider'); const status = searchParams.get('status'); const page = parseInt(searchParams.get('page') || '1', 10); const pageSize = parseInt(searchParams.get('pageSize') || '10', 10); const searchTerm = searchParams.get('search') || ''; let startDate = new Date(); if (timeRange === '24h') { startDate.setHours(startDate.getHours() - 24); } else if (timeRange === '30d') { startDate.setDate(startDate.getDate() - 30); } else { startDate.setDate(startDate.getDate() - 7); } const where = { createAt: { gte: startDate } }; if (projectId && projectId !== 'all') { where.projectId = projectId; } if (provider && provider !== 'all') { where.provider = provider; } if (status && status !== 'all') { where.status = status; } if (searchTerm) { where.OR = [{ model: { contains: searchTerm } }, { errorMessage: { contains: searchTerm } }]; } const total = await db.llmUsageLogs.count({ where }); const logs = await db.llmUsageLogs.findMany({ where, select: { id: true, projectId: true, provider: true, model: true, inputTokens: true, outputTokens: true, totalTokens: true, latency: true, status: true, errorMessage: true, createAt: true }, orderBy: { createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }); const projectIds = [...new Set(logs.map(log => log.projectId))]; const projects = await db.projects.findMany({ where: { id: { in: projectIds } }, select: { id: true, name: true } }); const projectMap = projects.reduce((acc, p) => { acc[p.id] = p.name; return acc; }, {}); const details = logs.map(log => ({ id: log.id, projectId: log.projectId, projectName: projectMap[log.projectId] || 'Unknown Project', provider: log.provider, model: log.model, status: log.status, failureReason: log.errorMessage, inputTokens: log.inputTokens, outputTokens: log.outputTokens, totalTokens: log.totalTokens, calls: 1, // Single record avgLatency: log.status === 'SUCCESS' ? (log.latency / 1000).toFixed(2) + 's' : '-', createAt: log.createAt })); return NextResponse.json({ details, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }); } catch (error) { console.error('Failed to fetch monitoring logs:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/monitoring/stats/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export const dynamic = 'force-dynamic'; export async function GET(request) { try { const { searchParams } = new URL(request.url); const timeRange = searchParams.get('timeRange') || '7d'; // 24h, 7d, 30d const projectId = searchParams.get('projectId'); const provider = searchParams.get('provider'); const status = searchParams.get('status'); let startDate = new Date(); if (timeRange === '24h') { startDate.setHours(startDate.getHours() - 24); } else if (timeRange === '30d') { startDate.setDate(startDate.getDate() - 30); } else { startDate.setDate(startDate.getDate() - 7); } const where = { createAt: { gte: startDate } }; if (projectId && projectId !== 'all') { where.projectId = projectId; } if (provider && provider !== 'all') { where.provider = provider; } if (status && status !== 'all') { where.status = status; } // 1. Fetch data for aggregation // Note: Prisma aggregation can be slow on very large datasets. If needed, optimize with pre-aggregated tables. const logs = await db.llmUsageLogs.findMany({ where, select: { id: true, projectId: true, provider: true, model: true, inputTokens: true, outputTokens: true, totalTokens: true, latency: true, status: true, errorMessage: true, createAt: true, dateString: true }, orderBy: { createAt: 'desc' } }); // Build project name map const projects = await db.projects.findMany({ select: { id: true, name: true } }); const projectMap = projects.reduce((acc, p) => { acc[p.id] = p.name; return acc; }, {}); // 2. Process and aggregate const summary = { totalTokens: 0, inputTokens: 0, outputTokens: 0, totalCalls: logs.length, successCalls: 0, failedCalls: 0, totalLatency: 0, avgLatency: 0 }; const trendMap = {}; const modelStats = {}; const detailedStatsMap = {}; // Key: projectId-model-status-errorMessage logs.forEach(log => { // Summary summary.totalTokens += log.totalTokens; summary.inputTokens += log.inputTokens; summary.outputTokens += log.outputTokens; if (log.status === 'SUCCESS') { summary.successCalls++; summary.totalLatency += log.latency; } else { summary.failedCalls++; } // Trend (by day or hour) let timeKey; if (timeRange === '24h') { const date = new Date(log.createAt); timeKey = `${String(date.getHours()).padStart(2, '0')}:00`; } else { timeKey = log.dateString.slice(5); // MM-DD } if (!trendMap[timeKey]) { trendMap[timeKey] = { name: timeKey, input: 0, output: 0 }; } trendMap[timeKey].input += log.inputTokens; trendMap[timeKey].output += log.outputTokens; // Model Distribution const modelKey = log.model; if (!modelStats[modelKey]) { modelStats[modelKey] = { name: modelKey, value: 0 }; } modelStats[modelKey].value += log.totalTokens; // Detailed Table Aggregation // Key: projectId + model + status + (errorMessage || '') const errorKey = log.errorMessage || ''; const detailKey = `${log.projectId}|${log.model}|${log.status}|${errorKey}`; if (!detailedStatsMap[detailKey]) { detailedStatsMap[detailKey] = { projectId: log.projectId, projectName: projectMap[log.projectId] || 'Unknown Project', provider: log.provider, model: log.model, status: log.status, failureReason: log.errorMessage, inputTokens: 0, outputTokens: 0, totalTokens: 0, calls: 0, totalLatency: 0 }; } const detailItem = detailedStatsMap[detailKey]; detailItem.inputTokens += log.inputTokens; detailItem.outputTokens += log.outputTokens; detailItem.totalTokens += log.totalTokens; detailItem.calls += 1; if (log.status === 'SUCCESS') { detailItem.totalLatency += log.latency; } }); // Calculate averages if (summary.successCalls > 0) { summary.avgLatency = Math.round(summary.totalLatency / summary.successCalls); } summary.avgTokensPerCall = summary.totalCalls > 0 ? Math.round(summary.totalTokens / summary.totalCalls) : 0; summary.failureRate = summary.totalCalls > 0 ? summary.failedCalls / summary.totalCalls : 0; // Format chart data const trend = Object.values(trendMap).sort((a, b) => { // Simple sorting; for production use, consider stricter time ordering. return a.name.localeCompare(b.name); }); const modelDistribution = Object.values(modelStats).sort((a, b) => b.value - a.value); // Format detailed table data const details = Object.values(detailedStatsMap) .map(item => ({ ...item, avgLatency: item.status === 'SUCCESS' && item.calls > 0 ? (item.totalLatency / item.calls / 1000).toFixed(2) + 's' : '-' })) .sort((a, b) => b.totalTokens - a.totalTokens); // Default sorting by token usage return NextResponse.json({ summary, trend, modelDistribution, details, projects }); } catch (error) { console.error('Failed to fetch monitoring stats:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/monitoring/summary/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export const dynamic = 'force-dynamic'; export async function GET(request) { try { const { searchParams } = new URL(request.url); const timeRange = searchParams.get('timeRange') || '7d'; const projectId = searchParams.get('projectId'); const provider = searchParams.get('provider'); const status = searchParams.get('status'); let startDate = new Date(); if (timeRange === '24h') { startDate.setHours(startDate.getHours() - 24); } else if (timeRange === '30d') { startDate.setDate(startDate.getDate() - 30); } else { startDate.setDate(startDate.getDate() - 7); } const where = { createAt: { gte: startDate } }; if (projectId && projectId !== 'all') { where.projectId = projectId; } if (provider && provider !== 'all') { where.provider = provider; } if (status && status !== 'all') { where.status = status; } const logs = await db.llmUsageLogs.findMany({ where, select: { inputTokens: true, outputTokens: true, totalTokens: true, latency: true, status: true, createAt: true, dateString: true, model: true } }); const summary = { totalTokens: 0, inputTokens: 0, outputTokens: 0, totalCalls: logs.length, successCalls: 0, failedCalls: 0, totalLatency: 0, avgLatency: 0 }; const trendMap = {}; const modelStats = {}; logs.forEach(log => { summary.totalTokens += log.totalTokens; summary.inputTokens += log.inputTokens; summary.outputTokens += log.outputTokens; if (log.status === 'SUCCESS') { summary.successCalls++; summary.totalLatency += log.latency; } else { summary.failedCalls++; } let timeKey; if (timeRange === '24h') { const date = new Date(log.createAt); timeKey = `${String(date.getHours()).padStart(2, '0')}:00`; } else { timeKey = log.dateString.slice(5); } if (!trendMap[timeKey]) { trendMap[timeKey] = { name: timeKey, input: 0, output: 0 }; } trendMap[timeKey].input += log.inputTokens; trendMap[timeKey].output += log.outputTokens; const modelKey = log.model; if (!modelStats[modelKey]) { modelStats[modelKey] = { name: modelKey, value: 0 }; } modelStats[modelKey].value += log.totalTokens; }); if (summary.successCalls > 0) { summary.avgLatency = Math.round(summary.totalLatency / summary.successCalls); } summary.avgTokensPerCall = summary.totalCalls > 0 ? Math.round(summary.totalTokens / summary.totalCalls) : 0; summary.failureRate = summary.totalCalls > 0 ? summary.failedCalls / summary.totalCalls : 0; const trend = Object.values(trendMap).sort((a, b) => a.name.localeCompare(b.name)); const modelDistribution = Object.values(modelStats).sort((a, b) => b.value - a.value); const projects = await db.projects.findMany({ select: { id: true, name: true }, orderBy: { createAt: 'desc' } }); const allLogs = await db.llmUsageLogs.findMany({ select: { provider: true }, distinct: ['provider'] }); const providers = allLogs.map(log => log.provider).filter(Boolean); return NextResponse.json({ summary, trend, modelDistribution, projects, providers }); } catch (error) { console.error('Failed to fetch monitoring summary:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/batch-add-manual-ga/route.js ================================================ import { NextResponse } from 'next/server'; import { getUploadFileInfoById } from '@/lib/db/upload-files'; import { createGaPairs, getGaPairsByFileId } from '@/lib/db/ga-pairs'; /** * 批量手动添加 GA 对到多个文件 */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const { fileIds, gaPair, appendMode = false } = body; if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) { return NextResponse.json({ error: 'File IDs array is required' }, { status: 400 }); } if (!gaPair || !gaPair.genreTitle || !gaPair.audienceTitle) { return NextResponse.json({ error: 'GA pair with genreTitle and audienceTitle is required' }, { status: 400 }); } console.log('开始处理批量手动添加GA对请求'); console.log('项目ID:', projectId); console.log('请求的文件IDs:', fileIds); console.log('GA对:', gaPair); // 使用 getUploadFileInfoById 逐个验证文件 const validFiles = []; const invalidFileIds = []; for (const fileId of fileIds) { try { console.log(`正在验证文件: ${fileId}`); const fileInfo = await getUploadFileInfoById(fileId); if (fileInfo && fileInfo.projectId === projectId) { console.log(`文件验证成功: ${fileInfo.fileName}`); validFiles.push(fileInfo); } else if (fileInfo) { console.log(`文件属于其他项目: ${fileInfo.projectId} != ${projectId}`); invalidFileIds.push(fileId); } else { console.log(`文件不存在: ${fileId}`); invalidFileIds.push(fileId); } } catch (error) { console.error(`验证文件 ${fileId} 时出错:`, String(error)); invalidFileIds.push(fileId); } } console.log(`文件验证完成: 有效${validFiles.length}个, 无效${invalidFileIds.length}个`); if (validFiles.length === 0) { return NextResponse.json( { error: 'No valid files found', debug: { projectId, requestedIds: fileIds, invalidIds: invalidFileIds, message: 'None of the requested files belong to this project or exist in the database' } }, { status: 404 } ); } // 批量手动添加 GA 对 console.log('开始批量手动添加GA对...'); console.log('追加模式:', appendMode); const results = []; for (const file of validFiles) { try { console.log(`处理文件: ${file.fileName}`); // 检查是否已存在 GA 对 const existingPairs = await getGaPairsByFileId(file.id); let pairNumber = 1; if (appendMode && existingPairs && existingPairs.length > 0) { // 追加模式:在现有 GA 对后面添加 pairNumber = existingPairs.length + 1; } else if (!appendMode && existingPairs && existingPairs.length > 0) { // 非追加模式:如果已存在 GA 对则跳过 console.log(`文件 ${file.fileName} 已存在GA对,跳过`); results.push({ fileId: file.id, fileName: file.fileName, success: true, skipped: true, message: 'GA pairs already exist' }); continue; } // 创建 GA 对数据 const gaPairData = [ { projectId, fileId: file.id, pairNumber, genreTitle: gaPair.genreTitle.trim(), genreDesc: gaPair.genreDesc?.trim() || '', audienceTitle: gaPair.audienceTitle.trim(), audienceDesc: gaPair.audienceDesc?.trim() || '', isActive: true } ]; // 保存 GA 对 if (appendMode) { // 追加模式:只创建新的 GA 对 await createGaPairs(gaPairData); } else { // 非追加模式:使用 saveGaPairs 替换现有的 const { saveGaPairs } = await import('@/lib/db/ga-pairs'); await saveGaPairs(projectId, file.id, [ { genre: { title: gaPair.genreTitle.trim(), description: gaPair.genreDesc?.trim() || '' }, audience: { title: gaPair.audienceTitle.trim(), description: gaPair.audienceDesc?.trim() || '' } } ]); } results.push({ fileId: file.id, fileName: file.fileName, success: true, skipped: false, message: 'GA pair added successfully' }); console.log(`成功为文件 ${file.fileName} 添加GA对`); } catch (error) { console.error(`为文件 ${file.fileName} 添加GA对失败:`, error); results.push({ fileId: file.id, fileName: file.fileName, success: false, skipped: false, error: error.message, message: `Failed: ${error.message}` }); } } // 统计结果 const successCount = results.filter(r => r.success).length; const failureCount = results.filter(r => !r.success).length; console.log(`批量手动添加完成: 成功${successCount}个, 失败${failureCount}个`); return NextResponse.json({ success: true, data: results, summary: { total: results.length, success: successCount, failure: failureCount, processed: validFiles.length, skipped: invalidFileIds.length }, message: `Added GA pairs to ${successCount} files, ${failureCount} failed, ${invalidFileIds.length} files not found` }); } catch (error) { console.error('Error batch adding manual GA pairs:', String(error)); return NextResponse.json({ error: String(error) || 'Failed to batch add manual GA pairs' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/batch-delete-files/route.js ================================================ import { NextResponse } from 'next/server'; import { getUploadFileInfoById, delUploadFileInfoById } from '@/lib/db/upload-files'; import { getProject } from '@/lib/db/projects'; import { getProjectChunks, getProjectTocByName } from '@/lib/file/text-splitter'; import { batchSaveTags } from '@/lib/db/tags'; import { handleDomainTree } from '@/lib/util/domain-tree'; import path from 'path'; import { getProjectRoot } from '@/lib/db/base'; import { promises as fs } from 'fs'; /** * 批量删除文件 * 复用单个文件删除的完整逻辑,包括领域树修订 */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const { fileIds, domainTreeAction = 'keep', model, language = '中文' } = body; if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) { return NextResponse.json({ error: 'File IDs array is required' }, { status: 400 }); } console.log('开始处理批量删除文件请求'); console.log('项目ID:', projectId); console.log('请求的文件IDs:', fileIds); console.log('领域树操作:', domainTreeAction); // 获取项目信息 const project = await getProject(projectId); if (!project) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 验证文件并删除 const results = []; const deletedTocs = []; let deletedCount = 0; let failedCount = 0; let totalStats = { deletedChunks: 0, deletedQuestions: 0, deletedDatasets: 0 }; for (const fileId of fileIds) { try { console.log(`正在验证文件: ${fileId}`); const fileInfo = await getUploadFileInfoById(fileId); if (!fileInfo) { console.log(`文件不存在: ${fileId}`); results.push({ fileId, success: false, error: 'File not found' }); failedCount++; continue; } if (fileInfo.projectId !== projectId) { console.log(`文件属于其他项目: ${fileInfo.projectId} != ${projectId}`); results.push({ fileId, success: false, error: 'File belongs to another project' }); failedCount++; continue; } // 删除文件及其相关的文本块、问题和数据集 console.log(`删除文件: ${fileInfo.fileName}`); const { stats, fileName } = await delUploadFileInfoById(fileId); // 累计统计信息 totalStats.deletedChunks += stats.deletedChunks || 0; totalStats.deletedQuestions += stats.deletedQuestions || 0; totalStats.deletedDatasets += stats.deletedDatasets || 0; // 获取并保存删除的 TOC 信息 const deleteToc = await getProjectTocByName(projectId, fileName); if (deleteToc) { deletedTocs.push(deleteToc); } // 删除 TOC 文件 try { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const tocDir = path.join(projectPath, 'toc'); const baseName = path.basename(fileInfo.fileName, path.extname(fileInfo.fileName)); const tocPath = path.join(tocDir, `${baseName}-toc.json`); await fs.unlink(tocPath); console.log(`成功删除 TOC 文件: ${tocPath}`); } catch (error) { console.error(`删除 TOC 文件失败:`, String(error)); } results.push({ fileId, fileName: fileInfo.fileName, success: true, stats }); deletedCount++; console.log(`成功删除文件: ${fileInfo.fileName}`); } catch (error) { console.error(`删除文件 ${fileId} 时出错:`, error); results.push({ fileId, success: false, error: error.message }); failedCount++; } } console.log(`批量删除完成: 成功${deletedCount}个, 失败${failedCount}个`); // 如果选择了保持领域树不变,直接返回删除结果 if (domainTreeAction === 'keep') { return NextResponse.json({ success: true, deletedCount, failedCount, total: fileIds.length, results, stats: totalStats, domainTreeAction: 'keep', message: `Successfully deleted ${deletedCount} files, ${failedCount} failed` }); } // 处理领域树更新 try { // 获取项目的所有文件 const { chunks, toc } = await getProjectChunks(projectId); // 如果不存在文本块,说明项目已经没有文件了 if (!chunks || chunks.length === 0) { // 清空领域树 await batchSaveTags(projectId, []); return NextResponse.json({ success: true, deletedCount, failedCount, total: fileIds.length, results, stats: totalStats, domainTreeAction, message: `Successfully deleted ${deletedCount} files, domain tree cleared`, domainTreeCleared: true }); } // 调用领域树处理模块 await handleDomainTree({ projectId, action: domainTreeAction, allToc: toc, model: model, language, deleteToc: deletedTocs.length > 0 ? deletedTocs : undefined, project }); console.log('领域树更新成功'); } catch (error) { console.error('Error updating domain tree after batch deletion:', String(error)); // 即使领域树更新失败,也不影响文件删除的结果 } return NextResponse.json({ success: true, deletedCount, failedCount, total: fileIds.length, results, stats: totalStats, domainTreeAction, message: `Successfully deleted ${deletedCount} files, ${failedCount} failed` }); } catch (error) { console.error('Error batch deleting files:', String(error)); return NextResponse.json({ error: String(error) || 'Failed to batch delete files' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/batch-generateGA/route.js ================================================ import { NextResponse } from 'next/server'; import { batchGenerateGaPairs } from '@/lib/services/ga/ga-pairs'; import { getUploadFileInfoById } from '@/lib/db/upload-files'; // 导入单个文件查询函数 /** * 批量生成多个文件的 GA 对 */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const { fileIds, modelConfigId, language = '中文', appendMode = false } = body; if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) { return NextResponse.json({ error: 'File IDs array is required' }, { status: 400 }); } if (!modelConfigId) { return NextResponse.json({ error: 'Model configuration ID is required' }, { status: 400 }); } console.log('开始处理批量生成GA对请求'); console.log('项目ID:', projectId); console.log('请求的文件IDs:', fileIds); // 使用 getUploadFileInfoById 逐个验证文件 const validFiles = []; const invalidFileIds = []; for (const fileId of fileIds) { try { console.log(`正在验证文件: ${fileId}`); const fileInfo = await getUploadFileInfoById(fileId); if (fileInfo && fileInfo.projectId === projectId) { console.log(`文件验证成功: ${fileInfo.fileName}`); validFiles.push(fileInfo); } else if (fileInfo) { console.log(`文件属于其他项目: ${fileInfo.projectId} != ${projectId}`); invalidFileIds.push(fileId); } else { console.log(`文件不存在: ${fileId}`); invalidFileIds.push(fileId); } } catch (error) { console.error(`验证文件 ${fileId} 时出错:`, String(error)); invalidFileIds.push(fileId); } } console.log(`文件验证完成: 有效${validFiles.length}个, 无效${invalidFileIds.length}个`); if (validFiles.length === 0) { return NextResponse.json( { error: 'No valid files found', debug: { projectId, requestedIds: fileIds, invalidIds: invalidFileIds, message: 'None of the requested files belong to this project or exist in the database' } }, { status: 404 } ); } // 批量生成 GA 对 console.log('开始批量生成GA对...'); console.log('追加模式:', appendMode); const results = await batchGenerateGaPairs( projectId, validFiles, modelConfigId, language, appendMode // 传递追加模式参数 ); // 统计结果 const successCount = results.filter(r => r.success).length; const failureCount = results.filter(r => !r.success).length; console.log(`批量生成完成: 成功${successCount}个, 失败${failureCount}个`); return NextResponse.json({ success: true, data: results, summary: { total: results.length, success: successCount, failure: failureCount, processed: validFiles.length, skipped: invalidFileIds.length }, message: `Generated GA pairs for ${successCount} files, ${failureCount} failed, ${invalidFileIds.length} files not found` }); } catch (error) { console.error('Error batch generating GA pairs:', String(error)); return NextResponse.json({ error: String(error) || 'Failed to batch generate GA pairs' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/[taskId]/current/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import LLMClient from '@/lib/llm/core/index'; import { getModelConfigById } from '@/lib/db/model-config'; /** * Get current question and generate answers from two models */ export async function GET(request, { params }) { try { const { projectId, taskId } = params; const task = await db.task.findFirst({ where: { id: taskId, projectId, taskType: 'blind-test' } }); if (!task) { return NextResponse.json({ code: 404, error: 'Task not found' }, { status: 404 }); } if (task.status !== 0) { return NextResponse.json({ code: 400, error: 'Task has ended' }, { status: 400 }); } // Parse task detail let detail = {}; let modelInfo = {}; try { detail = task.detail ? JSON.parse(task.detail) : {}; modelInfo = task.modelInfo ? JSON.parse(task.modelInfo) : {}; } catch (e) { console.error('Failed to parse task detail:', e); } const questionIds = detail.questionIds || detail.evalDatasetIds || []; const currentIndex = detail.currentIndex || 0; // Check if all questions are completed if (questionIds.length === 0 || currentIndex >= questionIds.length) { return NextResponse.json({ code: 0, data: { completed: true, message: 'All questions completed' } }); } // Fetch current question const currentQuestionId = questionIds[currentIndex]; const currentQuestion = await db.evalDatasets.findUnique({ where: { id: currentQuestionId }, select: { id: true, question: true, questionType: true, correctAnswer: true, tags: true } }); if (!currentQuestion) { return NextResponse.json({ code: 404, error: 'Question not found' }, { status: 404 }); } // Fetch both model configs const [modelConfigA, modelConfigB] = await Promise.all([ getModelConfigById(modelInfo.modelA.providerId), getModelConfigById(modelInfo.modelB.providerId) ]); if (!modelConfigA || !modelConfigB) { return NextResponse.json({ code: 400, error: 'Model configuration not found' }, { status: 400 }); } // Build prompts const systemPrompt = "You are a helpful assistant. Provide detailed and accurate answers to the user's question."; const userPrompt = currentQuestion.question; // Call both models in parallel const startTimeA = Date.now(); const startTimeB = Date.now(); let answerA = ''; let answerB = ''; let errorA = null; let errorB = null; let durationA = 0; let durationB = 0; try { // Call model A const clientA = new LLMClient(modelConfigA); const resultA = await clientA.chat([ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ]); answerA = resultA.text || ''; durationA = Date.now() - startTimeA; } catch (err) { console.error('Model A call failed:', err); errorA = err.message; durationA = Date.now() - startTimeA; } try { // Call model B const clientB = new LLMClient(modelConfigB); const resultB = await clientB.chat([ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ]); answerB = resultB.text || ''; durationB = Date.now() - startTimeB; } catch (err) { console.error('Model B call failed:', err); errorB = err.message; durationB = Date.now() - startTimeB; } // Randomly swap positions (core blind-test behavior) const isSwapped = Math.random() > 0.5; return NextResponse.json({ code: 0, data: { completed: false, currentIndex, totalCount: evalDatasetIds.length, question: currentQuestion, // Blind test: do not reveal which model is which leftAnswer: { content: isSwapped ? answerB : answerA, error: isSwapped ? errorB : errorA, duration: isSwapped ? durationB : durationA }, rightAnswer: { content: isSwapped ? answerA : answerB, error: isSwapped ? errorA : errorB, duration: isSwapped ? durationA : durationB }, // Server stores the actual mapping for scoring _swap: isSwapped } }); } catch (error) { console.error('Failed to fetch current question:', error); return NextResponse.json( { code: 500, error: 'Failed to fetch current question', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/[taskId]/question/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; /** * Get current question info (including random swap info) */ export async function GET(request, { params }) { const { projectId, taskId } = params; try { if (!projectId || !taskId) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } // Fetch task const task = await db.task.findUnique({ where: { id: taskId } }); if (!task || task.taskType !== 'blind-test') { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } // Parse task detail const detail = JSON.parse(task.detail || '{}'); // Support both evalDatasetIds and questionIds const questionIds = detail.questionIds || detail.evalDatasetIds || []; const currentIndex = detail.currentIndex || 0; // Check if task is completed if (questionIds.length === 0 || currentIndex >= questionIds.length) { return NextResponse.json({ completed: true, currentIndex, totalQuestions: questionIds.length }); } // Fetch current question const currentQuestionId = questionIds[currentIndex]; const currentQuestion = await db.evalDatasets.findUnique({ where: { id: currentQuestionId } }); if (!currentQuestion) { return NextResponse.json({ error: 'Question not found' }, { status: 404 }); } // Randomly decide whether to swap (core blind-test behavior) const isSwapped = Math.random() > 0.5; return NextResponse.json({ questionId: currentQuestion.id, question: currentQuestion.question, answer: currentQuestion.correctAnswer || '', questionIndex: currentIndex + 1, totalQuestions: questionIds.length, isSwapped }); } catch (error) { console.error('Failed to fetch question info:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/[taskId]/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; /** * Get blind-test task details * Results are fetched from EvalResults table */ export async function GET(request, { params }) { try { const { projectId, taskId } = params; const task = await db.task.findFirst({ where: { id: taskId, projectId, taskType: 'blind-test' } }); if (!task) { return NextResponse.json({ code: 404, error: 'Task not found' }, { status: 404 }); } let detail = {}; let modelInfo = {}; try { detail = task.detail ? JSON.parse(task.detail) : {}; modelInfo = task.modelInfo ? JSON.parse(task.modelInfo) : {}; } catch (e) { console.error('Failed to parse task detail:', e); } // Fetch all related evaluation questions const evalDatasetIds = detail.evalDatasetIds || []; const evalDatasets = await db.evalDatasets.findMany({ where: { id: { in: evalDatasetIds } }, select: { id: true, question: true, questionType: true, correctAnswer: true, tags: true } }); // Sort by evalDatasetIds order const orderedDatasets = evalDatasetIds.map(id => evalDatasets.find(d => d.id === id)).filter(Boolean); // Fetch results from EvalResults table const evalResults = await db.evalResults.findMany({ where: { taskId }, orderBy: { createAt: 'asc' } }); // Parse results into the format expected by frontend const results = evalResults.map(r => { let modelAnswer = {}; let judgeData = {}; try { modelAnswer = JSON.parse(r.modelAnswer || '{}'); judgeData = JSON.parse(r.judgeResponse || '{}'); } catch (e) { // Ignore parse errors } return { questionId: r.evalDatasetId, vote: judgeData.vote, isSwapped: judgeData.isSwapped, modelAScore: judgeData.modelAScore || 0, modelBScore: judgeData.modelBScore || 0, leftAnswer: modelAnswer.leftAnswer || '', rightAnswer: modelAnswer.rightAnswer || '', timestamp: r.createAt }; }); return NextResponse.json({ code: 0, data: { ...task, detail: { ...detail, results // Include results from EvalResults table }, modelInfo, evalDatasets: orderedDatasets } }); } catch (error) { console.error('Failed to fetch blind-test task details:', error); return NextResponse.json( { code: 500, error: 'Failed to fetch blind-test task details', message: error.message }, { status: 500 } ); } } /** * Update blind-test task (interrupt/stop) */ export async function PUT(request, { params }) { try { const { projectId, taskId } = params; const { action } = await request.json(); const task = await db.task.findFirst({ where: { id: taskId, projectId, taskType: 'blind-test' } }); if (!task) { return NextResponse.json({ code: 404, error: 'Task not found' }, { status: 404 }); } if (action === 'interrupt') { if (task.status !== 0) { return NextResponse.json({ code: 400, error: 'Only running tasks can be interrupted' }, { status: 400 }); } const updatedTask = await db.task.update({ where: { id: taskId }, data: { status: 3, // Interrupted endTime: new Date() } }); return NextResponse.json({ code: 0, data: updatedTask, message: 'Task interrupted' }); } return NextResponse.json({ code: 400, error: 'Unknown action' }, { status: 400 }); } catch (error) { console.error('Failed to update blind-test task:', error); return NextResponse.json( { code: 500, error: 'Failed to update blind-test task', message: error.message }, { status: 500 } ); } } /** * Delete blind-test task and its results */ export async function DELETE(request, { params }) { try { const { projectId, taskId } = params; const task = await db.task.findFirst({ where: { id: taskId, projectId, taskType: 'blind-test' } }); if (!task) { return NextResponse.json({ code: 404, error: 'Task not found' }, { status: 404 }); } // Delete related EvalResults first await db.evalResults.deleteMany({ where: { taskId } }); // Then delete the task await db.task.delete({ where: { id: taskId } }); return NextResponse.json({ code: 0, message: 'Task deleted' }); } catch (error) { console.error('Failed to delete blind-test task:', error); return NextResponse.json( { code: 500, error: 'Failed to delete blind-test task', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/[taskId]/stream/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import LLMClient from '@/lib/llm/core/index'; import { getModelConfigById } from '@/lib/db/model-config'; /** * Stream answers from two models for the current question */ export async function GET(request, { params }) { const { projectId, taskId } = params; try { if (!projectId || !taskId) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } // Fetch task const task = await db.task.findUnique({ where: { id: taskId } }); if (!task || task.taskType !== 'blind-test') { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } // Parse task detail const detail = JSON.parse(task.detail || '{}'); const modelInfo = JSON.parse(task.modelInfo || '{}'); const { questionIds = [], currentIndex = 0 } = detail; // Check if task is completed if (currentIndex >= questionIds.length) { return NextResponse.json({ completed: true }); } // Fetch current question const currentQuestionId = questionIds[currentIndex]; const currentQuestion = await db.evalDatasets.findUnique({ where: { id: currentQuestionId } }); if (!currentQuestion) { return NextResponse.json({ error: 'Question not found' }, { status: 404 }); } // Fetch model configs const [modelConfigA, modelConfigB] = await Promise.all([ getModelConfigById(modelInfo.modelA.providerId), getModelConfigById(modelInfo.modelB.providerId) ]); if (!modelConfigA || !modelConfigB) { return NextResponse.json({ error: 'Model configuration not found' }, { status: 400 }); } // Randomly swap positions (core blind-test behavior) const isSwapped = Math.random() > 0.5; // Create streaming response const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { try { // Send init message controller.enqueue( encoder.encode( JSON.stringify({ type: 'init', question: currentQuestion.question, questionId: currentQuestion.id, questionIndex: currentIndex + 1, totalQuestions: questionIds.length, isSwapped }) + '\n' ) ); // Prepare messages const messages = [ { role: 'system', content: "You are a helpful assistant. Provide detailed and accurate answers to the user's question." }, { role: 'user', content: currentQuestion.question } ]; // Create LLM clients const clientA = new LLMClient({ projectId, ...modelConfigA }); const clientB = new LLMClient({ projectId, ...modelConfigB }); let answerA = ''; let answerB = ''; const startTime = Date.now(); // Call both models in parallel (streaming) await Promise.all([ (async () => { try { const response = await clientA.chatStreamAPI(messages); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); answerA += chunk; // Send chunk update controller.enqueue( encoder.encode( JSON.stringify({ type: 'chunk', model: isSwapped ? 'B' : 'A', content: chunk }) + '\n' ) ); } } catch (err) { console.error('Model A call failed:', err); controller.enqueue( encoder.encode( JSON.stringify({ type: 'error', model: isSwapped ? 'B' : 'A', error: err.message }) + '\n' ) ); } })(), (async () => { try { const response = await clientB.chatStreamAPI(messages); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); answerB += chunk; // Send chunk update controller.enqueue( encoder.encode( JSON.stringify({ type: 'chunk', model: isSwapped ? 'A' : 'B', content: chunk }) + '\n' ) ); } } catch (err) { console.error('Model B call failed:', err); controller.enqueue( encoder.encode( JSON.stringify({ type: 'error', model: isSwapped ? 'A' : 'B', error: err.message }) + '\n' ) ); } })() ]); const duration = Date.now() - startTime; // Send done message controller.enqueue( encoder.encode( JSON.stringify({ type: 'done', duration, answerA: isSwapped ? answerB : answerA, answerB: isSwapped ? answerA : answerB }) + '\n' ) ); controller.close(); } catch (error) { console.error('Streaming handler failed:', error); controller.error(error); } } }); return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); } catch (error) { console.error('API error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/[taskId]/stream-model/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import LLMClient from '@/lib/llm/core/index'; import { getModelConfigById } from '@/lib/db/model-config'; /** * Stream answer for a specified model * Query param: model=A or model=B */ export async function GET(request, { params }) { const { projectId, taskId } = params; const { searchParams } = new URL(request.url); const modelType = searchParams.get('model'); // 'A' or 'B' try { if (!projectId || !taskId) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } if (!modelType || !['A', 'B'].includes(modelType)) { return NextResponse.json({ error: 'Model type must be specified (A or B)' }, { status: 400 }); } // Fetch task const task = await db.task.findUnique({ where: { id: taskId } }); if (!task || task.taskType !== 'blind-test') { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } // Parse task detail const detail = JSON.parse(task.detail || '{}'); const modelInfo = JSON.parse(task.modelInfo || '{}'); // Support both evalDatasetIds and questionIds const questionIds = detail.questionIds || detail.evalDatasetIds || []; const currentIndex = detail.currentIndex || 0; // Check if task is completed if (questionIds.length === 0 || currentIndex >= questionIds.length) { return NextResponse.json({ completed: true }); } // Fetch current question const currentQuestionId = questionIds[currentIndex]; const currentQuestion = await db.evalDatasets.findUnique({ where: { id: currentQuestionId } }); if (!currentQuestion) { return NextResponse.json({ error: 'Question not found' }, { status: 404 }); } // Resolve model config based on modelType const modelConfigKey = modelType === 'A' ? 'modelA' : 'modelB'; const modelConfig = await getModelConfigById(modelInfo[modelConfigKey].id); if (!modelConfig) { return NextResponse.json({ error: 'Model configuration not found' }, { status: 400 }); } // Prepare messages const messages = [ { role: 'system', content: "You are a helpful assistant. Provide detailed and accurate answers to the user's question." }, { role: 'user', content: currentQuestion.question } ]; // Create LLM client const client = new LLMClient({ projectId, ...modelConfig }); // Call streaming API and return response directly const response = await client.chatStreamAPI(messages); return new Response(response.body, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); } catch (error) { console.error(`Model ${modelType} streaming call failed:`, error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/[taskId]/vote/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; /** * Submit vote result * vote: 'left' | 'right' | 'both_good' | 'both_bad' * Results are stored in EvalResults table */ export async function POST(request, { params }) { try { const { projectId, taskId } = params; const { vote, questionId, isSwapped, leftAnswer, rightAnswer } = await request.json(); // Validate vote option const validVotes = ['left', 'right', 'both_good', 'both_bad']; if (!validVotes.includes(vote)) { return NextResponse.json({ code: 400, error: 'Invalid vote option' }, { status: 400 }); } if (!questionId) { return NextResponse.json({ code: 400, error: 'Question ID is required' }, { status: 400 }); } const task = await db.task.findFirst({ where: { id: taskId, projectId, taskType: 'blind-test' } }); if (!task) { return NextResponse.json({ code: 404, error: 'Task not found' }, { status: 404 }); } if (task.status !== 0) { return NextResponse.json({ code: 400, error: 'Task has ended' }, { status: 400 }); } // Parse task details let detail = {}; try { detail = task.detail ? JSON.parse(task.detail) : {}; } catch (e) { console.error('Failed to parse task detail:', e); } // Calculate scores // isSwapped: true means left is model B and right is model A // isSwapped: false means left is model A and right is model B let modelAScore = 0; let modelBScore = 0; if (vote === 'left') { if (isSwapped) { modelBScore = 1; // Left is B } else { modelAScore = 1; // Left is A } } else if (vote === 'right') { if (isSwapped) { modelAScore = 1; // Right is A } else { modelBScore = 1; // Right is B } } else if (vote === 'both_good') { modelAScore = 0.5; modelBScore = 0.5; } // both_bad: both scores remain 0 // Store result in EvalResults table const evalResult = await db.evalResults.create({ data: { projectId, taskId, evalDatasetId: questionId, modelAnswer: JSON.stringify({ leftAnswer: leftAnswer || '', rightAnswer: rightAnswer || '' }), score: modelAScore, // Store modelA score for sorting/aggregation isCorrect: false, // Not applicable for blind-test judgeResponse: JSON.stringify({ vote, isSwapped, modelAScore, modelBScore }), duration: 0, status: 0 } }); // Update task progress const evalDatasetIds = detail.evalDatasetIds || []; const newCurrentIndex = (detail.currentIndex || 0) + 1; const isCompleted = newCurrentIndex >= evalDatasetIds.length; const updatedDetail = { ...detail, currentIndex: newCurrentIndex }; await db.task.update({ where: { id: taskId }, data: { detail: JSON.stringify(updatedDetail), completedCount: newCurrentIndex, status: isCompleted ? 1 : 0, // 1-completed, 0-running endTime: isCompleted ? new Date() : null } }); // Calculate current total scores from EvalResults const allResults = await db.evalResults.findMany({ where: { taskId }, select: { judgeResponse: true } }); let totalModelAScore = 0; let totalModelBScore = 0; for (const r of allResults) { try { const judge = JSON.parse(r.judgeResponse || '{}'); totalModelAScore += judge.modelAScore || 0; totalModelBScore += judge.modelBScore || 0; } catch (e) { // Ignore parse errors } } return NextResponse.json({ code: 0, data: { success: true, isCompleted, currentIndex: newCurrentIndex, totalCount: evalDatasetIds.length, scores: { modelA: totalModelAScore, modelB: totalModelBScore } }, message: isCompleted ? 'Blind-test task completed' : 'Vote recorded' }); } catch (error) { console.error('Failed to submit vote result:', error); return NextResponse.json( { code: 500, error: 'Failed to submit vote result', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/blind-test-tasks/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; /** * Get all blind-test tasks for a project */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page') || '1'); const pageSize = parseInt(searchParams.get('pageSize') || '20'); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const skip = (page - 1) * pageSize; // Fetch task list and total count const [tasks, total] = await Promise.all([ db.task.findMany({ where: { projectId, taskType: 'blind-test' }, orderBy: { createAt: 'desc' }, skip, take: pageSize }), db.task.count({ where: { projectId, taskType: 'blind-test' } }) ]); // Fetch evaluation results for all tasks to calculate scores const taskIds = tasks.map(t => t.id); const allEvalResults = await db.evalResults.findMany({ where: { taskId: { in: taskIds } }, select: { taskId: true, judgeResponse: true } }); // Group results by taskId and calculate scores const taskScores = {}; for (const result of allEvalResults) { if (!taskScores[result.taskId]) { taskScores[result.taskId] = { modelAScore: 0, modelBScore: 0 }; } try { const judge = JSON.parse(result.judgeResponse || '{}'); taskScores[result.taskId].modelAScore += judge.modelAScore || 0; taskScores[result.taskId].modelBScore += judge.modelBScore || 0; } catch (e) { // Ignore parse errors } } // Parse task detail fields and attach scores const tasksWithDetails = tasks.map(task => { let detail = {}; let modelInfo = {}; try { detail = task.detail ? JSON.parse(task.detail) : {}; modelInfo = task.modelInfo ? JSON.parse(task.modelInfo) : {}; } catch (e) { console.error('Failed to parse task detail:', e); } // Attach calculated scores as results array const scores = taskScores[task.id] || { modelAScore: 0, modelBScore: 0 }; const results = [ { modelAScore: scores.modelAScore, modelBScore: scores.modelBScore } ]; return { ...task, detail: { ...detail, results // Attach results for display in task card }, modelInfo }; }); return NextResponse.json({ code: 0, data: { items: tasksWithDetails, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }); } catch (error) { console.error('Failed to fetch blind-test task list:', error); return NextResponse.json( { code: 500, error: 'Failed to fetch blind-test task list', message: error.message }, { status: 500 } ); } } /** * Create a blind-test task */ export async function POST(request, { params }) { try { const { projectId } = params; const data = await request.json(); const { modelA, modelB, evalDatasetIds, language = 'zh-CN' } = data; if (!modelA || !modelA.modelId || !modelA.providerId) { return NextResponse.json({ code: 400, error: 'Please select model A' }, { status: 400 }); } if (!modelB || !modelB.modelId || !modelB.providerId) { return NextResponse.json({ code: 400, error: 'Please select model B' }, { status: 400 }); } if (modelA.modelId === modelB.modelId && modelA.providerId === modelB.providerId) { return NextResponse.json({ code: 400, error: 'The two models must be different' }, { status: 400 }); } if (!evalDatasetIds || evalDatasetIds.length === 0) { return NextResponse.json({ code: 400, error: 'Please select questions to evaluate' }, { status: 400 }); } const evalDatasets = await db.evalDatasets.findMany({ where: { id: { in: evalDatasetIds }, projectId }, select: { id: true, questionType: true } }); const invalidQuestions = evalDatasets.filter( q => q.questionType !== 'short_answer' && q.questionType !== 'open_ended' ); if (invalidQuestions.length > 0) { return NextResponse.json( { code: 400, error: 'Blind-test tasks only support short-answer and open-ended questions' }, { status: 400 } ); } // Fetch model config info const [modelConfigA, modelConfigB] = await Promise.all([ db.modelConfig.findFirst({ where: { projectId, providerId: modelA.providerId, modelId: modelA.modelId } }), db.modelConfig.findFirst({ where: { projectId, providerId: modelB.providerId, modelId: modelB.modelId } }) ]); // Build model info (two models) const modelInfo = { modelA: { id: modelConfigA?.id, modelId: modelA.modelId, modelName: modelConfigA?.modelName || modelA.modelId, providerId: modelA.providerId, providerName: modelConfigA?.providerName || modelA.providerId }, modelB: { id: modelConfigB?.id, modelId: modelB.modelId, modelName: modelConfigB?.modelName || modelB.modelId, providerId: modelB.providerId, providerName: modelConfigB?.providerName || modelB.providerId } }; // Build task detail (only store evalDatasetIds and currentIndex) const taskDetail = { evalDatasetIds, currentIndex: 0 // Current question index }; // Create task const newTask = await db.task.create({ data: { projectId, taskType: 'blind-test', status: 0, // Running modelInfo: JSON.stringify(modelInfo), language, detail: JSON.stringify(taskDetail), totalCount: evalDatasetIds.length, completedCount: 0, note: '' } }); return NextResponse.json({ code: 0, data: { ...newTask, detail: taskDetail, modelInfo }, message: 'Blind-test task created' }); } catch (error) { console.error('Failed to create blind-test task:', error); return NextResponse.json( { code: 500, error: 'Failed to create blind-test task', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/chunks/[chunkId]/clean/route.js ================================================ import { NextResponse } from 'next/server'; import logger from '@/lib/util/logger'; import cleanService from '@/lib/services/clean'; // 为指定文本块进行数据清洗 export async function POST(request, { params }) { try { const { projectId, chunkId } = params; // 验证项目ID和文本块ID if (!projectId || !chunkId) { return NextResponse.json({ error: 'Project ID or text block ID cannot be empty' }, { status: 400 }); } // 获取请求体 const { model, language = '中文' } = await request.json(); if (!model) { return NextResponse.json({ error: 'Model cannot be empty' }, { status: 400 }); } // 使用数据清洗服务 const result = await cleanService.cleanDataForChunk(projectId, chunkId, { model, language }); // 返回清洗结果 return NextResponse.json({ chunkId, originalLength: result.originalLength, cleanedLength: result.cleanedLength, success: result.success, message: '数据清洗完成' }); } catch (error) { logger.error('Error cleaning data:', error); return NextResponse.json({ error: error.message || 'Error cleaning data' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/chunks/[chunkId]/eval-questions/route.js ================================================ import { NextResponse } from 'next/server'; import { generateEvalQuestionsForChunk } from '@/lib/services/eval'; import logger from '@/lib/util/logger'; /** * 为指定文本块生成测评题目 */ export async function POST(request, { params }) { try { const { projectId, chunkId } = params; // 验证参数 if (!projectId || !chunkId) { return NextResponse.json({ error: 'Project ID and Chunk ID are required' }, { status: 400 }); } // 获取请求体 const { model, language = 'zh-CN' } = await request.json(); if (!model) { return NextResponse.json({ error: 'Model configuration is required' }, { status: 400 }); } // 调用服务层生成测评题目 const result = await generateEvalQuestionsForChunk(projectId, chunkId, { model, language }); return NextResponse.json(result); } catch (error) { logger.error('Error generating eval questions:', error); return NextResponse.json({ error: error.message || 'Failed to generate eval questions' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/chunks/[chunkId]/questions/route.js ================================================ import { NextResponse } from 'next/server'; import { getQuestionsForChunk } from '@/lib/db/questions'; import logger from '@/lib/util/logger'; import questionService from '@/lib/services/questions'; // 为指定文本块生成问题 export async function POST(request, { params }) { try { const { projectId, chunkId } = params; // 验证项目ID和文本块ID if (!projectId || !chunkId) { return NextResponse.json({ error: 'Project ID or text block ID cannot be empty' }, { status: 400 }); } // 获取请求体 const { model, language = '中文', number, enableGaExpansion = false } = await request.json(); if (!model) { return NextResponse.json({ error: 'Model cannot be empty' }, { status: 400 }); } // 后续会根据是否有GA对来选择是否启用GA扩展选择服务函数 const serviceFunc = questionService.generateQuestionsForChunkWithGA; // 使用问题生成服务 const result = await serviceFunc(projectId, chunkId, { model, language, number, enableGaExpansion }); // 统一返回格式,确保包含GA扩展信息 const response = { chunkId, questions: result.questions || result.labelQuestions || [], total: result.total || (result.questions || result.labelQuestions || []).length, gaExpansionUsed: result.gaExpansionUsed || false, gaPairsCount: result.gaPairsCount || 0, expectedTotal: result.expectedTotal || result.total }; // 返回生成的问题 return NextResponse.json(response); } catch (error) { logger.error('Error generating questions:', error); return NextResponse.json({ error: error.message || 'Error generating questions' }, { status: 500 }); } } // 获取指定文本块的问题 export async function GET(request, { params }) { try { const { projectId, chunkId } = params; // 验证项目ID和文本块ID if (!projectId || !chunkId) { return NextResponse.json({ error: 'The item ID or text block ID cannot be empty' }, { status: 400 }); } // 获取文本块的问题 const questions = await getQuestionsForChunk(projectId, chunkId); // 返回问题列表 return NextResponse.json({ chunkId, questions, total: questions.length }); } catch (error) { console.error('Error getting questions:', String(error)); return NextResponse.json({ error: error.message || 'Error getting questions' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/chunks/[chunkId]/route.js ================================================ import { NextResponse } from 'next/server'; import { deleteChunkById, getChunkById, updateChunkById } from '@/lib/db/chunks'; // 获取文本块内容 export async function GET(request, { params }) { try { const { projectId, chunkId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } if (!chunkId) { return NextResponse.json({ error: 'Text block ID cannot be empty' }, { status: 400 }); } // 获取文本块内容 const chunk = await getChunkById(chunkId); return NextResponse.json(chunk); } catch (error) { console.error('Failed to get text block content:', String(error)); return NextResponse.json({ error: error.message || 'Failed to get text block content' }, { status: 500 }); } } // 删除文本块 export async function DELETE(request, { params }) { try { const { projectId, chunkId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } if (!chunkId) { return NextResponse.json({ error: 'Text block ID cannot be empty' }, { status: 400 }); } await deleteChunkById(chunkId); return NextResponse.json({ message: 'Text block deleted successfully' }); } catch (error) { console.error('Failed to delete text block:', String(error)); return NextResponse.json({ error: error.message || 'Failed to delete text block' }, { status: 500 }); } } // 编辑文本块内容 export async function PATCH(request, { params }) { try { const { projectId, chunkId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } if (!chunkId) { return NextResponse.json({ error: '文本块ID不能为空' }, { status: 400 }); } // 解析请求体获取新内容 const requestData = await request.json(); const { content } = requestData; if (!content) { return NextResponse.json({ error: '内容不能为空' }, { status: 400 }); } let res = await updateChunkById(chunkId, { content }); return NextResponse.json(res); } catch (error) { console.error('编辑文本块失败:', String(error)); return NextResponse.json({ error: error.message || '编辑文本块失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/chunks/batch-content/route.js ================================================ import { getChunkContentsByNames } from '@/lib/db/chunks'; import { NextResponse } from 'next/server'; export async function POST(request, { params }) { try { const { projectId } = params; const { chunkNames } = await request.json(); if (!chunkNames || !Array.isArray(chunkNames)) { return NextResponse.json({ error: 'chunkNames 参数必须是数组' }, { status: 400 }); } const chunkContentMap = await getChunkContentsByNames(projectId, chunkNames); return NextResponse.json(chunkContentMap); } catch (error) { console.error('批量获取文本块内容失败:', error); return NextResponse.json({ error: '批量获取文本块内容失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/chunks/batch-edit/route.js ================================================ import { NextRequest, NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); /** * 批量编辑文本块内容 * POST /api/projects/[projectId]/chunks/batch-edit */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { position, content, chunkIds } = body; // 验证参数 if (!position || !content || !chunkIds || !Array.isArray(chunkIds) || chunkIds.length === 0) { return NextResponse.json({ error: 'Missing required parameters: position, content, chunkIds' }, { status: 400 }); } if (!['start', 'end'].includes(position)) { return NextResponse.json({ error: 'Position must be "start" or "end"' }, { status: 400 }); } // 验证项目权限(获取要编辑的文本块) const chunksToUpdate = await prisma.chunks.findMany({ where: { id: { in: chunkIds }, projectId: projectId }, select: { id: true, content: true, name: true } }); if (chunksToUpdate.length === 0) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } if (chunksToUpdate.length !== chunkIds.length) { return NextResponse.json({ error: 'Some chunks not found' }, { status: 400 }); } // 准备更新数据 const updates = chunksToUpdate.map(chunk => { let newContent; if (position === 'start') { // 在开头添加内容 newContent = content + '\n\n' + chunk.content; } else { // 在结尾添加内容 newContent = chunk.content + '\n\n' + content; } return { where: { id: chunk.id }, data: { content: newContent, size: newContent.length, updateAt: new Date() } }; }); async function processBatches(items, batchSize, processFn) { const results = []; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); const batchResults = await Promise.all(batch.map(processFn)); results.push(...batchResults); } return results; } const BATCH_SIZE = 50; // 每批处理 50 个 await processBatches(updates, BATCH_SIZE, update => prisma.chunks.update(update)); // 记录操作日志(可选) console.log(`Successfully updated ${chunksToUpdate.length} chunks`); return NextResponse.json({ success: true, updatedCount: chunksToUpdate.length, message: `Successfully updated ${chunksToUpdate.length} chunks` }); } catch (error) { console.error('批量编辑文本块失败:', error); return NextResponse.json( { error: 'Batch edit chunks failed', details: error.message }, { status: 500 } ); } finally { await prisma.$disconnect(); } } ================================================ FILE: app/api/projects/[projectId]/chunks/name/route.js ================================================ import { NextResponse } from 'next/server'; import { getChunkByName } from '@/lib/db/chunks'; /** * 根据文本块名称获取文本块 * @param {Request} request 请求对象 * @param {object} context 上下文,包含路径参数 * @returns {Promise} 响应对象 */ export async function GET(request, { params }) { try { const { projectId } = params; // 从查询参数中获取 chunkName const { searchParams } = new URL(request.url); const chunkName = searchParams.get('chunkName'); if (!chunkName) { return NextResponse.json({ error: '文本块名称不能为空' }, { status: 400 }); } // 根据名称和项目ID查询文本块 const chunk = await getChunkByName(projectId, chunkName); if (!chunk) { return NextResponse.json({ error: '未找到指定的文本块' }, { status: 404 }); } // 返回文本块信息 return NextResponse.json(chunk); } catch (error) { console.error('根据名称获取文本块失败:', String(error)); return NextResponse.json({ error: '获取文本块失败: ' + error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/chunks/route.js ================================================ import { NextResponse } from 'next/server'; import { deleteChunkById, getChunkByFileIds, getChunkById, getChunksByFileIds, updateChunkById } from '@/lib/db/chunks'; // 获取文本块内容 export async function POST(request, { params }) { try { const { projectId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } const { array } = await request.json(); // 获取文本块内容 const chunk = await getChunksByFileIds(array); return NextResponse.json(chunk); } catch (error) { console.error('Failed to get text block content:', String(error)); return NextResponse.json({ error: String(error) || 'Failed to get text block content' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/config/route.js ================================================ import { NextResponse } from 'next/server'; import { getProject, updateProject, getTaskConfig } from '@/lib/db/projects'; // 获取项目配置 export async function GET(request, { params }) { try { const projectId = params.projectId; const config = await getProject(projectId); const taskConfig = await getTaskConfig(projectId); return NextResponse.json({ ...config, ...taskConfig }); } catch (error) { console.error('获取项目配置失败:', String(error)); return NextResponse.json({ error: error.message }, { status: 500 }); } } // 更新项目配置 export async function PUT(request, { params }) { try { const projectId = params.projectId; const newConfig = await request.json(); const currentConfig = await getProject(projectId); // 只更新 prompts 部分 const updatedConfig = { ...currentConfig, ...newConfig.prompts }; const config = await updateProject(projectId, updatedConfig); return NextResponse.json(config); } catch (error) { console.error('更新项目配置失败:', String(error)); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/custom-prompts/route.js ================================================ import { NextResponse } from 'next/server'; import { getCustomPrompts, getCustomPrompt, saveCustomPrompt, deleteCustomPrompt, batchSaveCustomPrompts, toggleCustomPrompt, getPromptTemplates } from '@/lib/db/custom-prompts'; // 获取项目的自定义提示词 export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const promptType = searchParams.get('promptType'); const language = searchParams.get('language'); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const customPrompts = await getCustomPrompts(projectId, promptType, language); const templates = await getPromptTemplates(); return NextResponse.json({ success: true, customPrompts, templates }); } catch (error) { console.error('获取自定义提示词失败:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } // 保存自定义提示词 export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // 批量保存 if (body.prompts && Array.isArray(body.prompts)) { const results = await batchSaveCustomPrompts(projectId, body.prompts); return NextResponse.json({ success: true, results }); } // 单个保存 const { promptType, promptKey, language, content } = body; if (!promptType || !promptKey || !language || content === undefined) { return NextResponse.json( { error: 'promptType, promptKey, language and content are required' }, { status: 400 } ); } const result = await saveCustomPrompt(projectId, promptType, promptKey, language, content); return NextResponse.json({ success: true, result }); } catch (error) { console.error('保存自定义提示词失败:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } // 删除自定义提示词 export async function DELETE(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const promptType = searchParams.get('promptType'); const promptKey = searchParams.get('promptKey'); const language = searchParams.get('language'); if (!projectId || !promptType || !promptKey || !language) { return NextResponse.json( { error: 'projectId, promptType, promptKey and language are required' }, { status: 400 } ); } const success = await deleteCustomPrompt(projectId, promptType, promptKey, language); return NextResponse.json({ success }); } catch (error) { console.error('删除自定义提示词失败:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/custom-split/route.js ================================================ import { NextResponse } from 'next/server'; import { saveChunks, deleteChunksByFileId } from '@/lib/db/chunks'; import path from 'path'; import fs from 'fs/promises'; import { getProjectRoot } from '@/lib/db/base'; /** * 处理自定义分块请求 * @param {Request} request - 请求对象 * @param {Object} params - 路由参数 * @returns {Promise} - 响应对象 */ export async function POST(request, { params }) { try { const { projectId } = params; const { fileId, fileName, content, splitPoints } = await request.json(); // 参数验证 if (!projectId || !fileId || !fileName || !content || !splitPoints) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'Project does not exist' }, { status: 404 }); } // 先删除该文件已有的文本块 await deleteChunksByFileId(projectId, fileId); // 根据分块点将文件内容分割成多个块 const customChunks = generateCustomChunks(projectId, fileId, fileName, content, splitPoints); // 保存新的文本块 await saveChunks(customChunks); return NextResponse.json({ success: true, message: 'Custom chunks saved successfully', totalChunks: customChunks.length }); } catch (error) { console.error('自定义分块处理出错:', String(error)); return NextResponse.json({ error: error.message || 'Failed to process custom split request' }, { status: 500 }); } } /** * 根据分块点生成自定义文本块 * @param {string} projectId - 项目ID * @param {string} fileId - 文件ID * @param {string} fileName - 文件名 * @param {string} content - 文件内容 * @param {Array} splitPoints - 分块点数组 * @returns {Array} - 生成的文本块数组 */ function generateCustomChunks(projectId, fileId, fileName, content, splitPoints) { // 按位置排序分块点 const sortedPoints = [...splitPoints].sort((a, b) => a.position - b.position); // 创建分块 const chunks = []; let startPos = 0; // 处理每个分块点 for (let i = 0; i < sortedPoints.length; i++) { const endPos = sortedPoints[i].position; // 提取当前分块内容 const chunkContent = content.substring(startPos, endPos); // 跳过空白分块 if (chunkContent.trim().length === 0) { startPos = endPos; continue; } // 创建分块对象 const chunk = { projectId, name: `${path.basename(fileName, path.extname(fileName))}-part-${i + 1}`, fileId, fileName, content: chunkContent, summary: `${fileName} 自定义分块 ${i + 1}/${sortedPoints.length + 1}`, size: chunkContent.length }; chunks.push(chunk); startPos = endPos; } // 添加最后一个分块(如果有内容) const lastChunkContent = content.substring(startPos); if (lastChunkContent.trim().length > 0) { const lastChunk = { projectId, name: `${path.basename(fileName, path.extname(fileName))}-part-${sortedPoints.length + 1}`, fileId, fileName, content: lastChunkContent, summary: `${fileName} 自定义分块 ${sortedPoints.length + 1}/${sortedPoints.length + 1}`, size: lastChunkContent.length }; chunks.push(lastChunk); } return chunks; } ================================================ FILE: app/api/projects/[projectId]/dataset-conversations/[conversationId]/route.js ================================================ /** * 单个多轮对话数据集操作API */ import { NextResponse } from 'next/server'; import { getDatasetConversationById, updateDatasetConversation, deleteDatasetConversation, getConversationNavigationItems } from '@/lib/db/dataset-conversations'; /** * 获取单个多轮对话数据集详情 */ export async function GET(request, { params }) { try { const { projectId, conversationId } = params; const { searchParams } = new URL(request.url); const operateType = searchParams.get('operateType'); // 如果是导航操作,返回导航项 if (operateType !== null) { const data = await getConversationNavigationItems(projectId, conversationId, operateType); return NextResponse.json(data); } const conversation = await getDatasetConversationById(conversationId); if (!conversation) { return NextResponse.json( { success: false, message: '对话数据集不存在' }, { status: 404 } ); } if (conversation.projectId !== projectId) { return NextResponse.json( { success: false, message: '对话数据集不属于指定项目' }, { status: 403 } ); } return NextResponse.json(conversation); } catch (error) { console.error('获取多轮对话数据集详情失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } /** * 更新多轮对话数据集 */ export async function PUT(request, { params }) { try { const { projectId, conversationId } = params; const body = await request.json(); // 验证对话数据集是否存在且属于项目 const conversation = await getDatasetConversationById(conversationId); if (!conversation) { return NextResponse.json( { success: false, message: '对话数据集不存在' }, { status: 404 } ); } if (conversation.projectId !== projectId) { return NextResponse.json( { success: false, message: '对话数据集不属于指定项目' }, { status: 403 } ); } // 只允许更新特定字段 const allowedFields = ['score', 'tags', 'note', 'confirmed', 'aiEvaluation', 'messages']; const updateData = {}; allowedFields.forEach(field => { if (body.hasOwnProperty(field)) { if (field === 'messages') { // 将messages数组转换为rawMessages字符串存储 updateData['rawMessages'] = JSON.stringify(body[field]); } else { updateData[field] = body[field]; } } }); if (Object.keys(updateData).length === 0) { return NextResponse.json( { success: false, message: '没有有效的更新字段' }, { status: 400 } ); } const updatedConversation = await updateDatasetConversation(conversationId, updateData); return NextResponse.json({ success: true, data: updatedConversation }); } catch (error) { console.error('更新多轮对话数据集失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } /** * 删除多轮对话数据集 */ export async function DELETE(request, { params }) { try { const { projectId, conversationId } = params; // 验证对话数据集是否存在且属于项目 const conversation = await getDatasetConversationById(conversationId); if (!conversation) { return NextResponse.json( { success: false, message: '对话数据集不存在' }, { status: 404 } ); } if (conversation.projectId !== projectId) { return NextResponse.json( { success: false, message: '对话数据集不属于指定项目' }, { status: 403 } ); } await deleteDatasetConversation(conversationId); return NextResponse.json({ success: true, message: '删除成功' }); } catch (error) { console.error('删除多轮对话数据集失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/dataset-conversations/export/route.js ================================================ /** * 多轮对话数据集导出API * 直接导出原始的 ShareGPT 格式数据集 */ import { NextResponse } from 'next/server'; import { getAllDatasetConversations } from '@/lib/db/dataset-conversations'; /** * 导出多轮对话数据集 */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); // 筛选条件 const filters = { confirmed: searchParams.get('confirmed') }; // 清除空值 Object.keys(filters).forEach(key => { if (!filters[key]) delete filters[key]; }); // 获取所有对话数据集 const conversations = await getAllDatasetConversations(projectId, filters); if (conversations.length === 0) { return NextResponse.json([]); } // 转换为 ShareGPT 格式数组 const shareGptData = []; for (const conversation of conversations) { try { // 解析 rawMessages const messages = JSON.parse(conversation.rawMessages || '[]'); if (messages.length > 0) { // 构建 ShareGPT 格式对象 const shareGptItem = { messages: messages }; shareGptData.push(shareGptItem); } } catch (error) { console.error(`解析对话消息失败 ${conversation.id}:`, error); // 跳过解析失败的对话,继续处理其他对话 continue; } } return NextResponse.json(shareGptData); } catch (error) { console.error('导出多轮对话数据集失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/dataset-conversations/route.js ================================================ /** * 多轮对话数据集管理API */ import { NextResponse } from 'next/server'; import { getDatasetConversationsByPagination, getAllDatasetConversationIds, createDatasetConversation } from '@/lib/db/dataset-conversations'; import { generateMultiTurnConversation } from '@/lib/services/multi-turn/index'; /** * 获取多轮对话数据集列表(支持分页和筛选) */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const getAllIds = searchParams.get('getAllIds') === 'true'; // 新增:获取所有对话ID的标志 // 筛选条件 const filters = { keyword: searchParams.get('keyword'), roleA: searchParams.get('roleA'), roleB: searchParams.get('roleB'), scenario: searchParams.get('scenario'), scoreMin: searchParams.get('scoreMin'), scoreMax: searchParams.get('scoreMax'), confirmed: searchParams.get('confirmed') }; // 清除空值 Object.keys(filters).forEach(key => { if (!filters[key]) delete filters[key]; }); // 如果请求获取所有ID if (getAllIds) { const allConversationIds = await getAllDatasetConversationIds(projectId, filters); return NextResponse.json({ allConversationIds }); } // 正常分页查询 const page = parseInt(searchParams.get('page') || '1'); const pageSize = parseInt(searchParams.get('pageSize') || '20'); const result = await getDatasetConversationsByPagination(projectId, page, pageSize, filters); return NextResponse.json({ success: true, ...result }); } catch (error) { console.error('获取多轮对话数据集失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } /** * 创建多轮对话数据集 */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { questionId, systemPrompt, scenario, rounds, roleA, roleB, model, language = '中文' } = body; if (!questionId) { return NextResponse.json( { success: false, message: '问题ID不能为空' }, { status: 400 } ); } if (!model || !model.modelId) { return NextResponse.json( { success: false, message: '模型配置不能为空' }, { status: 400 } ); } // 构建配置 const config = { systemPrompt: systemPrompt || '', scenario: scenario || '', rounds: rounds || 3, roleA: roleA || '用户', roleB: roleB || '助手', model, language }; // 生成多轮对话 const result = await generateMultiTurnConversation(projectId, questionId, config); if (!result.success) { return NextResponse.json( { success: false, message: result.error }, { status: 500 } ); } return NextResponse.json({ success: true, data: result.data }); } catch (error) { console.error('创建多轮对话数据集失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/dataset-conversations/tags/route.js ================================================ import { NextResponse } from 'next/server'; import { getAllDatasetConversations } from '@/lib/db/dataset-conversations'; /** * 获取项目中多轮对话数据集的所有标签 */ export async function GET(request, { params }) { try { const { projectId } = params; if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } // 获取项目所有对话数据集 const conversations = await getAllDatasetConversations(projectId); // 提取所有标签 const allTags = new Set(); conversations.forEach(conversation => { if (conversation.tags && typeof conversation.tags === 'string') { const tags = conversation.tags.split(/\s+/).filter(tag => tag.trim().length > 0); tags.forEach(tag => allTags.add(tag.trim())); } }); return NextResponse.json({ success: true, tags: Array.from(allTags).sort() }); } catch (error) { console.error('获取对话标签失败:', error); return NextResponse.json( { success: false, message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/datasets/[datasetId]/copy-to-eval/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; export async function POST(req, { params }) { try { const { projectId, datasetId } = params; // 1. 获取数据集详情 const dataset = await db.datasets.findUnique({ where: { id: datasetId, projectId } }); if (!dataset) { return NextResponse.json({ error: 'Dataset not found' }, { status: 404 }); } // 2. 尝试通过 questionId 查找关联的 chunkId let chunkId = null; if (dataset.questionId) { const question = await db.questions.findUnique({ where: { id: dataset.questionId } }); if (question) { chunkId = question.chunkId; } } // 3. 创建评估数据集记录 // 默认使用 open_ended 类型,因为通常数据集是问答对,适合作为评估 let evalTags = []; try { evalTags = JSON.parse(dataset.tags || '[]'); if (!Array.isArray(evalTags)) evalTags = []; } catch (e) { evalTags = []; } // 排除 'Eval' 标签,并将数组转为逗号分隔的字符串 const evalTagsString = evalTags.filter(tag => tag !== 'Eval').join(','); const evalDataset = await db.evalDatasets.create({ data: { projectId, question: dataset.question, questionType: 'open_ended', correctAnswer: dataset.answer, tags: evalTagsString, note: dataset.note, chunkId: chunkId, options: '' // 开放题不需要选项 } }); // 4. 更新原数据集,添加 'Eval' 标签 let currentTags = []; try { currentTags = JSON.parse(dataset.tags || '[]'); } catch (e) { // ignore error } if (!currentTags.includes('Eval')) { currentTags.push('Eval'); await db.datasets.update({ where: { id: datasetId }, data: { tags: JSON.stringify(currentTags) } }); } return NextResponse.json({ success: true, evalDataset }); } catch (error) { console.error('Failed to copy dataset to eval:', error); return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/datasets/[datasetId]/evaluate/route.js ================================================ import { NextResponse } from 'next/server'; import { evaluateDataset } from '@/lib/services/datasets/evaluation'; /** * 评估单个数据集的质量 */ export async function POST(request, { params }) { try { const { projectId, datasetId } = params; const { model, language = 'zh-CN' } = await request.json(); if (!projectId || !datasetId) { return NextResponse.json({ success: false, message: '项目ID和数据集ID不能为空' }, { status: 400 }); } if (!model) { return NextResponse.json({ success: false, message: '模型配置不能为空' }, { status: 400 }); } // 使用评估服务进行数据集评估 const result = await evaluateDataset(projectId, datasetId, model, language); if (!result.success) { return NextResponse.json({ success: false, message: result.error }, { status: 500 }); } return NextResponse.json({ success: true, message: '数据集评估完成', data: result.data }); } catch (error) { console.error('数据集评估失败:', error); return NextResponse.json({ success: false, message: `评估失败: ${error.message}` }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/datasets/[datasetId]/route.js ================================================ import { NextResponse } from 'next/server'; import { getDatasetsById, getDatasetsCounts, getNavigationItems, updateDatasetMetadata } from '@/lib/db/datasets'; /** * 获取项目的所有数据集 */ export async function GET(request, { params }) { try { const { projectId, datasetId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } if (!datasetId) { return NextResponse.json({ error: '数据集ID不能为空' }, { status: 400 }); } const { searchParams } = new URL(request.url); const operateType = searchParams.get('operateType'); if (operateType !== null) { const data = await getNavigationItems(projectId, datasetId, operateType); return NextResponse.json(data); } const datasets = await getDatasetsById(datasetId); let counts = await getDatasetsCounts(projectId); return NextResponse.json({ datasets, ...counts }); } catch (error) { console.error('获取数据集详情失败:', String(error)); return NextResponse.json( { error: error.message || '获取数据集详情失败' }, { status: 500 } ); } } /** * 更新数据集元数据(评分、标签、备注) */ export async function PATCH(request, { params }) { try { const { projectId, datasetId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } if (!datasetId) { return NextResponse.json({ error: '数据集ID不能为空' }, { status: 400 }); } const body = await request.json(); const { score, tags, note } = body; // 验证评分范围 if (score !== undefined && (score < 0 || score > 5)) { return NextResponse.json({ error: '评分必须在0-5之间' }, { status: 400 }); } // 验证标签格式 if (tags !== undefined && !Array.isArray(tags)) { return NextResponse.json({ error: '标签必须是数组格式' }, { status: 400 }); } // 更新数据集元数据 const updatedDataset = await updateDatasetMetadata(datasetId, { score, tags, note }); return NextResponse.json({ success: true, dataset: updatedDataset }); } catch (error) { console.error('更新数据集元数据失败:', String(error)); return NextResponse.json( { error: error.message || '更新数据集元数据失败' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/datasets/[datasetId]/token-count/route.js ================================================ import { NextResponse } from 'next/server'; import { getDatasetsById } from '@/lib/db/datasets'; import { getEncoding } from '@langchain/core/utils/tiktoken'; /** * 异步计算数据集文本的Token数量 */ export async function GET(request, { params }) { try { const { projectId, datasetId } = params; if (!datasetId) { return NextResponse.json({ error: '数据集ID不能为空' }, { status: 400 }); } const datasets = await getDatasetsById(datasetId); const tokenCounts = { answerTokens: 0, cotTokens: 0 }; try { if (datasets.answer || datasets.cot) { // 使用 cl100k_base 编码,适用于 gpt-3.5-turbo 和 gpt-4 const encoding = await getEncoding('cl100k_base'); if (datasets.answer) { const tokens = encoding.encode(datasets.answer); tokenCounts.answerTokens = tokens.length; } if (datasets.cot) { const tokens = encoding.encode(datasets.cot); tokenCounts.cotTokens = tokens.length; } } } catch (error) { console.error('计算Token数量失败:', String(error)); return NextResponse.json({ error: '计算Token数量失败' }, { status: 500 }); } return NextResponse.json(tokenCounts); } catch (error) { console.error('获取Token计数失败:', String(error)); return NextResponse.json( { error: error.message || '获取Token计数失败' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/datasets/batch-evaluate/route.js ================================================ /** * 批量数据集评估任务API * 创建批量评估数据集质量的异步任务 */ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import { processTask } from '@/lib/services/tasks/index'; /** * 创建批量数据集评估任务 */ export async function POST(request, { params }) { try { const { projectId } = params; const { model, language = 'zh-CN' } = await request.json(); if (!projectId) { return NextResponse.json({ success: false, message: '项目ID不能为空' }, { status: 400 }); } if (!model || !model.modelId) { return NextResponse.json({ success: false, message: '模型配置不能为空' }, { status: 400 }); } // 创建批量评估任务 const newTask = await db.task.create({ data: { projectId, taskType: 'dataset-evaluation', status: 0, // 初始状态: 处理中 modelInfo: JSON.stringify(model), language: language || 'zh-CN', detail: '', totalCount: 0, note: '准备开始批量评估数据集质量...', completedCount: 0 } }); // 异步处理任务 processTask(newTask.id).catch(err => { console.error(`批量评估任务启动失败: ${newTask.id}`, String(err)); }); return NextResponse.json({ success: true, message: '批量评估任务已创建', data: { taskId: newTask.id } }); } catch (error) { console.error('创建批量评估任务失败:', error); return NextResponse.json({ success: false, message: `创建任务失败: ${error.message}` }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/datasets/export/route.js ================================================ import { NextResponse } from 'next/server'; import { getDatasets, getBalancedDatasetsByTags, getTagsWithDatasetCounts, getDatasetsBatch, getBalancedDatasetsByTagsBatch, getDatasetsByIds, getDatasetsByIdsBatch } from '@/lib/db/datasets'; /** * 获取导出数据集 */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } const confirmedParam = searchParams.get('confirmed'); const confirmed = confirmedParam === null ? undefined : confirmedParam === 'true'; // 获取标签统计信息 const tagStats = await getTagsWithDatasetCounts(projectId, confirmed); return NextResponse.json(tagStats); } catch (error) { console.error('Failed to get tag statistics:', String(error)); return NextResponse.json( { error: error.message || 'Failed to get tag statistics' }, { status: 500 } ); } } /** * 获取标签统计信息 */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } let status = body.status; let confirmed = undefined; if (status === 'confirmed') confirmed = true; if (status === 'unconfirmed') confirmed = false; // 检查是否是分批导出模式 const batchMode = body.batchMode ? 'true' : 'false'; const offset = body.offset ?? 0; const batchSize = body.batchSize ?? 1000; // 检查是否是平衡导出 const balanceMode = body.balanceMode ? 'true' : 'false'; const balanceConfig = body.balanceConfig; // 检查是否有选中的数据集 ID const selectedIds = Array.isArray(body.selectedIds) ? body.selectedIds : null; if (batchMode === 'true') { // 分批导出模式 if (selectedIds && selectedIds.length > 0) { // 按选中 ID 分批导出 const datasets = await getDatasetsByIdsBatch(projectId, selectedIds, offset, batchSize); const hasMore = datasets.length === batchSize; return NextResponse.json({ data: datasets, hasMore, offset: offset + datasets.length }); } else if (balanceMode === 'true' && balanceConfig) { // 平衡分批导出 const parsedConfig = typeof balanceConfig === 'string' ? JSON.parse(balanceConfig) : balanceConfig; const result = await getBalancedDatasetsByTagsBatch(projectId, parsedConfig, confirmed, offset, batchSize); return NextResponse.json({ data: result.data, hasMore: result.hasMore, offset: offset + result.data.length }); } else { // 常规分批导出 const datasets = await getDatasetsBatch(projectId, confirmed, offset, batchSize); const hasMore = datasets.length === batchSize; return NextResponse.json({ data: datasets, hasMore, offset: offset + datasets.length }); } } else { // 传统一次性导出模式(保持向后兼容) if (selectedIds && selectedIds.length > 0) { // 按选中 ID 导出 const datasets = await getDatasetsByIds(projectId, selectedIds); return NextResponse.json(datasets); } else if (balanceMode === 'true' && balanceConfig) { // 平衡导出模式 const parsedConfig = typeof balanceConfig === 'string' ? JSON.parse(balanceConfig) : balanceConfig; const datasets = await getBalancedDatasetsByTags(projectId, parsedConfig, confirmed); return NextResponse.json(datasets); } else { // 常规导出模式 const datasets = await getDatasets(projectId, confirmed); return NextResponse.json(datasets); } } } catch (error) { console.error('Failed to get datasets:', String(error)); return NextResponse.json( { error: error.message || 'Failed to get datasets' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/datasets/generate-eval-variant/route.js ================================================ import { NextResponse } from 'next/server'; import { getDatasetsById } from '@/lib/db/datasets'; import LLMClient from '@/lib/llm/core/index'; import { getEvalQuestionPrompt } from '@/lib/llm/prompts/evalQuestion'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; export async function POST(request, { params }) { try { const { projectId } = params; const { datasetId, model, language, questionType = 'open_ended', count = 1 } = await request.json(); if (!datasetId || !model) { return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); } // 1. 获取原数据集 const dataset = await getDatasetsById(datasetId); if (!dataset) { return NextResponse.json({ error: 'Dataset not found' }, { status: 404 }); } // 2. 构建提示词 // 将原问题和答案合并作为上下文文本 const text = `Question: ${dataset.question}\nAnswer: ${dataset.answer}`; const prompt = await getEvalQuestionPrompt(language || 'zh-CN', questionType, { text, number: count }, projectId); // 3. 调用 LLM const client = new LLMClient(model); const response = await client.getResponse(prompt); const result = extractJsonFromLLMOutput(response); // 结果应该是一个数组 if (!result || !Array.isArray(result)) { throw new Error('Failed to parse LLM output or output is not an array'); } return NextResponse.json({ success: true, data: result }); } catch (error) { console.error('Generate eval variant failed:', error); return NextResponse.json({ error: error.message || 'Internal Server Error' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/datasets/import/route.js ================================================ import { NextResponse } from 'next/server'; import { createDataset } from '@/lib/db/datasets'; import { nanoid } from 'nanoid'; export async function POST(request, { params }) { try { const { projectId } = params; const { datasets, sourceInfo } = await request.json(); if (!datasets || !Array.isArray(datasets)) { return NextResponse.json({ error: 'Invalid datasets data' }, { status: 400 }); } const results = []; const errors = []; let successCount = 0; let skippedCount = 0; for (let i = 0; i < datasets.length; i++) { try { const dataset = datasets[i]; // 安全获取与清洗字段 const q = typeof dataset?.question === 'string' ? dataset.question.trim() : ''; const a = typeof dataset?.answer === 'string' ? dataset.answer.trim() : ''; // 验证必填字段:缺失则跳过 if (!q || !a) { errors.push(`第 ${i + 1} 条记录缺少必填字段(question/answer),已跳过`); skippedCount++; continue; } // 规范化可选字段 const chunkName = dataset?.chunkName || 'Imported Data'; const chunkContent = dataset?.chunkContent || 'Imported from external source'; const model = dataset?.model || 'imported'; const questionLabel = dataset?.questionLabel || ''; const cot = typeof dataset?.cot === 'string' ? dataset.cot : ''; const confirmed = typeof dataset?.confirmed === 'boolean' ? dataset.confirmed : false; const score = typeof dataset?.score === 'number' ? dataset.score : 0; // tags: 支持数组/字符串/对象 let tags = '[]'; if (Array.isArray(dataset?.tags)) { try { tags = JSON.stringify(dataset.tags); } catch { tags = '[]'; } } else if (typeof dataset?.tags === 'string') { tags = dataset.tags; } else if (dataset?.tags && typeof dataset.tags === 'object') { try { tags = JSON.stringify(dataset.tags); } catch { tags = '[]'; } } // other: 对象或字符串 let other = '{}'; if (typeof dataset?.other === 'string') { other = dataset.other; } else if (dataset?.other && typeof dataset.other === 'object') { try { other = JSON.stringify(dataset.other); } catch { other = '{}'; } } const note = typeof dataset?.note === 'string' ? dataset.note : ''; // 创建数据集记录 const newDataset = await createDataset({ projectId, questionId: nanoid(), // 生成唯一的问题ID question: q, answer: a, chunkName, chunkContent, model, questionLabel, cot, confirmed, score, tags, note, other }); results.push(newDataset); successCount++; } catch (error) { errors.push(`第 ${i + 1} 条记录: ${error.message}`); } } return NextResponse.json({ success: successCount, total: datasets.length, failed: errors.length, skipped: skippedCount, errors, sourceInfo }); } catch (error) { console.error('Import datasets error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/datasets/optimize/route.js ================================================ import { NextResponse } from 'next/server'; import { getDatasetsById, updateDataset } from '@/lib/db/datasets'; import { getQuestionById } from '@/lib/db/questions'; import { getChunkById } from '@/lib/db/chunks'; import LLMClient from '@/lib/llm/core/index'; import { getNewAnswerPrompt } from '@/lib/llm/prompts/newAnswer'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; // 优化数据集答案 export async function POST(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } // 获取请求体 const { datasetId, model, advice, language } = await request.json(); if (!datasetId) { return NextResponse.json({ error: 'Dataset ID cannot be empty' }, { status: 400 }); } if (!model) { return NextResponse.json({ error: 'Model cannot be empty' }, { status: 400 }); } if (!advice) { return NextResponse.json({ error: 'Please provide optimization suggestions' }, { status: 400 }); } // 获取数据集内容 const dataset = await getDatasetsById(datasetId); if (!dataset) { return NextResponse.json({ error: 'Dataset does not exist' }, { status: 404 }); } // 创建LLM客户端 const llmClient = new LLMClient(model); const { question, answer, cot, chunkContent: storedChunkContent, questionId } = dataset; let chunkContent = storedChunkContent || ''; if (!chunkContent && questionId) { try { const questionRecord = await getQuestionById(questionId); if (questionRecord?.chunkId) { const chunkRecord = await getChunkById(questionRecord.chunkId); chunkContent = chunkRecord?.content || ''; } } catch (error) { console.error('Failed to load chunk content by questionId:', error); } } // 生成优化后的答案和思维链 const prompt = await getNewAnswerPrompt(language, { question, answer, cot, advice, chunkContent }, projectId); const response = await llmClient.getResponse(prompt); // 从LLM输出中提取JSON格式的优化结果 const optimizedResult = extractJsonFromLLMOutput(response); if (!optimizedResult || !optimizedResult.answer) { return NextResponse.json({ error: 'Failed to optimize answer, please try again' }, { status: 500 }); } // 更新数据集 const updatedDataset = { ...dataset, answer: optimizedResult.answer, cot: cot ? optimizedResult.cot || cot : '' // 如果没有提供思考过程,则不更新 }; await updateDataset(updatedDataset); // 返回优化后的数据集 return NextResponse.json({ success: true, dataset: updatedDataset }); } catch (error) { console.error('Failed to optimize answer:', String(error)); return NextResponse.json({ error: error.message || 'Failed to optimize answer' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/datasets/route.js ================================================ import { NextResponse } from 'next/server'; import { deleteDataset, getDatasetsByPagination, getDatasetsIds, getDatasetsById, updateDataset } from '@/lib/db/datasets'; import datasetService from '@/lib/services/datasets'; // 优化思维链函数已移至服务层 /** * 生成数据集(为单个问题生成答案) */ export async function POST(request, { params }) { try { const { projectId } = params; const { questionId, model, language } = await request.json(); // 使用数据集生成服务 const result = await datasetService.generateDatasetForQuestion(projectId, questionId, { model, language }); return NextResponse.json(result); } catch (error) { console.error('Failed to generate dataset:', String(error)); return NextResponse.json( { error: error.message || 'Failed to generate dataset' }, { status: 500 } ); } } /** * 获取项目的所有数据集 */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } const page = parseInt(searchParams.get('page')) || 1; const size = parseInt(searchParams.get('size')) || 10; const input = searchParams.get('input'); const field = searchParams.get('field') || 'question'; const status = searchParams.get('status'); const hasCot = searchParams.get('hasCot'); const isDistill = searchParams.get('isDistill'); const scoreRange = searchParams.get('scoreRange'); const customTag = searchParams.get('customTag'); const noteKeyword = searchParams.get('noteKeyword'); const chunkName = searchParams.get('chunkName'); let confirmed = undefined; if (status === 'confirmed') confirmed = true; if (status === 'unconfirmed') confirmed = false; let selectedAll = searchParams.get('selectedAll'); if (selectedAll) { let data = await getDatasetsIds( projectId, confirmed, input, field, hasCot, isDistill, scoreRange, customTag, noteKeyword, chunkName ); return NextResponse.json(data); } // 获取数据集 const datasets = await getDatasetsByPagination( projectId, page, size, confirmed, input, field, // 传递搜索字段参数 hasCot, // 传递思维链筛选参数 isDistill, // 传递蒸馏数据集筛选参数 scoreRange, // 传递评分范围筛选参数 customTag, // 传递自定义标签筛选参数 noteKeyword, // 传递备注关键字筛选参数 chunkName // 传递文本块名称筛选参数 ); return NextResponse.json(datasets); } catch (error) { console.error('获取数据集失败:', String(error)); return NextResponse.json( { error: error.message || '获取数据集失败' }, { status: 500 } ); } } /** * 删除数据集 */ export async function DELETE(request) { try { const { searchParams } = new URL(request.url); const datasetId = searchParams.get('id'); if (!datasetId) { return NextResponse.json( { error: 'Dataset ID cannot be empty' }, { status: 400 } ); } await deleteDataset(datasetId); return NextResponse.json({ success: true, message: 'Dataset deleted successfully' }); } catch (error) { console.error('Failed to delete dataset:', error); return NextResponse.json( { error: error.message || 'Failed to delete dataset' }, { status: 500 } ); } } /** * 编辑数据集 */ export async function PATCH(request) { try { const { searchParams } = new URL(request.url); const datasetId = searchParams.get('id'); const { answer, cot, question, confirmed } = await request.json(); if (!datasetId) { return NextResponse.json( { error: 'Dataset ID cannot be empty' }, { status: 400 } ); } // 获取所有数据集 let dataset = await getDatasetsById(datasetId); if (!dataset) { return NextResponse.json( { error: 'Dataset does not exist' }, { status: 404 } ); } let data = { id: datasetId }; if (confirmed !== undefined) data.confirmed = confirmed; if (answer) data.answer = answer; if (cot) data.cot = cot; if (question) data.question = question; // 保存更新后的数据集列表 await updateDataset(data); return NextResponse.json({ success: true, message: 'Dataset updated successfully', dataset: dataset }); } catch (error) { console.error('Failed to update dataset:', String(error)); return NextResponse.json( { error: error.message || 'Failed to update dataset' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/datasets/tags/route.js ================================================ import { NextResponse } from 'next/server'; import { getUsedCustomTags } from '@/lib/db/datasets'; /** * 获取项目中使用过的自定义标签 */ export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } const tags = await getUsedCustomTags(projectId); return NextResponse.json({ tags }); } catch (error) { console.error('获取自定义标签失败:', String(error)); return NextResponse.json( { error: error.message || '获取自定义标签失败' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/default-prompts/route.js ================================================ import { NextResponse } from 'next/server'; // 获取默认提示词内容 export async function GET(request, { params }) { try { const { searchParams } = new URL(request.url); const promptType = searchParams.get('promptType'); const promptKey = searchParams.get('promptKey'); if (!promptType || !promptKey) { return NextResponse.json({ error: 'promptType and promptKey are required' }, { status: 400 }); } // 动态导入对应的提示词模块 let promptModule; try { promptModule = await import(`@/lib/llm/prompts/${promptType}`); } catch (error) { return NextResponse.json({ error: `Prompt module ${promptType} not found` }, { status: 404 }); } // 获取指定的提示词常量 const promptContent = promptModule[promptKey]; if (!promptContent) { return NextResponse.json({ error: `Prompt key ${promptKey} not found in module ${promptType}` }, { status: 404 }); } return NextResponse.json({ success: true, content: promptContent, promptType, promptKey }); } catch (error) { console.error('获取默认提示词失败:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/distill/questions/by-tag/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; /** * 根据标签ID获取问题列表 */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const tagId = searchParams.get('tagId'); // 验证参数 if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } if (!tagId) { return NextResponse.json({ error: '标签ID不能为空' }, { status: 400 }); } // 获取标签信息 const tag = await db.tags.findUnique({ where: { id: tagId } }); if (!tag) { return NextResponse.json({ error: '标签不存在' }, { status: 404 }); } // 获取或创建蒸馏文本块 let distillChunk = await db.chunks.findFirst({ where: { projectId, name: 'Distilled Content' } }); if (!distillChunk) { // 创建一个特殊的蒸馏文本块 distillChunk = await db.chunks.create({ data: { name: 'Distilled Content', projectId, fileId: 'distilled', fileName: 'distilled.md', content: 'This text block is used to store questions generated through data distillation and is not related to actual literature.', summary: 'Questions generated through data distillation', size: 0 } }); } const questions = await db.questions.findMany({ where: { projectId, label: tag.label, chunkId: distillChunk.id } }); return NextResponse.json(questions); } catch (error) { console.error('[distill/questions/by-tag] 获取问题失败:', String(error)); return NextResponse.json({ error: error.message || '获取问题失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/distill/questions/route.js ================================================ import { NextResponse } from 'next/server'; import { distillQuestionsPrompt } from '@/lib/llm/prompts/distillQuestions'; import { db } from '@/lib/db'; const LLMClient = require('@/lib/llm/core'); /** * 生成问题接口:根据某个标签链路构造指定数量的问题 */ export async function POST(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } const { tagPath, currentTag, tagId, count = 5, model, language = 'zh' } = await request.json(); if (!currentTag || !tagPath) { const errorMsg = language === 'en' ? 'Tag information cannot be empty' : '标签信息不能为空'; return NextResponse.json({ error: errorMsg }, { status: 400 }); } // 首先获取或创建蒸馏文本块 let distillChunk = await db.chunks.findFirst({ where: { projectId, name: 'Distilled Content' } }); if (!distillChunk) { // 创建一个特殊的蒸馏文本块 distillChunk = await db.chunks.create({ data: { name: 'Distilled Content', projectId, fileId: 'distilled', fileName: 'distilled.md', content: 'This text block is used to store questions generated through data distillation and is not related to actual literature.', summary: 'Questions generated through data distillation', size: 0 } }); } // 获取已有的问题,避免重复 const existingQuestions = await db.questions.findMany({ where: { projectId, label: currentTag, chunkId: distillChunk.id // 使用蒸馏文本块的 ID }, select: { question: true } }); const existingQuestionTexts = existingQuestions.map(q => q.question); const llmClient = new LLMClient(model); const prompt = await distillQuestionsPrompt( language, { tagPath, currentTag, count, existingQuestionTexts }, projectId ); const { answer } = await llmClient.getResponseWithCOT(prompt); let questions = []; try { questions = JSON.parse(answer); } catch (error) { console.error('解析问题JSON失败:', String(error)); // 尝试使用正则表达式提取问题 const matches = answer.match(/"([^"]+)"/g); if (matches) { questions = matches.map(match => match.replace(/"/g, '')); } } // 保存问题到数据库 const savedQuestions = []; for (const questionText of questions) { const question = await db.questions.create({ data: { question: questionText, projectId, label: currentTag, chunkId: distillChunk.id } }); savedQuestions.push(question); } return NextResponse.json(savedQuestions); } catch (error) { console.error('生成问题失败:', String(error)); return NextResponse.json({ error: error.message || '生成问题失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/distill/tags/[tagId]/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; /** * 更新标签接口 */ export async function PUT(request, { params }) { try { const { projectId, tagId } = params; // 验证参数 if (!projectId || !tagId) { return NextResponse.json({ error: '项目ID和标签ID不能为空' }, { status: 400 }); } const { label } = await request.json(); if (!label || !label.trim()) { return NextResponse.json({ error: '标签名称不能为空' }, { status: 400 }); } // 检查标签是否存在 const existingTag = await db.tags.findUnique({ where: { id: tagId } }); if (!existingTag) { return NextResponse.json({ error: '标签不存在' }, { status: 404 }); } // 检查项目ID是否匹配 if (existingTag.projectId !== projectId) { return NextResponse.json({ error: '无权限编辑此标签' }, { status: 403 }); } // 检查新标签名称是否已存在(同级标签) const duplicateTag = await db.tags.findFirst({ where: { projectId, label: label.trim(), parentId: existingTag.parentId, id: { not: tagId } } }); if (duplicateTag) { return NextResponse.json({ error: '同级标签名称已存在' }, { status: 400 }); } // 更新标签 const updatedTag = await db.tags.update({ where: { id: tagId }, data: { label: label.trim() } }); return NextResponse.json(updatedTag); } catch (error) { console.error('[标签编辑] 更新标签失败:', String(error)); return NextResponse.json({ error: error.message || '更新标签失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/distill/tags/all/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; /** * 获取项目的所有蒸馏标签 */ export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } // 获取所有标签 const tags = await db.tags.findMany({ where: { projectId }, orderBy: { label: 'asc' } }); return NextResponse.json(tags); } catch (error) { console.error('获取蒸馏标签失败:', String(error)); return NextResponse.json({ error: error.message || '获取蒸馏标签失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/distill/tags/route.js ================================================ import { NextResponse } from 'next/server'; import { distillTagsPrompt } from '@/lib/llm/prompts/distillTags'; import { db } from '@/lib/db'; import { getProject } from '@/lib/db/projects'; const LLMClient = require('@/lib/llm/core'); /** * 生成标签接口:根据顶级主题、某级标签构造指定数量的子标签 */ export async function POST(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } const { parentTag, parentTagId, tagPath, count = 10, model, language = 'zh' } = await request.json(); if (!parentTag) { const errorMsg = language === 'en' ? 'Topic tag name cannot be empty' : '主题标签名称不能为空'; return NextResponse.json({ error: errorMsg }, { status: 400 }); } // 查询现有标签 const existingTags = await db.tags.findMany({ where: { projectId, parentId: parentTagId || null } }); const existingTagNames = existingTags.map(tag => tag.label); // 创建LLM客户端 const llmClient = new LLMClient(model); // 生成提示词 const prompt = await distillTagsPrompt( language, { tagPath, parentTag, existingTags: existingTagNames, count }, projectId ); // 调用大模型生成标签 const { answer } = await llmClient.getResponseWithCOT(prompt); // 解析返回的标签 let tags = []; try { tags = JSON.parse(answer); } catch (error) { console.error('解析标签JSON失败:', String(error)); // 尝试使用正则表达式提取标签 const matches = answer.match(/"([^"]+)"/g); if (matches) { tags = matches.map(match => match.replace(/"/g, '')); } } // 保存标签到数据库 const savedTags = []; for (let i = 0; i < tags.length; i++) { const tagName = tags[i]; try { const tag = await db.tags.create({ data: { label: tagName, projectId, parentId: parentTagId || null } }); savedTags.push(tag); } catch (error) { console.error(`[标签生成] 保存标签 ${tagName} 失败:`, String(error)); throw error; } } return NextResponse.json(savedTags); } catch (error) { console.error('[标签生成] 生成标签失败:', String(error)); console.error('[标签生成] 错误堆栈:', error.stack); return NextResponse.json({ error: error.message || '生成标签失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/[evalId]/route.js ================================================ import { NextResponse } from 'next/server'; import { getEvalQuestionById, updateEvalQuestion, deleteEvalQuestion } from '@/lib/db/evalDatasets'; import { db } from '@/lib/db/index'; /** * Get evaluation dataset details by ID * Supports operateType=prev|next to navigate neighbors */ export async function GET(request, { params }) { try { const { projectId, evalId } = params; const { searchParams } = new URL(request.url); const operateType = searchParams.get('operateType'); // Navigation request (prev/next) if (operateType) { const current = await db.evalDatasets.findUnique({ where: { id: evalId }, select: { createAt: true } }); if (!current) { return NextResponse.json(null); } let neighbor = null; if (operateType === 'prev') { // Get previous item (newer createAt when list is sorted desc) neighbor = await db.evalDatasets.findFirst({ where: { projectId, createAt: { gt: current.createAt } }, orderBy: { createAt: 'asc' }, select: { id: true } }); } else if (operateType === 'next') { // Get next item (older createAt) neighbor = await db.evalDatasets.findFirst({ where: { projectId, createAt: { lt: current.createAt } }, orderBy: { createAt: 'desc' }, select: { id: true } }); } return NextResponse.json(neighbor || null); } // Regular detail request const evalQuestion = await getEvalQuestionById(evalId); if (!evalQuestion) { return NextResponse.json({ error: 'Eval question not found' }, { status: 404 }); } return NextResponse.json(evalQuestion); } catch (error) { console.error('Failed to get eval question:', error); return NextResponse.json({ error: error.message || 'Failed to get eval question' }, { status: 500 }); } } /** * Update evaluation dataset */ export async function PUT(request, { params }) { try { const { evalId } = params; const data = await request.json(); // Only allow specific fields const allowedFields = ['question', 'options', 'correctAnswer', 'tags', 'note']; const updateData = {}; for (const field of allowedFields) { if (data[field] !== undefined) { updateData[field] = data[field]; } } const updated = await updateEvalQuestion(evalId, updateData); return NextResponse.json(updated); } catch (error) { console.error('Failed to update eval question:', error); return NextResponse.json({ error: error.message || 'Failed to update eval question' }, { status: 500 }); } } /** * Delete evaluation dataset */ export async function DELETE(request, { params }) { try { const { evalId } = params; await deleteEvalQuestion(evalId); return NextResponse.json({ success: true }); } catch (error) { console.error('Failed to delete eval question:', error); return NextResponse.json({ error: error.message || 'Failed to delete eval question' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/count/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { buildEvalQuestionWhere } from '@/lib/db/evalDatasets'; export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const questionType = searchParams.get('questionType') || ''; const keyword = searchParams.get('keyword') || ''; const chunkId = searchParams.get('chunkId') || ''; const questionTypes = searchParams.getAll('questionTypes') || []; const tags = searchParams.getAll('tags').length > 0 ? searchParams.getAll('tags') : searchParams.get('tag') ? searchParams.get('tag').split(',') : []; const where = buildEvalQuestionWhere(projectId, { questionType: questionType || undefined, questionTypes: questionTypes.length > 0 ? questionTypes : undefined, keyword: keyword || undefined, chunkId: chunkId || undefined, tags: tags.length > 0 ? tags : undefined }); const [total, byTypeRaw] = await Promise.all([ db.evalDatasets.count({ where }), db.evalDatasets.groupBy({ by: ['questionType'], where, _count: { id: true } }) ]); const byType = {}; byTypeRaw.forEach(item => { byType[item.questionType] = item._count.id; }); const hasShortAnswer = (byType.short_answer || 0) > 0; const hasOpenEnded = (byType.open_ended || 0) > 0; const hasSubjective = hasShortAnswer || hasOpenEnded; return NextResponse.json( { code: 0, data: { total, byType, hasSubjective, hasShortAnswer, hasOpenEnded } }, { status: 200 } ); } catch (error) { console.error('Failed to count eval datasets:', error); return NextResponse.json( { code: 500, error: 'Failed to count eval datasets', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/export/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import { buildEvalQuestionWhere } from '@/lib/db/evalDatasets'; const BATCH_SIZE = 500; /** * Convert an evaluation item to a CSV row */ function convertToCSVRow(item, isHeader = false) { if (isHeader) { return ['questionType', 'question', 'options', 'correctAnswer', 'tags'].join(','); } const escapeCSV = str => { if (str === null || str === undefined) return ''; const strValue = String(str); if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) { return `"${strValue.replace(/"/g, '""')}"`; } return strValue; }; return [ escapeCSV(item.questionType), escapeCSV(item.question), escapeCSV(item.options), escapeCSV(item.correctAnswer), escapeCSV(item.tags) ].join(','); } /** * Convert an evaluation item to export format */ function formatExportItem(item) { return { questionType: item.questionType, question: item.question, options: item.options, correctAnswer: item.correctAnswer, tags: item.tags }; } /** * Export evaluation datasets * Supports JSON, JSONL, and CSV * Uses batched streaming for large datasets */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { format = 'json', // json | jsonl | csv questionTypes = [], tags = [], keyword = '' } = body; // Validate format if (!['json', 'jsonl', 'csv'].includes(format)) { return NextResponse.json({ code: 400, error: 'Unsupported export format' }, { status: 400 }); } // Build query conditions const where = buildEvalQuestionWhere(projectId, { questionTypes: questionTypes.length > 0 ? questionTypes : undefined, tags: tags.length > 0 ? tags : undefined, keyword: keyword || undefined }); // Fetch total count const total = await db.evalDatasets.count({ where }); if (total === 0) { return NextResponse.json({ code: 400, error: 'No data matches the criteria' }, { status: 400 }); } // Return directly for small datasets if (total <= 1000) { const items = await db.evalDatasets.findMany({ where, orderBy: { createAt: 'desc' } }); const formattedItems = items.map(formatExportItem); if (format === 'json') { return new Response(JSON.stringify(formattedItems, null, 2), { headers: { 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="eval-datasets-${Date.now()}.json"` } }); } if (format === 'jsonl') { const jsonlContent = formattedItems.map(item => JSON.stringify(item)).join('\n'); return new Response(jsonlContent, { headers: { 'Content-Type': 'application/x-ndjson', 'Content-Disposition': `attachment; filename="eval-datasets-${Date.now()}.jsonl"` } }); } if (format === 'csv') { const csvContent = [convertToCSVRow(null, true), ...items.map(item => convertToCSVRow(item))].join('\n'); return new Response('\uFEFF' + csvContent, { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="eval-datasets-${Date.now()}.csv"` } }); } } // Stream export for large datasets const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); let isFirst = true; // CSV outputs header row first if (format === 'csv') { controller.enqueue(encoder.encode('\uFEFF' + convertToCSVRow(null, true) + '\n')); } // JSON outputs opening bracket if (format === 'json') { controller.enqueue(encoder.encode('[\n')); } // Fetch data in batches const totalBatches = Math.ceil(total / BATCH_SIZE); for (let batch = 0; batch < totalBatches; batch++) { const items = await db.evalDatasets.findMany({ where, orderBy: { createAt: 'desc' }, skip: batch * BATCH_SIZE, take: BATCH_SIZE }); for (const item of items) { const formattedItem = formatExportItem(item); if (format === 'json') { const prefix = isFirst ? '' : ',\n'; controller.enqueue(encoder.encode(prefix + JSON.stringify(formattedItem))); isFirst = false; } else if (format === 'jsonl') { controller.enqueue(encoder.encode(JSON.stringify(formattedItem) + '\n')); } else if (format === 'csv') { controller.enqueue(encoder.encode(convertToCSVRow(item) + '\n')); } } } // JSON outputs closing bracket if (format === 'json') { controller.enqueue(encoder.encode('\n]')); } controller.close(); } }); const contentTypes = { json: 'application/json', jsonl: 'application/x-ndjson', csv: 'text/csv; charset=utf-8' }; const extensions = { json: 'json', jsonl: 'jsonl', csv: 'csv' }; return new Response(stream, { headers: { 'Content-Type': contentTypes[format], 'Content-Disposition': `attachment; filename="eval-datasets-${Date.now()}.${extensions[format]}"`, 'Transfer-Encoding': 'chunked' } }); } catch (error) { console.error('Failed to export eval datasets:', error); return NextResponse.json({ code: 500, error: error.message || 'Export failed' }, { status: 500 }); } } /** * Get export preview (count only) */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); // Parse query params const questionTypes = searchParams.getAll('questionTypes'); const tags = searchParams.getAll('tags'); const keyword = searchParams.get('keyword') || ''; // Build query conditions const where = buildEvalQuestionWhere(projectId, { questionTypes: questionTypes.length > 0 ? questionTypes : undefined, tags: tags.length > 0 ? tags : undefined, keyword: keyword || undefined }); // Count rows const total = await db.evalDatasets.count({ where }); return NextResponse.json({ code: 0, data: { total, isLargeDataset: total > 1000 } }); } catch (error) { console.error('Failed to get export preview:', error); return NextResponse.json({ code: 500, error: error.message || 'Failed to get export preview' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/import/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import { nanoid } from 'nanoid'; import * as XLSX from 'xlsx'; /** * Validate true/false item schema */ function validateTrueFalse(item, index) { const errors = []; if (!item.question || typeof item.question !== 'string') { errors.push(`Item ${index + 1}: missing or invalid "question"`); } if (!item.correctAnswer || (item.correctAnswer !== '✅' && item.correctAnswer !== '❌')) { errors.push(`Item ${index + 1}: "correctAnswer" must be "✅" or "❌"`); } return errors; } /** * Validate single-choice item schema */ function validateSingleChoice(item, index) { const errors = []; if (!item.question || typeof item.question !== 'string') { errors.push(`Item ${index + 1}: missing or invalid "question"`); } // Normalize options let options = item.options; if (typeof options === 'string') { try { options = JSON.parse(options); } catch (e) { errors.push(`Item ${index + 1}: invalid "options" format; unable to parse`); return errors; } } if (!options || !Array.isArray(options) || options.length < 2) { errors.push(`Item ${index + 1}: "options" must be an array with at least 2 items`); } if (!item.correctAnswer || !/^[A-Z]$/.test(item.correctAnswer)) { errors.push(`Item ${index + 1}: "correctAnswer" must be a single uppercase letter (A-Z)`); } return errors; } /** * Validate multiple-choice item schema */ function validateMultipleChoice(item, index) { const errors = []; if (!item.question || typeof item.question !== 'string') { errors.push(`Item ${index + 1}: missing or invalid "question"`); } // Normalize options let options = item.options; if (typeof options === 'string') { try { options = JSON.parse(options); } catch (e) { errors.push(`Item ${index + 1}: invalid "options" format; unable to parse`); return errors; } } if (!options || !Array.isArray(options) || options.length < 2) { errors.push(`Item ${index + 1}: "options" must be an array with at least 2 items`); } // Normalize correctAnswer let correctAnswer = item.correctAnswer; if (typeof correctAnswer === 'string') { try { correctAnswer = JSON.parse(correctAnswer); } catch (e) { errors.push(`Item ${index + 1}: invalid "correctAnswer" format; unable to parse`); return errors; } } if (!correctAnswer || !Array.isArray(correctAnswer) || correctAnswer.length < 1) { errors.push(`Item ${index + 1}: "correctAnswer" must be an array with at least 1 item`); } // Validate each answer token if (Array.isArray(correctAnswer)) { for (const ans of correctAnswer) { if (!/^[A-Z]$/.test(ans)) { errors.push(`Item ${index + 1}: "${ans}" is not a valid option letter in "correctAnswer"`); } } } return errors; } /** * Validate QA item schema (short_answer and open_ended) */ function validateQA(item, index) { const errors = []; if (!item.question || typeof item.question !== 'string') { errors.push(`Item ${index + 1}: missing or invalid "question"`); } if (!item.correctAnswer || typeof item.correctAnswer !== 'string') { errors.push(`Item ${index + 1}: missing or invalid "correctAnswer"`); } return errors; } /** * Validate data by question type */ function validateData(data, questionType) { const allErrors = []; for (let i = 0; i < data.length; i++) { let errors = []; switch (questionType) { case 'true_false': errors = validateTrueFalse(data[i], i); break; case 'single_choice': errors = validateSingleChoice(data[i], i); break; case 'multiple_choice': errors = validateMultipleChoice(data[i], i); break; case 'short_answer': case 'open_ended': errors = validateQA(data[i], i); break; default: errors = [`Unsupported question type: ${questionType}`]; } allErrors.push(...errors); } return allErrors; } /** * Parse an Excel file */ function parseExcel(buffer, questionType) { const excelHeaders = { question: '\u9898\u76ee', correctAnswer: '\u6b63\u786e\u7b54\u6848', answer: '\u7b54\u6848', options: '\u9009\u9879' }; const workbook = XLSX.read(buffer, { type: 'buffer' }); const sheetName = workbook.SheetNames[0]; const sheet = workbook.Sheets[sheetName]; const rawData = XLSX.utils.sheet_to_json(sheet, { defval: '' }); // Convert to normalized schema const data = rawData.map(row => { const item = { question: row.question || row[excelHeaders.question] || '', correctAnswer: row.correctAnswer || row[excelHeaders.correctAnswer] || row[excelHeaders.answer] || '' }; // Handle options (choice questions) if (questionType === 'single_choice' || questionType === 'multiple_choice') { // Try to parse from options column if (row.options || row[excelHeaders.options]) { let optionsStr = (row.options || row[excelHeaders.options]).trim(); // Replace single quotes so it becomes valid JSON if (optionsStr.startsWith('[') && optionsStr.includes("'")) { optionsStr = optionsStr.replace(/'/g, '"'); } try { // Try JSON parsing item.options = JSON.parse(optionsStr); } catch { // Fallback: split by separators item.options = optionsStr .split(/[,;|,;]/) .map(o => o.trim()) .filter(Boolean); } } } // Handle multiple-choice correctAnswer if (questionType === 'multiple_choice') { if (typeof item.correctAnswer === 'string') { let answerStr = item.correctAnswer.trim(); // Replace single quotes so it becomes valid JSON if (answerStr.startsWith('[') && answerStr.includes("'")) { answerStr = answerStr.replace(/'/g, '"'); } // Try JSON parsing try { item.correctAnswer = JSON.parse(answerStr); } catch { // Split string such as "A,B,C" or "ABC" if (answerStr.includes(',') || answerStr.includes(',')) { item.correctAnswer = answerStr.split(/[,,]/).map(a => a.trim().toUpperCase()); } else { // Split characters such as "ABC" -> ["A", "B", "C"] item.correctAnswer = answerStr .toUpperCase() .split('') .filter(c => /[A-Z]/.test(c)); } } } } return item; }); return data; } /** * Parse a JSON file */ function parseJSON(content) { return JSON.parse(content); } /** * POST - Import evaluation datasets */ export async function POST(request, { params }) { try { const { projectId } = params; const formData = await request.formData(); const file = formData.get('file'); const questionType = formData.get('questionType'); const tags = formData.get('tags') || ''; console.log(`[Import] Start processing. Project: ${projectId}, questionType: ${questionType}, tags: ${tags}`); if (!file) { return NextResponse.json({ code: 400, error: 'Please upload a file' }, { status: 400 }); } if (!questionType) { return NextResponse.json({ code: 400, error: 'Please select a question type' }, { status: 400 }); } // Validate question type const validTypes = ['true_false', 'single_choice', 'multiple_choice', 'short_answer', 'open_ended']; if (!validTypes.includes(questionType)) { return NextResponse.json({ code: 400, error: `Unsupported question type: ${questionType}` }, { status: 400 }); } // Get file extension const fileName = file.name; const fileExt = fileName.split('.').pop().toLowerCase(); console.log(`[Import] File name: ${fileName}, extension: ${fileExt}`); // Validate file type if (!['json', 'xls', 'xlsx'].includes(fileExt)) { return NextResponse.json( { code: 400, error: 'Unsupported file format. Please upload a json, xls, or xlsx file' }, { status: 400 } ); } // Read file content const buffer = await file.arrayBuffer(); let data = []; // Parse file console.log('[Import] Parsing file...'); if (fileExt === 'json') { const content = new TextDecoder().decode(buffer); data = parseJSON(content); } else { data = parseExcel(Buffer.from(buffer), questionType); } console.log(`[Import] Parsing completed. Total items: ${data.length}`); if (!Array.isArray(data) || data.length === 0) { return NextResponse.json({ code: 400, error: 'File is empty or has an invalid format' }, { status: 400 }); } // Validate data console.log('[Import] Validating data...'); const errors = validateData(data, questionType); if (errors.length > 0) { console.log(`[Import] Validation failed. Error count: ${errors.length}`); return NextResponse.json( { code: 400, error: 'Data validation failed', details: errors.slice(0, 10), totalErrors: errors.length }, { status: 400 } ); } console.log('[Import] Validation passed. Writing to database...'); // Prepare data const now = new Date(); const evalDatasets = data.map(item => { // Normalize options let options = item.options; if (typeof options === 'string') { try { options = JSON.parse(options); } catch (e) { // Keep original on parse failure } } // Normalize correctAnswer let correctAnswer = item.correctAnswer; if (typeof correctAnswer === 'string' && questionType === 'multiple_choice') { try { correctAnswer = JSON.parse(correctAnswer); } catch (e) { // Keep original on parse failure } } return { id: nanoid(), projectId, question: item.question, questionType, options: options ? JSON.stringify(options) : '', // For multiple_choice, store correctAnswer as JSON array string correctAnswer: Array.isArray(correctAnswer) ? JSON.stringify(correctAnswer) : correctAnswer, tags: tags || '', note: '', createAt: now, updateAt: now }; }); // Batch insert const batchSize = 100; let insertedCount = 0; for (let i = 0; i < evalDatasets.length; i += batchSize) { const batch = evalDatasets.slice(i, i + batchSize); await db.evalDatasets.createMany({ data: batch }); insertedCount += batch.length; console.log(`[Import] Inserted ${insertedCount}/${evalDatasets.length} items`); } console.log(`[Import] Import completed. Total inserted: ${insertedCount}`); return NextResponse.json({ code: 0, data: { total: insertedCount, questionType, tags }, message: `Successfully imported ${insertedCount} evaluation items` }); } catch (error) { console.error('[Import] Import failed:', error); return NextResponse.json( { code: 500, error: 'Import failed', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/route.js ================================================ import { NextResponse } from 'next/server'; import { getEvalQuestionsWithPagination, getEvalQuestionsStats, deleteEvalQuestion } from '@/lib/db/evalDatasets'; /** * Get project's evaluation dataset list (paginated) */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); // Parse query params const page = parseInt(searchParams.get('page') || '1', 10); const pageSize = parseInt(searchParams.get('pageSize') || '20', 10); const questionType = searchParams.get('questionType') || ''; const questionTypes = searchParams.getAll('questionTypes'); const keyword = searchParams.get('keyword') || ''; const chunkId = searchParams.get('chunkId') || ''; // Support multiple tags params or comma-separated tag const tags = searchParams.getAll('tags').length > 0 ? searchParams.getAll('tags') : searchParams.get('tag') ? searchParams.get('tag').split(',') : []; const includeStats = searchParams.get('includeStats') === 'true'; const queryOptions = { page, pageSize, questionType: questionType || undefined, questionTypes: questionTypes.length > 0 ? questionTypes : undefined, keyword: keyword || undefined, chunkId: chunkId || undefined, tags: tags.length > 0 ? tags : undefined }; if (includeStats) { const [result, stats] = await Promise.all([ getEvalQuestionsWithPagination(projectId, queryOptions), getEvalQuestionsStats(projectId) ]); result.stats = stats; return NextResponse.json(result); } const result = await getEvalQuestionsWithPagination(projectId, queryOptions); return NextResponse.json(result); } catch (error) { console.error('Failed to get eval datasets:', error); return NextResponse.json({ error: error.message || 'Failed to get eval datasets' }, { status: 500 }); } } /** * Batch delete evaluation datasets */ export async function DELETE(request, { params }) { try { const { ids } = await request.json(); if (!ids || !Array.isArray(ids) || ids.length === 0) { return NextResponse.json({ error: 'Invalid request: ids array is required' }, { status: 400 }); } const results = await Promise.all(ids.map(id => deleteEvalQuestion(id).catch(err => ({ error: err.message, id })))); const deleted = results.filter(r => !r.error).length; const failed = results.filter(r => r.error).length; return NextResponse.json({ success: true, deleted, failed, message: `Successfully deleted ${deleted} items${failed > 0 ? `, ${failed} failed` : ''}` }); } catch (error) { console.error('Failed to delete eval datasets:', error); return NextResponse.json({ error: error.message || 'Failed to delete eval datasets' }, { status: 500 }); } } /** * Create a new evaluation dataset (or batch create) */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { createEvalQuestion, createManyEvalQuestions } = require('@/lib/db/evalDatasets'); // Handle batch creation if (Array.isArray(body) || (body.items && Array.isArray(body.items))) { const items = Array.isArray(body) ? body : body.items; if (items.length === 0) { return NextResponse.json({ success: true, count: 0 }); } // Validate items const validItems = items .map(item => { // 确保标签格式正确: 数组转为逗号分隔字符串 let tagsStr = item.tags || ''; if (Array.isArray(tagsStr)) { tagsStr = tagsStr.join(','); } return { projectId, question: item.question, questionType: item.questionType || 'open_ended', correctAnswer: typeof item.correctAnswer === 'object' ? JSON.stringify(item.correctAnswer) : item.correctAnswer, tags: tagsStr, note: item.note || '', chunkId: item.chunkId || null, options: item.options ? typeof item.options === 'object' ? JSON.stringify(item.options) : item.options : '' }; }) .filter(item => item.question && item.correctAnswer); if (validItems.length === 0) { return NextResponse.json({ error: 'No valid items to create' }, { status: 400 }); } const result = await createManyEvalQuestions(validItems); return NextResponse.json({ success: true, count: result.count }); } // Handle single creation const { question, correctAnswer, questionType = 'open_ended', tags, note, chunkId, options } = body; if (!question || !correctAnswer) { return NextResponse.json({ error: 'Question and Correct Answer are required' }, { status: 400 }); } // 确保标签格式正确: 数组转为逗号分隔字符串 let tagsStr = tags || ''; if (Array.isArray(tagsStr)) { tagsStr = tagsStr.join(','); } const evalDataset = await createEvalQuestion({ projectId, question, questionType, correctAnswer: typeof correctAnswer === 'object' ? JSON.stringify(correctAnswer) : correctAnswer, tags: tagsStr, note: note || '', chunkId: chunkId || null, options: options ? (typeof options === 'object' ? JSON.stringify(options) : options) : '' }); return NextResponse.json({ success: true, evalDataset }); } catch (error) { console.error('Failed to create eval dataset:', error); return NextResponse.json({ error: error.message || 'Failed to create eval dataset' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/sample/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { buildEvalQuestionWhere } from '@/lib/db/evalDatasets'; const SMALL_TOTAL_THRESHOLD = 5000; const HARD_LIMIT = 50000; function shuffleArray(arr) { const result = [...arr]; for (let i = result.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { questionType = '', questionTypes = [], keyword = '', chunkId = '', tags = [], limit = 0, strategy = 'random' } = body || {}; const where = buildEvalQuestionWhere(projectId, { questionType: questionType || undefined, questionTypes: Array.isArray(questionTypes) && questionTypes.length > 0 ? questionTypes : undefined, keyword: keyword || undefined, chunkId: chunkId || undefined, tags: Array.isArray(tags) && tags.length > 0 ? tags : undefined }); const total = await db.evalDatasets.count({ where }); if (total === 0) { return NextResponse.json( { code: 0, data: { total: 0, selectedCount: 0, ids: [], strategyUsed: strategy } }, { status: 200 } ); } let normalizedLimit = typeof limit === 'number' && limit > 0 ? Math.min(limit, HARD_LIMIT) : HARD_LIMIT; if (normalizedLimit >= total) { const items = await db.evalDatasets.findMany({ where, select: { id: true }, orderBy: { createAt: 'desc' } }); const ids = items.map(item => item.id); return NextResponse.json( { code: 0, data: { total, selectedCount: ids.length, ids, strategyUsed: total > HARD_LIMIT ? 'top' : strategy } }, { status: 200 } ); } let ids = []; let strategyUsed = strategy; if (total <= SMALL_TOTAL_THRESHOLD) { const items = await db.evalDatasets.findMany({ where, select: { id: true }, orderBy: { createAt: 'desc' } }); const shuffled = shuffleArray(items); ids = shuffled.slice(0, normalizedLimit).map(item => item.id); strategyUsed = 'random-small'; } else { const items = await db.evalDatasets.findMany({ where, select: { id: true }, orderBy: { createAt: 'desc' }, take: normalizedLimit }); ids = items.map(item => item.id); strategyUsed = 'top-latest'; } return NextResponse.json( { code: 0, data: { total, selectedCount: ids.length, ids, strategyUsed } }, { status: 200 } ); } catch (error) { console.error('Failed to sample eval datasets:', error); return NextResponse.json( { code: 500, error: 'Failed to sample eval datasets', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/eval-datasets/tags/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; /** * Get all evaluation dataset tags in the project */ export async function GET(request, { params }) { try { const { projectId } = params; // Fetch tags for all datasets in the project const datasets = await db.evalDatasets.findMany({ where: { projectId }, select: { tags: true } }); // Extract and de-duplicate tags const tagsSet = new Set(); datasets.forEach(dataset => { if (dataset.tags) { // Support both English and Chinese commas const tags = dataset.tags .split(/[,,]/) .map(t => t.trim()) .filter(Boolean); tags.forEach(tag => tagsSet.add(tag)); } }); return NextResponse.json({ tags: Array.from(tagsSet).sort() }); } catch (error) { console.error('Failed to get tags:', error); return NextResponse.json({ error: error.message || 'Failed to get tags' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/eval-tasks/[taskId]/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import { getEvalResultsByTaskId, getEvalResultsStats } from '@/lib/db/evalResults'; /** * Get evaluation task details and results */ export async function GET(request, { params }) { try { const { projectId, taskId } = params; if (!projectId || !taskId) { return NextResponse.json({ error: 'Project ID and Task ID are required' }, { status: 400 }); } // Fetch task details const task = await db.task.findUnique({ where: { id: taskId } }); if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } if (task.projectId !== projectId) { return NextResponse.json({ error: 'Task does not belong to this project' }, { status: 403 }); } // Parse task detail fields let detail = {}; let modelInfo = {}; try { detail = task.detail ? JSON.parse(task.detail) : {}; modelInfo = task.modelInfo ? JSON.parse(task.modelInfo) : {}; } catch (e) { console.error('Failed to parse task detail:', e); } // Parse query params const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page') || '1'); const pageSize = parseInt(searchParams.get('pageSize') || '10'); const type = searchParams.get('type') || null; const isCorrectStr = searchParams.get('isCorrect'); const isCorrect = isCorrectStr === 'true' ? true : isCorrectStr === 'false' ? false : null; // Fetch results (supports pagination and filters) const { items: results, total } = await getEvalResultsByTaskId(taskId, { page, pageSize, type, isCorrect }); // Fetch stats const stats = await getEvalResultsStats(taskId); return NextResponse.json({ code: 0, data: { task: { ...task, detail, modelInfo }, results, total, page, pageSize, stats } }); } catch (error) { console.error('Failed to fetch evaluation task details:', error); return NextResponse.json( { code: 500, error: 'Failed to fetch evaluation task details', message: error.message }, { status: 500 } ); } } /** * Delete evaluation task */ export async function DELETE(request, { params }) { try { const { projectId, taskId } = params; if (!projectId || !taskId) { return NextResponse.json({ error: 'Project ID and Task ID are required' }, { status: 400 }); } // Validate task exists and belongs to this project const task = await db.task.findUnique({ where: { id: taskId } }); if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } if (task.projectId !== projectId) { return NextResponse.json({ error: 'Task does not belong to this project' }, { status: 403 }); } // Delete evaluation results await db.evalResults.deleteMany({ where: { taskId } }); // Delete task await db.task.delete({ where: { id: taskId } }); return NextResponse.json({ code: 0, message: 'Deleted' }); } catch (error) { console.error('Failed to delete evaluation task:', error); return NextResponse.json( { code: 500, error: 'Failed to delete evaluation task', message: error.message }, { status: 500 } ); } } /** * Interrupt evaluation task */ export async function PUT(request, { params }) { try { const { projectId, taskId } = params; const data = await request.json(); const { action } = data; if (!projectId || !taskId) { return NextResponse.json({ error: 'Project ID and Task ID are required' }, { status: 400 }); } // Validate task exists and belongs to this project const task = await db.task.findUnique({ where: { id: taskId } }); if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } if (task.projectId !== projectId) { return NextResponse.json({ error: 'Task does not belong to this project' }, { status: 403 }); } if (action === 'interrupt') { // Interrupt task await db.task.update({ where: { id: taskId }, data: { status: 3, // Interrupted endTime: new Date() } }); return NextResponse.json({ code: 0, message: 'Task interrupted' }); } return NextResponse.json({ error: 'Unknown action' }, { status: 400 }); } catch (error) { console.error('Failed to operate evaluation task:', error); return NextResponse.json({ code: 500, error: 'Operation failed', message: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/eval-tasks/route.js ================================================ import { NextResponse } from 'next/server'; import { db } from '@/lib/db/index'; import { processTask } from '@/lib/services/tasks'; /** * Get all evaluation tasks for a project */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page') || '1'); const pageSize = parseInt(searchParams.get('pageSize') || '20'); if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const skip = (page - 1) * pageSize; // Fetch task list and total count const [tasks, total] = await Promise.all([ db.task.findMany({ where: { projectId, taskType: 'model-evaluation' }, orderBy: { createAt: 'desc' }, skip, take: pageSize }), db.task.count({ where: { projectId, taskType: 'model-evaluation' } }) ]); // Parse task detail fields const tasksWithDetails = tasks.map(task => { let detail = {}; let modelInfo = {}; try { detail = task.detail ? JSON.parse(task.detail) : {}; modelInfo = task.modelInfo ? JSON.parse(task.modelInfo) : {}; } catch (e) { console.error('Failed to parse task detail:', e); } return { ...task, detail, modelInfo }; }); return NextResponse.json({ code: 0, data: { items: tasksWithDetails, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }); } catch (error) { console.error('Failed to fetch evaluation task list:', error); return NextResponse.json( { code: 500, error: 'Failed to fetch evaluation task list', message: error.message }, { status: 500 } ); } } /** * Create evaluation tasks * Supports selecting multiple models and creating one task per model */ export async function POST(request, { params }) { try { const { projectId } = params; const data = await request.json(); const { models, // Models to evaluate: [{ modelId, providerId }] evalDatasetIds, // Evaluation question IDs judgeModelId, // Judge model ID (for subjective grading) judgeProviderId, // Judge provider ID language = 'zh-CN', filterOptions = {}, // Filter options (for display) customScoreAnchors = null // Custom score anchors for subjective grading } = data; // Validate required fields if (!models || models.length === 0) { return NextResponse.json({ code: 400, error: 'Please select at least one model to evaluate' }, { status: 400 }); } if (!evalDatasetIds || evalDatasetIds.length === 0) { return NextResponse.json({ code: 400, error: 'Please select questions to evaluate' }, { status: 400 }); } // Check for subjective questions const evalDatasets = await db.evalDatasets.findMany({ where: { id: { in: evalDatasetIds }, projectId }, select: { questionType: true } }); const hasSubjectiveQuestions = evalDatasets.some( q => q.questionType === 'short_answer' || q.questionType === 'open_ended' ); // If there are subjective questions, a judge model is required if (hasSubjectiveQuestions && (!judgeModelId || !judgeProviderId)) { return NextResponse.json( { code: 400, error: 'Short-answer or open-ended questions found. Please select a judge model for grading' }, { status: 400 } ); } // Judge model must not be the same as any test model if (judgeModelId && judgeProviderId) { const judgeModel = { modelId: judgeModelId, providerId: judgeProviderId }; const isJudgeInTestModels = models.some( m => m.modelId === judgeModel.modelId && m.providerId === judgeModel.providerId ); if (isJudgeInTestModels) { return NextResponse.json( { code: 400, error: 'Judge model cannot be the same as a test model' }, { status: 400 } ); } } // Create one task per model const createdTasks = []; for (const model of models) { const { modelId, providerId } = model; // Fetch full model config const modelConfig = await db.modelConfig.findFirst({ where: { projectId, providerId, modelId } }); // Keep providerId for lookup, add providerName for display const modelInfo = { modelId, modelName: modelConfig?.modelName || modelId, providerId: providerId, // Provider ID (DB ID) providerName: modelConfig?.providerName || providerId // Provider display name }; // Build task detail const taskDetail = { evalDatasetIds, judgeModelId: judgeModelId || null, judgeProviderId: judgeProviderId || null, filterOptions, hasSubjectiveQuestions, customScoreAnchors: customScoreAnchors || null // Store custom score anchors }; // Create task const newTask = await db.task.create({ data: { projectId, taskType: 'model-evaluation', status: 0, // Processing modelInfo: JSON.stringify(modelInfo), language, detail: JSON.stringify(taskDetail), totalCount: evalDatasetIds.length, completedCount: 0, note: '' } }); createdTasks.push(newTask); // Start task processing asynchronously processTask(newTask.id).catch(err => { console.error(`Failed to start evaluation task: ${newTask.id}`, err); }); } return NextResponse.json({ code: 0, data: createdTasks, message: `Successfully created ${createdTasks.length} evaluation tasks` }); } catch (error) { console.error('Failed to create evaluation task:', error); return NextResponse.json( { code: 500, error: 'Failed to create evaluation task', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/files/[fileId]/ga-pairs/route.js ================================================ import { NextResponse } from 'next/server'; import { getGaPairsByFileId, toggleGaPairActive, saveGaPairs, createGaPairs } from '@/lib/db/ga-pairs'; import { getUploadFileInfoById } from '@/lib/db/upload-files'; import { generateGaPairs } from '@/lib/services/ga/ga-generation'; import logger from '@/lib/util/logger'; import { db } from '@/lib/db/index'; /** * 生成文件的 GA 对 */ export async function POST(request, { params }) { try { const { projectId, fileId } = params; const { regenerate = false, appendMode = false, language = '中文' } = await request.json(); // 验证参数 if (!projectId || !fileId) { return NextResponse.json({ error: 'Project ID and File ID are required' }, { status: 400 }); } logger.info(`Starting GA pairs generation for project: ${projectId}, file: ${fileId}, appendMode: ${appendMode}`); // 检查文件是否存在 const file = await getUploadFileInfoById(fileId); if (!file || file.projectId !== projectId) { return NextResponse.json({ error: 'File not found or does not belong to the project' }, { status: 404 }); } // 获取现有的GA对 const existingGaPairs = await getGaPairsByFileId(fileId); // 如果是追加模式且已有GA对,或者不是重新生成且已存在GA对 if (!regenerate && !appendMode && existingGaPairs.length > 0) { return NextResponse.json({ success: true, message: 'GA pairs already exist for this file', data: existingGaPairs }); } // 读取文件内容 const fileContent = await getFileContent(projectId, file.fileName); if (!fileContent) { return NextResponse.json({ error: 'Failed to read file content' }, { status: 500 }); } logger.info(`File content loaded successfully, length: ${fileContent.length}`); // 检查模型配置 try { const { getActiveModel } = await import('@/lib/services/models'); const activeModel = await getActiveModel(projectId); if (!activeModel) { logger.error('No active model configuration found'); return NextResponse.json( { error: 'No active AI model configured. Please configure a model in settings first.' }, { status: 400 } ); } logger.info(`Using active model: ${activeModel.provider} - ${activeModel.model}`); } catch (modelError) { logger.error('Error checking model configuration:', modelError); return NextResponse.json( { error: 'Failed to load model configuration. Please check your AI model settings.' }, { status: 500 } ); } // 调用 LLM 生成 GA 对 logger.info(`Generating GA pairs for file: ${file.fileName}`); let generatedGaPairs; try { generatedGaPairs = await generateGaPairs(fileContent, projectId, language); if (!generatedGaPairs || generatedGaPairs.length === 0) { logger.warn('No GA pairs generated from LLM'); return NextResponse.json( { error: 'No GA pairs could be generated from the file content. The content might be too short or not suitable for GA pair generation.' }, { status: 400 } ); } logger.info(`Successfully generated ${generatedGaPairs.length} GA pairs from LLM`); } catch (generationError) { logger.error('GA pairs generation failed:', generationError); // 现有的错误处理逻辑... let errorMessage = 'Failed to generate GA pairs'; if (generationError.message.includes('No active model')) { errorMessage = 'No active AI model available. Please configure and activate a model in settings.'; } else if (generationError.message.includes('API key')) { errorMessage = 'Invalid API key or model configuration. Please check your AI model settings.'; } else if (generationError.message.includes('rate limit')) { errorMessage = 'API rate limit exceeded. Please try again later.'; } else { errorMessage = `AI model error: ${generationError.message}`; } return NextResponse.json({ error: errorMessage }, { status: 500 }); } // 保存到数据库 try { if (appendMode && existingGaPairs.length > 0) { // 追加模式:只保存新生成的GA对,不删除现有的 logger.info(`Appending ${generatedGaPairs.length} new GA pairs to existing ${existingGaPairs.length} pairs`); // 为新GA对设置正确的pairNumber const startPairNumber = existingGaPairs.length + 1; const newGaPairData = generatedGaPairs.map((pair, index) => ({ projectId, fileId, pairNumber: startPairNumber + index, genreTitle: pair.genre?.title || pair.genreTitle || '', genreDesc: pair.genre?.description || pair.genreDesc || '', audienceTitle: pair.audience?.title || pair.audienceTitle || '', audienceDesc: pair.audience?.description || pair.audienceDesc || '', isActive: true })); // 只创建新的GA对,不删除现有的 await createGaPairs(newGaPairData); logger.info('New GA pairs appended to database successfully'); } else { // 覆盖模式:删除现有的,保存新的 await saveGaPairs(projectId, fileId, generatedGaPairs); logger.info('GA pairs saved to database successfully'); } } catch (saveError) { logger.error('Failed to save GA pairs to database:', saveError); return NextResponse.json( { error: 'Generated GA pairs successfully but failed to save to database' }, { status: 500 } ); } // 获取保存后的所有GA对 const allGaPairs = await getGaPairsByFileId(fileId); if (appendMode && existingGaPairs.length > 0) { // 追加模式:只返回新生成的GA对 const newGaPairs = allGaPairs.slice(existingGaPairs.length); logger.info(`Successfully appended ${newGaPairs.length} GA pairs. Total pairs: ${allGaPairs.length}`); return NextResponse.json({ success: true, message: `${newGaPairs.length} new GA pairs appended successfully`, data: newGaPairs, total: allGaPairs.length }); } else { // 覆盖模式:返回所有GA对 logger.info(`Successfully generated and saved ${allGaPairs.length} GA pairs for file: ${file.fileName}`); return NextResponse.json({ success: true, message: 'GA pairs generated successfully', data: allGaPairs }); } } catch (error) { logger.error('Unexpected error in GA pairs generation:', error); return NextResponse.json( { error: error.message || 'Unexpected error occurred during GA pairs generation' }, { status: 500 } ); } } /** * 获取文件的 GA 对 */ export async function GET(request, { params }) { try { const { projectId, fileId } = params; if (!projectId || !fileId) { return NextResponse.json({ error: 'Project ID and File ID are required' }, { status: 400 }); } const gaPairs = await getGaPairsByFileId(fileId); return NextResponse.json({ success: true, data: gaPairs }); } catch (error) { console.error('Error getting GA pairs:', String(error)); return NextResponse.json({ error: 'Failed to get GA pairs' }, { status: 500 }); } } /** * 更新/替换文件的所有 GA 对 */ export async function PUT(request, { params }) { try { const { projectId, fileId } = params; const body = await request.json(); if (!projectId || !fileId) { return NextResponse.json({ error: 'Project ID and File ID are required' }, { status: 400 }); } const { updates } = body; if (!updates || !Array.isArray(updates)) { return NextResponse.json({ error: 'Updates array is required' }, { status: 400 }); } logger.info(`Replacing all GA pairs for file ${fileId} with ${updates.length} pairs`); // 使用数据库事务确保原子性操作 const results = await db.$transaction(async tx => { // 1. 先删除所有现有的GA对 await tx.gaPairs.deleteMany({ where: { fileId } }); // 2. 然后创建新的GA对 if (updates.length > 0) { const gaPairData = updates.map((pair, index) => ({ projectId, fileId, pairNumber: index + 1, genreTitle: pair.genreTitle || pair.genre?.title || pair.genre || '', genreDesc: pair.genreDesc || pair.genre?.description || '', audienceTitle: pair.audienceTitle || pair.audience?.title || pair.audience || '', audienceDesc: pair.audienceDesc || pair.audience?.description || '', isActive: pair.isActive !== undefined ? pair.isActive : true })); // 验证数据 for (const data of gaPairData) { if (!data.genreTitle || !data.audienceTitle) { throw new Error(`Invalid GA pair data: missing genre or audience title`); } } await tx.gaPairs.createMany({ data: gaPairData }); } // 3. 返回新创建的GA对 return await tx.gaPairs.findMany({ where: { fileId }, orderBy: { pairNumber: 'asc' } }); }); logger.info(`Successfully replaced GA pairs, new count: ${results.length}`); return NextResponse.json({ success: true, data: results }); } catch (error) { logger.error('Error updating GA pairs:', error); return NextResponse.json({ error: error.message || 'Failed to update GA pairs' }, { status: 500 }); } } /** * 切换 GA 对激活状态 */ export async function PATCH(request, { params }) { try { const { projectId, fileId } = params; const body = await request.json(); if (!projectId || !fileId) { return NextResponse.json({ error: 'Project ID and File ID are required' }, { status: 400 }); } const { gaPairId, isActive } = body; if (!gaPairId || typeof isActive !== 'boolean') { return NextResponse.json({ error: 'GA pair ID and active status are required' }, { status: 400 }); } const updatedPair = await toggleGaPairActive(gaPairId, isActive); return NextResponse.json({ success: true, data: updatedPair }); } catch (error) { console.error('Error toggling GA pair active status:', String(error)); return NextResponse.json({ error: 'Failed to toggle GA pair active status' }, { status: 500 }); } } // Helper function to read file content async function getFileContent(projectId, fileName) { try { const { getProjectRoot } = await import('@/lib/db/base'); const path = await import('path'); const fs = await import('fs'); const projectRoot = await getProjectRoot(); const filePath = path.join(projectRoot, projectId, 'files', fileName.replace('.pdf', '.md')); return await fs.promises.readFile(filePath, 'utf8'); } catch (error) { logger.error('Failed to read file content:', error); return null; } } ================================================ FILE: app/api/projects/[projectId]/files/route.js ================================================ import { NextResponse } from 'next/server'; import { getProject } from '@/lib/db/projects'; import path from 'path'; import { getProjectRoot, ensureDir } from '@/lib/db/base'; import { promises as fs } from 'fs'; import { checkUploadFileInfoByMD5, createUploadFileInfo, delUploadFileInfoById, getUploadFilesPagination } from '@/lib/db/upload-files'; import { getFileMD5 } from '@/lib/util/file'; import { batchSaveTags } from '@/lib/db/tags'; import { getProjectChunks, getProjectTocByName } from '@/lib/file/text-splitter'; import { handleDomainTree } from '@/lib/util/domain-tree'; // Replace the deprecated config export with the new export syntax export const dynamic = 'force-dynamic'; // This tells Next.js not to parse the request body automatically export const bodyParser = false; // 获取项目文件列表 export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page')) || 1; const pageSize = parseInt(searchParams.get('pageSize')) || 10; // 每页10个文件,支持分页 const fileName = searchParams.get('fileName') || ''; const getAllIds = searchParams.get('getAllIds') === 'true'; // 新增:获取所有文件ID的标志 // 如果请求所有文件ID,直接返回ID列表 if (getAllIds) { const allFiles = await getUploadFilesPagination(projectId, 1, 9999, fileName); // 获取所有文件 const allFileIds = allFiles.data?.map(file => String(file.id)) || []; return NextResponse.json({ allFileIds }); } // 获取文件列表 const files = await getUploadFilesPagination(projectId, page, pageSize, fileName); return NextResponse.json(files); } catch (error) { console.error('Error obtaining file list:', String(error)); return NextResponse.json({ error: error.message || 'Error obtaining file list' }, { status: 500 }); } } // 删除文件 export async function DELETE(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const fileId = searchParams.get('fileId'); const domainTreeAction = searchParams.get('domainTreeAction') || 'keep'; // 从请求体中获取模型信息和语言环境 const requestData = await request.json(); const model = requestData.model; const language = requestData.language || 'en'; // 验证项目ID和文件名 if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } if (!fileId) { return NextResponse.json({ error: 'The file name cannot be empty' }, { status: 400 }); } // 获取项目信息 const project = await getProject(projectId); if (!project) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 删除文件及其相关的文本块、问题和数据集 const { stats, fileName, fileInfo } = await delUploadFileInfoById(fileId); const deleteToc = await getProjectTocByName(projectId, fileName); try { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const tocDir = path.join(projectPath, 'toc'); const baseName = path.basename(fileInfo.fileName, path.extname(fileInfo.fileName)); const tocPath = path.join(tocDir, `${baseName}-toc.json`); // 检查文件是否存在再删除 await fs.unlink(tocPath); console.log(`成功删除 TOC 文件: ${tocPath}`); } catch (error) { console.error(`删除 TOC 文件失败:`, String(error)); // 即使 TOC 文件删除失败,不影响整体结果 } // 如果选择了保持领域树不变,直接返回删除结果 if (domainTreeAction === 'keep') { return NextResponse.json({ message: '文件删除成功', stats: stats, domainTreeAction: 'keep', cascadeDelete: true }); } // 处理领域树更新 try { // 获取项目的所有文件 const { chunks, toc } = await getProjectChunks(projectId); // 如果不存在文本块,说明项目已经没有文件了 if (!chunks || chunks.length === 0) { // 清空领域树 await batchSaveTags(projectId, []); return NextResponse.json({ message: '文件删除成功,领域树已清空', stats: stats, domainTreeAction, cascadeDelete: true }); } // 调用领域树处理模块 await handleDomainTree({ projectId, action: domainTreeAction, allToc: toc, model, language, deleteToc, project }); } catch (error) { console.error('Error updating domain tree after file deletion:', String(error)); // 即使领域树更新失败,也不影响文件删除的结果 } return NextResponse.json({ message: '文件删除成功', stats: stats, domainTreeAction, cascadeDelete: true }); } catch (error) { console.error('Error deleting file:', String(error)); return NextResponse.json({ error: error.message || 'Error deleting file' }, { status: 500 }); } } // 上传文件 export async function POST(request, { params }) { console.log('File upload request processing, parameters:', params); const { projectId } = params; // 验证项目ID if (!projectId) { console.log('The project ID cannot be empty, returning 400 error'); return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取项目信息 const project = await getProject(projectId); if (!project) { console.log('The project does not exist, returning 404 error'); return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } console.log('Project information retrieved successfully:', project.name || project.id); try { console.log('Try using alternate methods for file upload...'); // 检查请求头中是否包含文件名 const encodedFileName = request.headers.get('x-file-name'); const fileName = encodedFileName ? decodeURIComponent(encodedFileName) : null; console.log('Get file name from request header:', fileName); if (!fileName) { console.log('The request header does not contain a file name'); return NextResponse.json( { error: 'The request header does not contain a file name (x-file-name)' }, { status: 400 } ); } // 检查文件类型 if (!fileName.endsWith('.md') && !fileName.endsWith('.pdf')) { return NextResponse.json({ error: 'Only Markdown files are supported' }, { status: 400 }); } // 直接从请求体中读取二进制数据 const fileBuffer = Buffer.from(await request.arrayBuffer()); // 保存文件 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filesDir = path.join(projectPath, 'files'); await ensureDir(filesDir); const filePath = path.join(filesDir, fileName); await fs.writeFile(filePath, fileBuffer); //获取文件大小 const stats = await fs.stat(filePath); //获取文件md5 const md5 = await getFileMD5(filePath); //获取文件扩展名 const ext = path.extname(filePath); // let res = await checkUploadFileInfoByMD5(projectId, md5); // if (res) { // return NextResponse.json({ error: `【${fileName}】该文件已在此项目中存在` }, { status: 400 }); // } let fileInfo = await createUploadFileInfo({ projectId, fileName, size: stats.size, md5, fileExt: ext, path: filesDir }); console.log('The file upload process is complete, and a successful response is returned'); return NextResponse.json({ message: 'File uploaded successfully', fileName, filePath, fileId: fileInfo.id }); } catch (error) { console.error('Error processing file upload:', String(error)); console.error('Error stack:', error.stack); return NextResponse.json( { error: 'File upload failed: ' + (error.message || 'Unknown error') }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/generate-questions/route.js ================================================ import { NextResponse } from 'next/server'; import { getProjectChunks } from '@/lib/file/text-splitter'; import { getTaskConfig } from '@/lib/db/projects'; import { getChunkById } from '@/lib/db/chunks'; import { generateQuestionsForChunk, generateQuestionsForChunkWithGA } from '@/lib/services/questions'; // 批量生成问题 export async function POST(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取请求体 const { model, chunkIds, language = '中文', enableGaExpansion = false } = await request.json(); if (!model) { return NextResponse.json({ error: 'The model cannot be empty' }, { status: 400 }); } // 如果没有指定文本块ID,则获取所有文本块 let chunks = []; if (!chunkIds || chunkIds.length === 0) { const result = await getProjectChunks(projectId); chunks = result.chunks || []; } else { // 获取指定的文本块 chunks = await Promise.all( chunkIds.map(async chunkId => { const chunk = await getChunkById(chunkId); if (chunk) { return { id: chunk.id, content: chunk.content, length: chunk.content.length }; } return null; }) ); chunks = chunks.filter(Boolean); // 过滤掉不存在的文本块 } if (chunks.length === 0) { return NextResponse.json({ error: 'No valid text blocks found' }, { status: 404 }); } const results = []; const errors = []; // 获取项目 task-config 信息 const taskConfig = await getTaskConfig(projectId); const { questionGenerationLength } = taskConfig; for (const chunk of chunks) { try { // 根据文本长度自动计算问题数量 const questionNumber = Math.floor(chunk.length / questionGenerationLength); let result; if (enableGaExpansion) { // 使用GA增强的问题生成 result = await generateQuestionsForChunkWithGA(projectId, chunk.id, { model, language, number: questionNumber }); } else { // 使用标准问题生成 result = await generateQuestionsForChunk(projectId, chunk.id, { model, language, number: questionNumber }); } // 统一处理返回结果格式 if (result && result.questions && Array.isArray(result.questions)) { // GA增强模式的结果格式 results.push({ chunkId: chunk.id, success: true, questions: result.questions, total: result.total, gaExpansionUsed: result.gaExpansionUsed, gaPairsCount: result.gaPairsCount }); } else if (result && result.labelQuestions && Array.isArray(result.labelQuestions)) { // 标准模式的结果格式 results.push({ chunkId: chunk.id, success: true, questions: result.labelQuestions, total: result.total, gaExpansionUsed: false, gaPairsCount: 0 }); } else { errors.push({ chunkId: chunk.id, error: 'Failed to parse questions' }); } } catch (error) { console.error(`Failed to generate questions for text block ${chunk.id}:`, String(error)); errors.push({ chunkId: chunk.id, error: error.message || 'Failed to generate questions' }); } } // 返回生成结果 return NextResponse.json({ results, errors, totalSuccess: results.length, totalErrors: errors.length, totalChunks: chunks.length }); } catch (error) { console.error('Failed to generate questions:', String(error)); return NextResponse.json({ error: error.message || 'Failed to generate questions' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/huggingface/upload/route.js ================================================ import { NextResponse } from 'next/server'; import { getProject } from '@/lib/db/projects'; import { getDatasets } from '@/lib/db/datasets'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { uploadFiles, createRepo, checkRepoAccess } from '@huggingface/hub'; // 上传数据集到 HuggingFace export async function POST(request, { params }) { try { const projectId = params.projectId; const { token, datasetName, isPrivate, formatType, systemPrompt, confirmedOnly, includeCOT, fileFormat, customFields, reasoningLanguage } = await request.json(); // 获取项目信息 const project = await getProject(projectId); if (!project) { return NextResponse.json({ error: '项目不存在' }, { status: 404 }); } // 获取数据集问题 const questions = await getDatasets(projectId, confirmedOnly); if (!questions || questions.length === 0) { return NextResponse.json({ error: '没有可用的数据集问题' }, { status: 400 }); } // 格式化数据集 const formattedData = formatDataset(questions, formatType, systemPrompt, includeCOT, customFields); // 创建临时目录 const tempDir = path.join(os.tmpdir(), `hf-upload-${projectId}-${Date.now()}`); fs.mkdirSync(tempDir, { recursive: true }); // 创建数据集文件 const datasetFilePath = path.join(tempDir, `dataset.${fileFormat}`); if (fileFormat === 'json') { fs.writeFileSync(datasetFilePath, JSON.stringify(formattedData, null, 2)); } else if (fileFormat === 'jsonl') { const jsonlContent = formattedData.map(item => JSON.stringify(item)).join('\n'); fs.writeFileSync(datasetFilePath, jsonlContent); } else if (fileFormat === 'csv') { const csvContent = convertToCSV(formattedData); fs.writeFileSync(datasetFilePath, csvContent); } // 创建 README.md 文件 const readmePath = path.join(tempDir, 'README.md'); const readmeContent = generateReadme(project.name, project.description, formatType); fs.writeFileSync(readmePath, readmeContent); // 使用 Hugging Face REST API 上传数据集 const visibility = isPrivate ? 'private' : 'public'; try { // 准备仓库配置 const repo = { type: 'dataset', name: datasetName }; // 检查仓库是否存在 let repoExists = true; try { await checkRepoAccess({ repo, accessToken: token }); console.log(`Repository ${datasetName} exists, continuing to upload files`); } catch (error) { // If error code is 404, the repository does not exist if (error.statusCode === 404) { repoExists = false; console.log(`Repository ${datasetName} does not exist, preparing to create`); } else { // Other errors (e.g., permission errors) throw new Error(`Failed to check repository access: ${error.message}`); } } // If the repository does not exist, create a new one if (!repoExists) { try { await createRepo({ repo, accessToken: token, private: isPrivate, license: 'mit', description: project.description || 'Dataset created with Easy Dataset' }); console.log(`Successfully created dataset repository: ${datasetName}`); } catch (error) { throw new Error(`Failed to create dataset repository: ${error.message}`); } } // 2. 上传数据集文件 await uploadFile(token, datasetName, datasetFilePath, `dataset.${fileFormat}`); // 3. 上传 README.md await uploadFile(token, datasetName, readmePath, 'README.md'); } catch (error) { console.error('Upload to HuggingFace Failed:', String(error)); return NextResponse.json({ error: `Upload Error: ${error.message}` }, { status: 500 }); } // 清理临时目录 fs.rmSync(tempDir, { recursive: true, force: true }); // 返回成功信息 const datasetUrl = `https://huggingface.co/datasets/${datasetName}`; return NextResponse.json({ success: true, message: 'Upload successfully HuggingFace', url: datasetUrl }); } catch (error) { console.error('Upload Faile:', String(error)); return NextResponse.json({ error: error.message }, { status: 500 }); } } // 格式化数据集 function formatDataset(questions, formatType, systemPrompt, includeCOT, customFields) { if (formatType === 'alpaca') { return questions.map(q => { const item = { instruction: q.question, input: '', output: includeCOT && q.cot ? `${q.cot}\n\n${q.answer}` : q.answer }; if (systemPrompt) { item.system = systemPrompt; } return item; }); } else if (formatType === 'sharegpt') { return questions.map(q => { const messages = []; if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } messages.push({ role: 'user', content: q.question }); messages.push({ role: 'assistant', content: includeCOT && q.cot ? `${q.cot}\n\n${q.answer}` : q.answer }); return { messages }; }); } else if (formatType === 'multilingualthinking') { return questions.map(q => { const messages = []; // Main message block const mainMsg = { reasoning_language: reasoningLanguage ? reasoningLanguage : 'English', user: q.question, analysis: includeCOT && q.cot ? `${q.cot}` : null, final: q.answer }; if (systemPrompt) { mainMsg.developer = systemPrompt; } messages.push(mainMsg); // Optional system prompt if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt, thinking: null }); } // User message messages.push({ role: 'user', content: q.question, thinking: null }); // Assistant message messages.push({ role: 'assistant', content: q.answer, thinking: includeCOT && q.cot ? `${q.cot}` : null }); return { messages }; }); } else if (formatType === 'custom' && customFields) { return questions.map(q => { const item = { [customFields.questionField]: q.question, [customFields.answerField]: q.answer }; if (includeCOT && q.cot) { item[customFields.cotField] = q.cot; } if (customFields.includeLabels && q.labels) { item.labels = q.labels; } if (customFields.includeChunk && q.chunkId) { item.chunkId = q.chunkId; } return item; }); } // 默认返回 alpaca 格式 return questions.map(q => ({ instruction: q.question, output: includeCOT && q.cot ? `${q.cot}\n\n${q.answer}` : q.answer })); } // 将数据转换为 CSV 格式 function convertToCSV(data) { if (!data || data.length === 0) return ''; const headers = Object.keys(data[0]); const headerRow = headers.join(','); const rows = data.map(item => { return headers .map(header => { const value = item[header]; if (typeof value === 'string') { // 处理字符串中的逗号和引号 return `"${value.replace(/"/g, '""')}"`; } else if (Array.isArray(value)) { return `"${JSON.stringify(value).replace(/"/g, '""')}"`; } else if (typeof value === 'object' && value !== null) { return `"${JSON.stringify(value).replace(/"/g, '""')}"`; } return value; }) .join(','); }); return [headerRow, ...rows].join('\n'); } // 使用 @huggingface/hub 包上传文件到 HuggingFace async function uploadFile(token, datasetName, filePath, destFileName) { try { // 准备仓库配置 const repo = { type: 'dataset', name: datasetName }; // 创建文件 URL const fileUrl = new URL(`file://${filePath}`); // 使用 @huggingface/hub 包上传文件 await uploadFiles({ repo, accessToken: token, files: [ { path: destFileName, content: fileUrl } ], commitTitle: `Upload ${destFileName}`, commitDescription: `Files uploaded using Easy Dataset` }); return { success: true }; } catch (error) { console.error(`File ${destFileName} Upload Error:`, String(error)); throw error; } } // Generate README.md file function generateReadme(projectName, projectDescription, formatType) { return `# ${projectName} ## Description ${projectDescription || 'This dataset was created using the Easy Dataset tool.'} ## Format This dataset is in ${formatType} format. ## Creation Method This dataset was created using the [Easy Dataset](https://github.com/ConardLi/easy-dataset) tool. > Easy Dataset is a specialized application designed to streamline the creation of fine-tuning datasets for Large Language Models (LLMs). It offers an intuitive interface for uploading domain-specific files, intelligently splitting content, generating questions, and producing high-quality training data for model fine-tuning. `; } ================================================ FILE: app/api/projects/[projectId]/image-datasets/[datasetId]/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageDatasetById, updateImageDataset, deleteImageDataset } from '@/lib/db/imageDatasets'; import { getProjectPath } from '@/lib/db/base'; import fs from 'fs/promises'; import path from 'path'; // 获取单个数据集详情 export async function GET(request, { params }) { try { const { projectId, datasetId } = params; const dataset = await getImageDatasetById(datasetId); if (!dataset || dataset.projectId !== projectId) { return NextResponse.json({ error: 'Dataset not found' }, { status: 404 }); } // 获取项目路径 const projectPath = await getProjectPath(projectId); // 读取图片 base64 let base64 = null; try { const imagePath = path.join(projectPath, 'images', dataset.imageName); const imageBuffer = await fs.readFile(imagePath); const base64Data = imageBuffer.toString('base64'); const ext = path.extname(dataset.imageName).toLowerCase(); const mimeType = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : 'image/jpeg'; base64 = `data:${mimeType};base64,${base64Data}`; } catch (error) { console.error(`Failed to read image ${dataset.imageName}:`, error); } // 添加图片 base64 const datasetWithImage = { ...dataset, base64 }; return NextResponse.json(datasetWithImage); } catch (error) { console.error('Failed to get dataset detail:', error); return NextResponse.json({ error: error.message || 'Failed to get dataset detail' }, { status: 500 }); } } // 更新数据集 export async function PUT(request, { params }) { try { const { projectId, datasetId } = params; const updates = await request.json(); // 验证数据集存在且属于该项目 const dataset = await getImageDatasetById(datasetId); if (!dataset || dataset.projectId !== projectId) { return NextResponse.json({ error: 'Dataset not found' }, { status: 404 }); } // 更新数据集 const updated = await updateImageDataset(datasetId, updates); // 获取项目路径 const projectPath = await getProjectPath(projectId); // 读取图片 base64 let base64 = null; try { const imagePath = path.join(projectPath, 'images', updated.imageName); const imageBuffer = await fs.readFile(imagePath); const base64Data = imageBuffer.toString('base64'); const ext = path.extname(updated.imageName).toLowerCase(); const mimeType = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : 'image/jpeg'; base64 = `data:${mimeType};base64,${base64Data}`; } catch (error) { console.error(`Failed to read image ${updated.imageName}:`, error); } // 添加图片 base64 const updatedWithImage = { ...updated, base64 }; return NextResponse.json(updatedWithImage); } catch (error) { console.error('Failed to update dataset:', error); return NextResponse.json({ error: error.message || 'Failed to update dataset' }, { status: 500 }); } } // 删除数据集 export async function DELETE(request, { params }) { try { const { projectId, datasetId } = params; // 验证数据集存在且属于该项目 const dataset = await getImageDatasetById(datasetId); if (!dataset || dataset.projectId !== projectId) { return NextResponse.json({ error: 'Dataset not found' }, { status: 404 }); } await deleteImageDataset(datasetId); return NextResponse.json({ success: true }); } catch (error) { console.error('Failed to delete dataset:', error); return NextResponse.json({ error: error.message || 'Failed to delete dataset' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/image-datasets/export/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageDatasetsForExport } from '@/lib/db/imageDatasets'; /** * 导出图像数据集 */ export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } const confirmedOnly = body.confirmedOnly || false; // 获取数据集 const datasets = await getImageDatasetsForExport(projectId, confirmedOnly); return NextResponse.json(datasets); } catch (error) { console.error('Failed to export image datasets:', String(error)); return NextResponse.json( { error: error.message || 'Failed to export image datasets' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/image-datasets/export-zip/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageDatasetsForExport } from '@/lib/db/imageDatasets'; import archiver from 'archiver'; import { getProjectPath } from '@/lib/db/base'; import path from 'path'; import fs from 'fs'; /** * 导出图片文件压缩包 */ export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const confirmedOnly = searchParams.get('confirmedOnly') === 'true'; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } // 获取数据集(用于确定需要哪些图片) const datasets = await getImageDatasetsForExport(projectId, confirmedOnly); if (!datasets || datasets.length === 0) { return NextResponse.json({ error: 'No data to export' }, { status: 404 }); } // 获取所有需要的图片名称 const imageNames = new Set(datasets.map(d => d.imageName).filter(Boolean)); if (imageNames.size === 0) { return NextResponse.json({ error: 'No images to export' }, { status: 404 }); } // 创建压缩包 const archive = archiver('zip', { zlib: { level: 9 } }); // 设置响应头 const dateStr = new Date().toISOString().slice(0, 10); const filename = `images-${projectId}-${dateStr}.zip`; // 添加图片文件到压缩包 const projectPath = await getProjectPath(projectId); const imageDir = path.join(projectPath, 'images'); if (!fs.existsSync(imageDir)) { return NextResponse.json({ error: 'Image directory not found' }, { status: 404 }); } let addedCount = 0; for (const imageName of imageNames) { const imagePath = path.join(imageDir, imageName); if (fs.existsSync(imagePath)) { archive.file(imagePath, { name: imageName }); addedCount++; } } if (addedCount === 0) { return NextResponse.json({ error: 'No image files found' }, { status: 404 }); } // 完成压缩 archive.finalize(); // 返回流式响应 return new NextResponse(archive, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${filename}"` } }); } catch (error) { console.error('Failed to export images:', String(error)); return NextResponse.json( { error: error.message || 'Failed to export images' }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/image-datasets/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageDatasetsByProject } from '@/lib/db/imageDatasets'; import { getProjectPath } from '@/lib/db/base'; import fs from 'fs/promises'; import path from 'path'; // 获取图片数据集列表 export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page')) || 1; const pageSize = parseInt(searchParams.get('pageSize')) || 20; const search = searchParams.get('search') || ''; const confirmed = searchParams.get('confirmed'); const minScore = searchParams.get('minScore'); const maxScore = searchParams.get('maxScore'); // 构建筛选条件 const filters = {}; if (search) { filters.search = search; } if (confirmed !== null && confirmed !== undefined) { filters.confirmed = confirmed === 'true'; } if (minScore) { filters.minScore = parseInt(minScore); } if (maxScore) { filters.maxScore = parseInt(maxScore); } const result = await getImageDatasetsByProject(projectId, page, pageSize, filters); // 获取项目路径 const projectPath = await getProjectPath(projectId); // 为每个数据集添加图片 base64 const datasetsWithImages = await Promise.all( result.data.map(async dataset => { try { const imagePath = path.join(projectPath, 'images', dataset.imageName); const imageBuffer = await fs.readFile(imagePath); const base64 = imageBuffer.toString('base64'); const ext = path.extname(dataset.imageName).toLowerCase(); const mimeType = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : 'image/jpeg'; return { ...dataset, base64: `data:${mimeType};base64,${base64}` }; } catch (error) { console.error(`Failed to read image ${dataset.imageName}:`, error); return { ...dataset, base64: null }; } }) ); return NextResponse.json({ data: datasetsWithImages, total: result.total }); } catch (error) { console.error('Failed to get image datasets:', error); return NextResponse.json({ error: error.message || 'Failed to get image datasets' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/image-datasets/tags/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageDatasetsTagsByProject } from '@/lib/db/imageDatasets'; // 获取项目中所有已使用的标签 export async function GET(request, { params }) { try { const { projectId } = params; // 获取项目的所有数据集 const datasets = await getImageDatasetsTagsByProject(projectId); console.log('datasets', datasets); // 提取所有标签 const tagsSet = new Set(); datasets.forEach(dataset => { if (dataset.tags) { try { const tags = JSON.parse(dataset.tags); if (Array.isArray(tags)) { tags.forEach(tag => tagsSet.add(tag)); } } catch (e) { // 忽略解析错误 } } }); // 转换为数组并排序 const tags = Array.from(tagsSet).sort(); return NextResponse.json({ tags }); } catch (error) { console.error('Failed to get tags:', error); return NextResponse.json({ error: error.message || 'Failed to get tags' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/[imageId]/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageDetailWithQuestions } from '@/lib/services/images'; // 根据图片ID获取图片详情,包含问题列表和已标注数据 export async function GET(request, { params }) { try { const { projectId, imageId } = params; // 调用服务层获取图片详情 const imageData = await getImageDetailWithQuestions(projectId, imageId); return NextResponse.json({ success: true, data: imageData }); } catch (error) { console.error('Failed to get image details:', error); // 根据错误类型返回不同的状态码 let statusCode = 500; if (error.message === '缺少图片ID') { statusCode = 400; } else if (error.message === '图片不存在') { statusCode = 404; } else if (error.message === '图片不属于指定项目') { statusCode = 403; } return NextResponse.json({ error: error.message || 'Failed to get image details' }, { status: statusCode }); } } ================================================ FILE: app/api/projects/[projectId]/images/annotations/route.js ================================================ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; import { getImageById, getImageChunk } from '@/lib/db/images'; import { createImageDataset } from '@/lib/db/imageDatasets'; const prisma = new PrismaClient(); // 创建标注 export async function POST(request, { params }) { try { const { projectId } = params; const { imageId, questionId, question, answerType, answer, note } = await request.json(); // 验证必填字段 if (!imageId || !question || !answerType || answer === undefined || answer === null) { return NextResponse.json({ error: '缺少必要参数:imageId, question, answerType, answer' }, { status: 400 }); } // 验证图片存在 const image = await getImageById(imageId); if (!image || image.projectId !== projectId) { return NextResponse.json({ error: '图片不存在' }, { status: 404 }); } // 验证答案类型 if (!['text', 'label', 'custom_format'].includes(answerType)) { return NextResponse.json({ error: '无效的答案类型' }, { status: 400 }); } // 验证答案内容 if (answerType === 'text' && typeof answer !== 'string') { return NextResponse.json({ error: '文本类型答案必须是字符串' }, { status: 400 }); } if (answerType === 'label' && !Array.isArray(answer)) { return NextResponse.json({ error: '标签类型答案必须是数组' }, { status: 400 }); } // 序列化答案 let answerString = answer; if (answerType !== 'text' && typeof answerString !== 'string') { answerString = JSON.stringify(answer, null, 2); } // 1. 获取问题记录(前端传递的 questionId 指向已有的问题) if (!questionId) { return NextResponse.json({ error: '缺少必要参数:questionId' }, { status: 400 }); } const questionRecord = await prisma.questions.findUnique({ where: { id: questionId } }); if (!questionRecord) { return NextResponse.json({ error: '问题不存在' }, { status: 404 }); } // 验证问题属于该图片 if (questionRecord.imageId !== imageId) { return NextResponse.json({ error: '问题不属于该图片' }, { status: 400 }); } // 2. 更新问题为已回答 await prisma.questions.update({ where: { id: questionRecord.id }, data: { answered: true } }); // 3. 创建 ImageDataset 记录 const dataset = await createImageDataset(projectId, { imageId: image.id, imageName: image.imageName, questionId: questionRecord.id, question, answer: answerString, answerType, model: 'manual', note: note || '' }); return NextResponse.json({ success: true, dataset, questionId: questionRecord.id }); } catch (error) { console.error('Failed to create annotation:', error); return NextResponse.json({ error: error.message || 'Failed to create annotation' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/datasets/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageByName } from '@/lib/db/images'; import imageService from '@/lib/services/images'; // 生成图像数据集 export async function POST(request, { params }) { try { const { projectId } = params; const { imageName, question, model, language = 'zh', previewOnly = false } = await request.json(); if (!imageName || !question) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } if (!model) { return NextResponse.json({ error: '请选择一个视觉模型' }, { status: 400 }); } // 获取图片信息 const image = await getImageByName(projectId, imageName); if (!image) { return NextResponse.json({ error: '图片不存在' }, { status: 404 }); } // 调用图片数据集生成服务 const result = await imageService.generateDatasetForImage(projectId, image.id, question, { model, language, previewOnly }); return NextResponse.json({ success: true, answer: result.answer, dataset: result.dataset }); } catch (error) { console.error('Failed to generate image dataset:', error); return NextResponse.json({ error: error.message || 'Failed to generate dataset' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/next-unanswered/route.js ================================================ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; import { getImageDetailWithQuestions } from '@/lib/services/images'; const prisma = new PrismaClient(); // 获取下一个有未标注问题的图片 export async function GET(request, { params }) { try { const { projectId } = params; // 查找第一个有未标注问题的图片 const unansweredQuestion = await prisma.questions.findFirst({ where: { projectId, imageId: { not: null }, answered: false } }); if (!unansweredQuestion) { return NextResponse.json({ success: true, data: null }); } // 调用服务层获取图片详情 const imageData = await getImageDetailWithQuestions(projectId, unansweredQuestion.imageId); return NextResponse.json({ success: true, data: imageData }); } catch (error) { console.error('Failed to get next unanswered image:', error); return NextResponse.json({ error: error.message || 'Failed to get next unanswered image' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/pdf-convert/route.js ================================================ import { NextResponse } from 'next/server'; import { getProjectPath } from '@/lib/db/base'; import { importImagesFromDirectories } from '@/lib/services/images'; import fs from 'fs/promises'; import path from 'path'; import { savePdfAsImages } from '@/lib/util/file'; // PDF 转图片并导入 export async function POST(request, { params }) { let tempPdfPath = null; let tempImagesDir = null; try { const { projectId } = params; const formData = await request.formData(); const pdfFile = formData.get('file'); if (!pdfFile) { return NextResponse.json({ error: '请选择 PDF 文件' }, { status: 400 }); } if (!pdfFile.name.toLowerCase().endsWith('.pdf')) { return NextResponse.json({ error: '只支持 PDF 文件' }, { status: 400 }); } const projectPath = await getProjectPath(projectId); const tempDir = path.join(projectPath, 'temp'); await fs.mkdir(tempDir, { recursive: true }); // 1. 保存 PDF 到临时目录 tempPdfPath = path.join(tempDir, `temp_${Date.now()}_${pdfFile.name}`); const pdfBuffer = Buffer.from(await pdfFile.arrayBuffer()); await fs.writeFile(tempPdfPath, pdfBuffer); // 2. 创建临时图片目录 tempImagesDir = path.join(tempDir, `pdf_images_${Date.now()}`); await fs.mkdir(tempImagesDir, { recursive: true }); // 3. 调用 pdf2md-js 转换 PDF 为图片 console.log('开始转换 PDF 为图片...'); const imagePaths = await savePdfAsImages(tempPdfPath, tempImagesDir, 3); console.log('PDF 转换完成,生成图片数量:', imagePaths.length); if (!imagePaths || imagePaths.length === 0) { throw new Error('PDF 转换失败,未生成图片'); } // 4. 直接调用服务层导入图片 const importResult = await importImagesFromDirectories(projectId, [tempImagesDir]); // 5. 清理临时文件 try { if (tempPdfPath) { await fs.unlink(tempPdfPath); } if (tempImagesDir) { const tempImages = await fs.readdir(tempImagesDir); for (const img of tempImages) { await fs.unlink(path.join(tempImagesDir, img)); } await fs.rmdir(tempImagesDir); } const tempDirContents = await fs.readdir(tempDir); if (tempDirContents.length === 0) { await fs.rmdir(tempDir); } } catch (cleanupErr) { console.warn('清理临时文件失败:', cleanupErr); } return NextResponse.json({ success: true, count: importResult.count, images: importResult.images, pdfName: pdfFile.name }); } catch (error) { console.error('Failed to convert PDF:', error); // 清理临时文件 try { if (tempPdfPath) { await fs.unlink(tempPdfPath).catch(() => {}); } if (tempImagesDir) { const tempImages = await fs.readdir(tempImagesDir).catch(() => []); for (const img of tempImages) { await fs.unlink(path.join(tempImagesDir, img)).catch(() => {}); } await fs.rmdir(tempImagesDir).catch(() => {}); } } catch (cleanupErr) { console.warn('清理临时文件失败:', cleanupErr); } return NextResponse.json({ error: error.message || 'Failed to convert PDF' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/questions/route.js ================================================ import { NextResponse } from 'next/server'; import { getImageByName } from '@/lib/db/images'; import imageService from '@/lib/services/images'; // 生成图片问题 export async function POST(request, { params }) { try { const { projectId } = params; const { imageName, count = 3, model, language = 'zh' } = await request.json(); if (!imageName) { return NextResponse.json({ error: '缺少图片名称' }, { status: 400 }); } if (!model) { return NextResponse.json({ error: '请选择一个视觉模型' }, { status: 400 }); } // 获取图片信息 const image = await getImageByName(projectId, imageName); if (!image) { return NextResponse.json({ error: '图片不存在' }, { status: 404 }); } // 调用图片问题生成服务 const result = await imageService.generateQuestionsForImage(projectId, image.id, { model, language, count }); return NextResponse.json({ success: true, questions: result.questions }); } catch (error) { console.error('Failed to generate image questions:', error); return NextResponse.json({ error: error.message || 'Failed to generate questions' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/route.js ================================================ import { NextResponse } from 'next/server'; import { getImages, deleteImage, getImageDetail } from '@/lib/db/images'; import { getProjectPath } from '@/lib/db/base'; import { db } from '@/lib/db/index'; import { importImagesFromDirectories } from '@/lib/services/images'; import fs from 'fs/promises'; import path from 'path'; // 获取图片列表 export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page')) || 1; const pageSize = parseInt(searchParams.get('pageSize')) || 20; const imageName = searchParams.get('imageName') || ''; const hasQuestions = searchParams.get('hasQuestions'); const hasDatasets = searchParams.get('hasDatasets'); const simple = searchParams.get('simple'); const result = await getImages(projectId, page, pageSize, imageName, hasQuestions, hasDatasets, simple); return NextResponse.json(result); } catch (error) { console.error('Failed to get images:', error); return NextResponse.json({ error: error.message || 'Failed to get images' }, { status: 500 }); } } // 导入图片 export async function POST(request, { params }) { try { const { projectId } = params; const { directories } = await request.json(); // 调用服务层处理图片导入 const result = await importImagesFromDirectories(projectId, directories); return NextResponse.json(result); } catch (error) { console.error('Failed to import images:', error); return NextResponse.json({ error: error.message || 'Failed to import images' }, { status: 500 }); } } // 删除图片 export async function DELETE(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const imageId = searchParams.get('imageId'); if (!imageId) { return NextResponse.json({ error: '缺少图片ID' }, { status: 400 }); } // 获取图片信息 const image = await getImageDetail(imageId); if (!image) { return NextResponse.json({ error: '图片不存在' }, { status: 404 }); } // 删除关联的数据集 await db.imageDatasets.deleteMany({ where: { imageId } }); // 删除关联的问题 await db.questions.deleteMany({ where: { imageId } }); // 删除文件 const projectPath = await getProjectPath(projectId); const filePath = path.join(projectPath, 'images', image.imageName); try { await fs.unlink(filePath); } catch (err) { console.warn('删除文件失败:', err); } // 删除数据库记录 await deleteImage(imageId); return NextResponse.json({ success: true }); } catch (error) { console.error('Failed to delete image:', error); return NextResponse.json({ error: error.message || 'Failed to delete image' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/images/zip-import/route.js ================================================ import { NextResponse } from 'next/server'; import { getProjectPath } from '@/lib/db/base'; import { importImagesFromDirectories } from '@/lib/services/images'; import fs from 'fs/promises'; import path from 'path'; import AdmZip from 'adm-zip'; // 压缩包解压并导入图片 export async function POST(request, { params }) { let tempZipPath = null; let tempExtractDir = null; try { const { projectId } = params; const formData = await request.formData(); const zipFile = formData.get('file'); if (!zipFile) { return NextResponse.json({ error: '请选择压缩包文件' }, { status: 400 }); } if (!zipFile.name.toLowerCase().endsWith('.zip')) { return NextResponse.json({ error: '只支持 ZIP 格式的压缩包' }, { status: 400 }); } const projectPath = await getProjectPath(projectId); const tempDir = path.join(projectPath, 'temp'); await fs.mkdir(tempDir, { recursive: true }); // 1. 保存压缩包到临时目录 tempZipPath = path.join(tempDir, `temp_${Date.now()}_${zipFile.name}`); const zipBuffer = Buffer.from(await zipFile.arrayBuffer()); await fs.writeFile(tempZipPath, zipBuffer); // 2. 创建临时解压目录 tempExtractDir = path.join(tempDir, `zip_extract_${Date.now()}`); await fs.mkdir(tempExtractDir, { recursive: true }); // 3. 使用 adm-zip 解压文件 console.log('开始解压压缩包...'); const zip = new AdmZip(tempZipPath); const zipEntries = zip.getEntries(); // 支持的图片扩展名 const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']; let extractedCount = 0; // 遍历压缩包中的所有文件 for (const entry of zipEntries) { // 跳过目录和隐藏文件 if ( entry.isDirectory || entry.entryName.startsWith('__MACOSX') || path.basename(entry.entryName).startsWith('.') ) { continue; } const ext = path.extname(entry.entryName).toLowerCase(); if (imageExtensions.includes(ext)) { // 提取文件名(不包含路径) const fileName = path.basename(entry.entryName); const targetPath = path.join(tempExtractDir, fileName); // 解压文件 zip.extractEntryTo(entry, tempExtractDir, false, true, false, fileName); extractedCount++; } } console.log(`压缩包解压完成,提取图片数量: ${extractedCount}`); if (extractedCount === 0) { throw new Error('压缩包中没有找到支持的图片文件'); } // 4. 调用服务层导入图片 const importResult = await importImagesFromDirectories(projectId, [tempExtractDir]); // 5. 清理临时文件 try { if (tempZipPath) { await fs.unlink(tempZipPath); } if (tempExtractDir) { const tempImages = await fs.readdir(tempExtractDir); for (const img of tempImages) { await fs.unlink(path.join(tempExtractDir, img)); } await fs.rmdir(tempExtractDir); } const tempDirContents = await fs.readdir(tempDir); if (tempDirContents.length === 0) { await fs.rmdir(tempDir); } } catch (cleanupErr) { console.warn('清理临时文件失败:', cleanupErr); } return NextResponse.json({ success: true, count: importResult.count, images: importResult.images, zipName: zipFile.name }); } catch (error) { console.error('Failed to import ZIP:', error); // 清理临时文件 try { if (tempZipPath) { await fs.unlink(tempZipPath).catch(() => {}); } if (tempExtractDir) { const tempImages = await fs.readdir(tempExtractDir).catch(() => []); for (const img of tempImages) { await fs.unlink(path.join(tempExtractDir, img)).catch(() => {}); } await fs.rmdir(tempExtractDir).catch(() => {}); } } catch (cleanupErr) { console.warn('清理临时文件失败:', cleanupErr); } return NextResponse.json({ error: error.message || 'Failed to import ZIP' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/llamaFactory/checkConfig/route.js ================================================ import { NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs'; import { getProjectRoot } from '@/lib/db/base'; export async function GET(request, { params }) { try { const { projectId } = params; if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const configPath = path.join(projectPath, 'dataset_info.json'); const exists = fs.existsSync(configPath); return NextResponse.json({ exists, configPath: exists ? configPath : null }); } catch (error) { console.error('Error checking Llama Factory config:', String(error)); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/llamaFactory/generate/route.js ================================================ import { NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs'; import { getProjectRoot } from '@/lib/db/base'; import { getDatasets } from '@/lib/db/datasets'; export async function POST(request, { params }) { try { const { projectId } = params; const { formatType, systemPrompt, confirmedOnly, includeCOT, reasoningLanguage } = await request.json(); if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const configPath = path.join(projectPath, 'dataset_info.json'); const alpacaPath = path.join(projectPath, 'alpaca.json'); const sharegptPath = path.join(projectPath, 'sharegpt.json'); const multilingualThinkingPath = path.join(projectPath, 'multilingual-thinking.json'); // 获取数据集 let datasets = await getDatasets(projectId, !!confirmedOnly); // 创建 dataset_info.json 配置 const config = { [`[Easy Dataset] [${projectId}] Alpaca`]: { file_name: 'alpaca.json', columns: { prompt: 'instruction', query: 'input', response: 'output', system: 'system' } }, [`[Easy Dataset] [${projectId}] ShareGPT`]: { file_name: 'sharegpt.json', formatting: 'sharegpt', columns: { messages: 'messages' }, tags: { role_tag: 'role', content_tag: 'content', user_tag: 'user', assistant_tag: 'assistant', system_tag: 'system' } }, [`[Easy Dataset] [${projectId}] multilingual-thinking`]: { file_name: 'multilingual-thinking.json', formatting: 'multilingual-thinking', columns: { messages: 'messages' }, tags: { role_tag: 'role', content_tag: 'content', user_tag: 'user', assistant_tag: 'assistant', system_tag: 'system' } } }; // 生成数据文件 const alpacaData = datasets.map(({ question, answer, cot }) => ({ instruction: question, input: '', output: cot && includeCOT ? `${cot}\n${answer}` : answer, system: systemPrompt || '' })); const sharegptData = datasets.map(({ question, answer, cot }) => { const messages = []; if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } messages.push({ role: 'user', content: question }); messages.push({ role: 'assistant', content: cot && includeCOT ? `${cot}\n${answer}` : answer }); return { messages }; }); const multilingualThinkingData = datasets.map(({ question, answer, cot }) => ({ reasoning_language: reasoningLanguage ? reasoningLanguage : 'English', developer: systemPrompt ? systemPrompt : '', // system prompt (may be empty) user: question, analysis: includeCOT && cot ? cot : null, // null if no COT final: answer, messages: [ { content: systemPrompt ? systemPrompt : '', role: 'system', thinking: null }, { content: question, role: 'user', thinking: null }, { content: answer, role: 'assistant', thinking: includeCOT && cot ? cot : null } ] })); const multilingualThinkingLines = multilingualThinkingData.map(item => JSON.stringify(item, null, 2)).join('\n'); await fs.promises.writeFile(multilingualThinkingPath, multilingualThinkingLines, 'utf8'); // 写入文件 await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)); await fs.promises.writeFile(alpacaPath, JSON.stringify(alpacaData, null, 2)); await fs.promises.writeFile(sharegptPath, JSON.stringify(sharegptData, null, 2)); return NextResponse.json({ success: true, configPath, files: [ { path: alpacaPath, format: 'alpaca' }, { path: sharegptPath, format: 'sharegpt' }, { path: multilingualThinkingPath, format: 'multilingual-thinking' } ] }); } catch (error) { console.error('Error generating Llama Factory config:', String(error)); return NextResponse.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/model-config/[modelConfigId]/route.js ================================================ import { NextResponse } from 'next/server'; import { deleteModelConfigById } from '@/lib/db/model-config'; // 删除模型配置 export async function DELETE(request, { params }) { try { const { projectId, modelConfigId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } await deleteModelConfigById(modelConfigId); return NextResponse.json(true); } catch (error) { console.error('Error obtaining model configuration:', String(error)); return NextResponse.json({ error: 'Failed to obtain model configuration' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/model-config/route.js ================================================ import { NextResponse } from 'next/server'; import { createInitModelConfig, getModelConfigByProjectId, saveModelConfig } from '@/lib/db/model-config'; import { DEFAULT_MODEL_SETTINGS, MODEL_PROVIDERS } from '@/constant/model'; import { getProject } from '@/lib/db/projects'; import { sortProvidersByPriority } from '@/lib/util/providerLogo'; function normalizeModelEndpoint(endpoint = '') { let normalizedEndpoint = String(endpoint).trim(); if (!normalizedEndpoint) { return ''; } if (normalizedEndpoint.includes('/chat/completions')) { normalizedEndpoint = normalizedEndpoint.replace('/chat/completions', ''); } return normalizedEndpoint; } // 获取模型配置列表 export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } let modelConfigList = await getModelConfigByProjectId(projectId); if (!modelConfigList || modelConfigList.length === 0) { let insertModelConfigList = []; const sortedProviders = sortProvidersByPriority(MODEL_PROVIDERS, item => item.id); sortedProviders.forEach(item => { let data = { projectId: projectId, providerId: item.id, providerName: item.name, endpoint: item.defaultEndpoint, apiKey: '', modelId: '', modelName: '', type: 'text', temperature: DEFAULT_MODEL_SETTINGS.temperature, maxTokens: DEFAULT_MODEL_SETTINGS.maxTokens, topK: 0, topP: DEFAULT_MODEL_SETTINGS.topP, status: 1 }; insertModelConfigList.push(data); }); modelConfigList = await createInitModelConfig(insertModelConfigList); } modelConfigList = sortProvidersByPriority(modelConfigList, item => item.providerId); let project = await getProject(projectId); return NextResponse.json({ data: modelConfigList, defaultModelConfigId: project.defaultModelConfigId }); } catch (error) { console.error('Error obtaining model configuration:', String(error)); return NextResponse.json({ error: 'Failed to obtain model configuration' }, { status: 500 }); } } // 保存模型配置 export async function POST(request, { params }) { try { const { projectId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取请求体 const modelConfig = await request.json(); // 验证请求体 if (!modelConfig) { return NextResponse.json({ error: 'The model configuration cannot be empty ' }, { status: 400 }); } modelConfig.projectId = projectId; modelConfig.endpoint = normalizeModelEndpoint(modelConfig.endpoint); // 如果没有 modelId,使用 modelName 补齐(兼容旧逻辑) if (!modelConfig.modelId && modelConfig.modelName) { modelConfig.modelId = modelConfig.modelName; } // 如果没有 modelName,使用 modelId 补齐 if (!modelConfig.modelName && modelConfig.modelId) { modelConfig.modelName = modelConfig.modelId; } if (!modelConfig.topK) { modelConfig.topK = 0; } if (!modelConfig.status) { modelConfig.status = 1; } const parsedMaxTokens = Number(modelConfig.maxTokens ?? DEFAULT_MODEL_SETTINGS.maxTokens); if (!Number.isInteger(parsedMaxTokens) || parsedMaxTokens < 1) { return NextResponse.json({ error: 'maxTokens must be a positive integer' }, { status: 400 }); } modelConfig.maxTokens = parsedMaxTokens; const res = await saveModelConfig(modelConfig); return NextResponse.json(res); } catch (error) { console.error('Error updating model configuration:', String(error)); return NextResponse.json({ error: 'Failed to update model configuration' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/models/[modelId]/route.js ================================================ import { NextResponse } from 'next/server'; import { getProjectRoot } from '@/lib/db/base'; import path from 'path'; import fs from 'fs/promises'; export async function GET(request, { params }) { try { const { projectId, modelId } = params; // 验证项目ID和模型ID if (!projectId || !modelId) { return NextResponse.json({ error: 'The project ID and model ID cannot be empty' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 获取模型配置文件路径 const modelConfigPath = path.join(projectPath, 'model-config.json'); // 检查模型配置文件是否存在 try { await fs.access(modelConfigPath); } catch (error) { return NextResponse.json({ error: 'The model configuration does not exist' }, { status: 404 }); } // 读取模型配置文件 const modelConfigData = await fs.readFile(modelConfigPath, 'utf-8'); const modelConfig = JSON.parse(modelConfigData); // 查找指定ID的模型 const model = modelConfig.find(model => model.id === modelId); if (!model) { return NextResponse.json({ error: 'The model does not exist' }, { status: 404 }); } return NextResponse.json(model); } catch (error) { console.error('Error getting model:', String(error)); return NextResponse.json({ error: 'Failed to get model' }, { status: 500 }); } } export async function PUT(request, { params }) { try { const { projectId, modelId } = params; // 验证项目ID和模型ID if (!projectId || !modelId) { return NextResponse.json({ error: 'The project ID and model ID cannot be empty' }, { status: 400 }); } // 获取请求体 const modelData = await request.json(); // 验证请求体 if (!modelData || !modelData.provider || !modelData.name) { return NextResponse.json({ error: 'The model data is incomplete' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 获取模型配置文件路径 const modelConfigPath = path.join(projectPath, 'model-config.json'); // 读取模型配置文件 let modelConfig = []; try { const modelConfigData = await fs.readFile(modelConfigPath, 'utf-8'); modelConfig = JSON.parse(modelConfigData); } catch (error) { // 如果文件不存在,创建一个空数组 } // 更新模型数据 const modelIndex = modelConfig.findIndex(model => model.id === modelId); if (modelIndex >= 0) { // 更新现有模型 modelConfig[modelIndex] = { ...modelConfig[modelIndex], ...modelData, id: modelId // 确保ID不变 }; } else { // 添加新模型 modelConfig.push({ ...modelData, id: modelId }); } // 写入模型配置文件 await fs.writeFile(modelConfigPath, JSON.stringify(modelConfig, null, 2), 'utf-8'); return NextResponse.json({ message: 'Model configuration updated successfully' }); } catch (error) { console.error('Error updating model configuration:', String(error)); return NextResponse.json({ error: 'Failed to update model configuration' }, { status: 500 }); } } export async function DELETE(request, { params }) { try { const { projectId, modelId } = params; // 验证项目ID和模型ID if (!projectId || !modelId) { return NextResponse.json({ error: 'The project ID and model ID cannot be empty' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 获取模型配置文件路径 const modelConfigPath = path.join(projectPath, 'model-config.json'); // 检查模型配置文件是否存在 try { await fs.access(modelConfigPath); } catch (error) { return NextResponse.json({ error: 'The model configuration does not exist' }, { status: 404 }); } // 读取模型配置文件 const modelConfigData = await fs.readFile(modelConfigPath, 'utf-8'); let modelConfig = JSON.parse(modelConfigData); // 过滤掉要删除的模型 const initialLength = modelConfig.length; modelConfig = modelConfig.filter(model => model.id !== modelId); // 检查是否找到并删除了模型 if (modelConfig.length === initialLength) { return NextResponse.json({ error: 'The model does not exist' }, { status: 404 }); } // 写入模型配置文件 await fs.writeFile(modelConfigPath, JSON.stringify(modelConfig, null, 2), 'utf-8'); return NextResponse.json({ message: 'Model deleted successfully' }); } catch (error) { console.error('Error deleting model:', String(error)); return NextResponse.json({ error: 'Failed to delete model' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/models/route.js ================================================ import { NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs/promises'; import { getProjectRoot } from '@/lib/db/base'; // 获取模型配置 export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 获取模型配置文件路径 const modelConfigPath = path.join(projectPath, 'model-config.json'); // 检查模型配置文件是否存在 try { await fs.access(modelConfigPath); } catch (error) { // 如果配置文件不存在,返回默认配置 return NextResponse.json([]); } // 读取模型配置文件 const modelConfigData = await fs.readFile(modelConfigPath, 'utf-8'); const modelConfig = JSON.parse(modelConfigData); return NextResponse.json(modelConfig); } catch (error) { console.error('Error obtaining model configuration:', String(error)); return NextResponse.json({ error: 'Failed to obtain model configuration' }, { status: 500 }); } } // 更新模型配置 export async function PUT(request, { params }) { try { const { projectId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取请求体 const modelConfig = await request.json(); // 验证请求体 if (!modelConfig || !Array.isArray(modelConfig)) { return NextResponse.json({ error: 'The model configuration must be an array' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'The project does not exist' }, { status: 404 }); } // 获取模型配置文件路径 const modelConfigPath = path.join(projectPath, 'model-config.json'); // 写入模型配置文件 await fs.writeFile(modelConfigPath, JSON.stringify(modelConfig, null, 2), 'utf-8'); return NextResponse.json({ message: 'Model configuration updated successfully' }); } catch (error) { console.error('Error updating model configuration:', String(error)); return NextResponse.json({ error: 'Failed to update model configuration' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/playground/chat/route.js ================================================ import { NextResponse } from 'next/server'; import LLMClient from '@/lib/llm/core/index'; import { getModelConfigById } from '@/lib/db/model-config'; async function resolveLatestModelConfig(projectId, incomingModel = {}) { const modelId = incomingModel?.id; if (!modelId) { return incomingModel; } try { const latestModelConfig = await getModelConfigById(modelId); if (!latestModelConfig) { return incomingModel; } if (String(latestModelConfig.projectId) !== String(projectId)) { return incomingModel; } // Keep transient client-only fields, but force endpoint/auth/model fields to latest DB values. return { ...incomingModel, ...latestModelConfig }; } catch (error) { console.error('Failed to resolve latest model config:', String(error)); return incomingModel; } } export async function POST(request, { params }) { try { const { projectId } = params; // Validate project ID. if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // Read request payload. const { model, messages } = await request.json(); const resolvedModel = await resolveLatestModelConfig(projectId, model); // Validate request parameters. if (!resolvedModel) { return NextResponse.json({ error: 'The model parameters cannot be empty' }, { status: 400 }); } if (!Array.isArray(messages) || messages.length === 0) { return NextResponse.json({ error: 'The message list cannot be empty' }, { status: 400 }); } // Use custom LLM client. const llmClient = new LLMClient(resolvedModel); // Normalize message payload for text + vision models. const formattedMessages = messages.map(msg => { // Plain text message. if (typeof msg.content === 'string') { return { role: msg.role, content: msg.content }; } // Multimodal message (e.g. image parts). if (Array.isArray(msg.content)) { return { role: msg.role, content: msg.content }; } // Fallback. return { role: msg.role, content: msg.content }; }); // Call LLM API. let response = ''; try { const { answer, cot } = await llmClient.getResponseWithCOT(formattedMessages.filter(f => f.role !== 'error')); response = `${cot}${answer}`; } catch (error) { console.error('Failed to call LLM API:', String(error)); return NextResponse.json( { error: `Failed to call ${resolvedModel.modelId || resolvedModel.modelName || 'unknown'} model: ${error.message}` }, { status: 500 } ); } return NextResponse.json({ response }); } catch (error) { console.error('Failed to process chat request:', String(error)); return NextResponse.json({ error: `Failed to process chat request: ${error.message}` }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/playground/chat/stream/route.js ================================================ import { NextResponse } from 'next/server'; import LLMClient from '@/lib/llm/core/index'; import { getModelConfigById } from '@/lib/db/model-config'; async function resolveLatestModelConfig(projectId, incomingModel = {}) { const modelId = incomingModel?.id; if (!modelId) { return incomingModel; } try { const latestModelConfig = await getModelConfigById(modelId); if (!latestModelConfig) { return incomingModel; } if (String(latestModelConfig.projectId) !== String(projectId)) { return incomingModel; } return { ...incomingModel, ...latestModelConfig }; } catch (error) { console.error('Failed to resolve latest model config:', String(error)); return incomingModel; } } /** * Streaming chat endpoint. */ export async function POST(request, { params }) { const { projectId } = params; try { const body = await request.json(); const { model, messages } = body; const resolvedModel = await resolveLatestModelConfig(projectId, model); if (!resolvedModel || !messages) { return NextResponse.json({ error: 'Missing necessary parameters' }, { status: 400 }); } // Use custom LLM client. const llmClient = new LLMClient(resolvedModel); // Normalize message payload for text + vision models. const formattedMessages = messages.map(msg => { // Plain text message. if (typeof msg.content === 'string') { return { role: msg.role, content: msg.content }; } // Multimodal message (e.g. image parts). if (Array.isArray(msg.content)) { return { role: msg.role, content: msg.content }; } // Fallback. return { role: msg.role, content: msg.content }; }); try { // Stream response from provider. const response = await llmClient.chatStreamAPI(formattedMessages.filter(f => f.role !== 'error')); // Return native streaming response. return response; } catch (error) { console.error('Failed to call LLM API:', error); return NextResponse.json( { error: `Failed to call ${resolvedModel.modelId || resolvedModel.modelName || 'unknown'} model: ${error.message}` }, { status: 500 } ); } } catch (error) { console.error('Failed to process stream chat request:', String(error)); return NextResponse.json({ error: `Failed to process stream chat request: ${error.message}` }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/preview/[fileId]/route.js ================================================ import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; import { getProjectRoot } from '@/lib/db/base'; import { getUploadFileInfoById } from '@/lib/db/upload-files'; // 获取文件内容 export async function GET(request, { params }) { try { const { projectId, fileId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: 'Project ID cannot be empty' }, { status: 400 }); } // 获取项目根目录 let fileInfo = await getUploadFileInfoById(fileId); if (!fileInfo) { return NextResponse.json({ error: 'file does not exist' }, { status: 400 }); } // 获取文件路径 let filePath = path.join(fileInfo.path, fileInfo.fileName); if (fileInfo.fileExt !== '.md') { filePath = path.join(fileInfo.path, fileInfo.fileName.replace(/\.[^/.]+$/, '.md')); } //获取文件 const buffer = fs.readFileSync(filePath); const text = buffer.toString('utf-8'); return NextResponse.json({ fileId: fileId, fileName: fileInfo.fileName, content: text }); } catch (error) { console.error('Failed to get text block content:', String(error)); return NextResponse.json({ error: error.message || 'Failed to get text block content' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/questions/[questionId]/route.js ================================================ import { NextResponse } from 'next/server'; import { deleteQuestion } from '@/lib/db/questions'; // 删除单个问题 export async function DELETE(request, { params }) { try { const { projectId, questionId } = params; // 验证参数 if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } if (!questionId) { return NextResponse.json({ error: 'Question ID is required' }, { status: 400 }); } // 删除问题 await deleteQuestion(questionId); return NextResponse.json({ success: true, message: 'Delete successful' }); } catch (error) { console.error('Delete failed:', String(error)); return NextResponse.json({ error: error.message || 'Delete failed' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/questions/batch-delete/route.js ================================================ import { NextResponse } from 'next/server'; import { batchDeleteQuestions } from '@/lib/db/questions'; // 批量删除问题 export async function DELETE(request) { try { const body = await request.json(); const { questionIds } = body; // 验证参数 if (questionIds.length === 0) { return NextResponse.json({ error: 'Question ID is required' }, { status: 400 }); } // 删除问题 await batchDeleteQuestions(questionIds); return NextResponse.json({ success: true, message: 'Delete successful' }); } catch (error) { console.error('Delete failed:', String(error)); return NextResponse.json({ error: error.message || 'Delete failed' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/questions/export/route.js ================================================ import { NextResponse } from 'next/server'; export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { format, selectedIds, filters } = body; let questions; // 如果有选中的问题 ID,按 ID 获取 if (selectedIds && selectedIds.length > 0) { questions = await getQuestionsByIds(projectId, selectedIds); } else { // 否则获取全部问题(不限分页) questions = await getAllQuestions( projectId, filters?.searchTerm || '', filters?.chunkName || '', filters?.sourceType || 'all' ); } // 固定导出字段:问题内容、文本块名称、问题标签 const filteredQuestions = questions.map(q => ({ question: q.question, chunkName: q.chunk?.name || q.chunkName || '', questionLabel: q.questionLabel || '' })); return NextResponse.json(filteredQuestions); } catch (error) { console.error('Failed to export questions:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } } // 获取全部问题(不限分页) async function getAllQuestions(projectId, searchTerm = '', chunkName = '', sourceType = 'all') { const { db } = await import('@/lib/db/index'); const whereClause = { projectId }; // 搜索条件 if (searchTerm) { whereClause.OR = [{ question: { contains: searchTerm } }, { questionLabel: { contains: searchTerm } }]; } // 文本块名称筛选 if (chunkName) { whereClause.chunk = { name: { contains: chunkName } }; } // 数据源类型筛选 if (sourceType === 'text') { whereClause.imageName = null; } else if (sourceType === 'image') { whereClause.imageName = { not: null }; } return await db.questions.findMany({ where: whereClause, include: { chunk: { select: { name: true } } }, orderBy: { createAt: 'desc' } }); } // 根据 ID 列表获取问题 async function getQuestionsByIds(projectId, questionIds) { const { db } = await import('@/lib/db/index'); return await db.questions.findMany({ where: { projectId, id: { in: questionIds } }, include: { chunk: { select: { name: true } } }, orderBy: { createAt: 'desc' } }); } ================================================ FILE: app/api/projects/[projectId]/questions/route.js ================================================ import { NextResponse } from 'next/server'; import { getAllQuestionsByProjectId, getQuestions, getQuestionsIds, saveQuestions, updateQuestion } from '@/lib/db/questions'; import { getImageById, getImageChunk } from '@/lib/db/images'; // 获取项目的所有问题 export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Missing project ID' }, { status: 400 }); } const { searchParams } = new URL(request.url); let status = searchParams.get('status'); let answered = undefined; if (status === 'answered') answered = true; if (status === 'unanswered') answered = false; const chunkName = searchParams.get('chunkName'); const sourceType = searchParams.get('sourceType') || 'all'; // 'all', 'text', 'image' const searchMatchMode = searchParams.get('searchMatchMode') || 'match'; // 'match', 'notMatch' let selectedAll = searchParams.get('selectedAll'); if (selectedAll) { let data = await getQuestionsIds( projectId, answered, searchParams.get('input'), chunkName, sourceType, searchMatchMode ); return NextResponse.json(data); } let all = searchParams.get('all'); if (all) { let data = await getAllQuestionsByProjectId(projectId); return NextResponse.json(data); } // 获取问题列表 const questions = await getQuestions( projectId, parseInt(searchParams.get('page')), parseInt(searchParams.get('size')), answered, searchParams.get('input'), chunkName, sourceType, searchMatchMode ); return NextResponse.json(questions); } catch (error) { console.error('Failed to get questions:', String(error)); return NextResponse.json({ error: error.message || 'Failed to get questions' }, { status: 500 }); } } // 新增问题 export async function POST(request, { params }) { try { const { projectId } = params; const body = await request.json(); const { question, chunkId, label } = body; // 验证必要参数 if (!projectId || !question) { return NextResponse.json({ error: 'Missing necessary parameters' }, { status: 400 }); } if (!body.chunkId && body.imageId) { const chunk = await getImageChunk(projectId); body.chunkId = chunk.id; body.label = 'image'; } // 添加新问题 let questions = [body]; // 保存更新后的数据 let data = await saveQuestions(projectId, questions); // 返回成功响应 return NextResponse.json(data); } catch (error) { console.error('Failed to create question:', String(error)); return NextResponse.json({ error: error.message || 'Failed to create question' }, { status: 500 }); } } // 更新问题 export async function PUT(request) { try { const body = await request.json(); // 保存更新后的数据 const { imageId } = body; if (imageId) { body.imageName = (await getImageById(imageId))?.imageName; } let data = await updateQuestion(body); // 返回更新后的问题数据 return NextResponse.json(data); } catch (error) { console.error('更新问题失败:', String(error)); return NextResponse.json({ error: error.message || '更新问题失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/questions/templates/[templateId]/route.js ================================================ import { NextResponse } from 'next/server'; import templateDb from '@/lib/db/questionTemplates'; import { generateQuestionsFromTemplateEdit } from '@/lib/services/questions/template'; // 获取单个模板 export async function GET(request, { params }) { try { const { templateId } = params; const template = await templateDb.getTemplateById(templateId); if (!template) { return NextResponse.json({ error: '模板不存在' }, { status: 404 }); } // 获取使用统计 const usageCount = await templateDb.getTemplateUsageCount(templateId); return NextResponse.json({ success: true, template: { ...template, usageCount } }); } catch (error) { console.error('Failed to get template:', error); return NextResponse.json({ error: error.message || 'Failed to get template' }, { status: 500 }); } } // 更新问题模板 export async function PUT(request, { params }) { try { const { projectId, templateId } = params; const data = await request.json(); const { question, sourceType, answerType, description, labels, customFormat, order, autoGenerate } = data; // 验证数据源类型 if (sourceType && !['image', 'text'].includes(sourceType)) { return NextResponse.json({ error: '无效的数据源类型' }, { status: 400 }); } // 验证答案类型 if (answerType && !['text', 'label', 'custom_format'].includes(answerType)) { return NextResponse.json({ error: '无效的答案类型' }, { status: 400 }); } const updateData = {}; if (question !== undefined) updateData.question = question; if (sourceType !== undefined) updateData.sourceType = sourceType; if (answerType !== undefined) updateData.answerType = answerType; if (description !== undefined) updateData.description = description; if (labels !== undefined) updateData.labels = labels; if (customFormat !== undefined) updateData.customFormat = customFormat; if (order !== undefined) updateData.order = order; const template = await templateDb.updateTemplate(templateId, updateData); let generationResult = null; // 如果启用自动生成,则为还未创建此模板问题的数据源创建问题 if (autoGenerate) { try { generationResult = await generateQuestionsFromTemplateEdit(projectId, template); } catch (error) { console.error('编辑模式自动生成问题失败:', error); generationResult = { success: false, successCount: 0, failCount: 0, message: '自动生成问题时发生错误' }; } } return NextResponse.json({ success: true, template, generation: generationResult }); } catch (error) { console.error('Failed to update template:', error); return NextResponse.json({ error: error.message || 'Failed to update template' }, { status: 500 }); } } // 删除问题模板 export async function DELETE(request, { params }) { try { const { templateId } = params; // 检查是否有关联的问题 const usageCount = await templateDb.getTemplateUsageCount(templateId); if (usageCount > 0) { return NextResponse.json({ error: `此模板已被 ${usageCount} 个问题使用,无法删除` }, { status: 400 }); } await templateDb.deleteTemplate(templateId); return NextResponse.json({ success: true, message: '模板删除成功' }); } catch (error) { console.error('Failed to delete template:', error); return NextResponse.json({ error: error.message || 'Failed to delete template' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/questions/templates/route.js ================================================ import { NextResponse } from 'next/server'; import templateDb from '@/lib/db/questionTemplates'; import { generateQuestionsFromTemplate, checkTemplateGenerationAvailability } from '@/lib/services/questions/template'; // 获取问题模板列表 export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const sourceType = searchParams.get('sourceType'); const search = searchParams.get('search'); const templates = await templateDb.getTemplates(projectId, { sourceType, search }); // 获取使用统计 const templateIds = templates.map(t => t.id); const usageCounts = await templateDb.getTemplatesUsageCount(templateIds); // 添加使用统计到模板数据 const templatesWithUsage = templates.map(template => ({ ...template, usageCount: usageCounts[template.id] || 0 })); return NextResponse.json({ success: true, templates: templatesWithUsage }); } catch (error) { console.error('Failed to get templates:', error); return NextResponse.json({ error: error.message || 'Failed to get templates' }, { status: 500 }); } } // 创建问题模板 export async function POST(request, { params }) { try { const { projectId } = params; const data = await request.json(); const { question, sourceType, answerType, description, labels, customFormat, order, autoGenerate } = data; // 验证必填字段 if (!question || !sourceType || !answerType) { return NextResponse.json({ error: '缺少必要参数:question, sourceType, answerType' }, { status: 400 }); } // 验证数据源类型 if (!['image', 'text'].includes(sourceType)) { return NextResponse.json({ error: '无效的数据源类型' }, { status: 400 }); } // 验证答案类型 if (!['text', 'label', 'custom_format'].includes(answerType)) { return NextResponse.json({ error: '无效的答案类型' }, { status: 400 }); } // 如果是标签类型,验证 labels if (answerType === 'label' && (!labels || !Array.isArray(labels) || labels.length === 0)) { return NextResponse.json({ error: '标签类型问题必须提供标签列表' }, { status: 400 }); } // 如果是自定义格式,验证 customFormat if (answerType === 'custom_format' && !customFormat) { return NextResponse.json({ error: '自定义格式问题必须提供格式定义' }, { status: 400 }); } const template = await templateDb.createTemplate(projectId, { question, sourceType, answerType, description, labels: answerType === 'label' ? labels : [], customFormat: answerType === 'custom_format' ? customFormat : null, order: order || 0 }); let generationResult = null; // 如果启用自动生成,则为所有相关数据源创建问题 if (autoGenerate) { try { // 先检查是否有可用的数据源 const availability = await checkTemplateGenerationAvailability(projectId, sourceType); if (availability.available) { generationResult = await generateQuestionsFromTemplate(projectId, template); } else { generationResult = { success: false, successCount: 0, failCount: 0, message: availability.message }; } } catch (error) { console.error('自动生成问题失败:', error); generationResult = { success: false, successCount: 0, failCount: 0, message: '自动生成问题时发生错误' }; } } return NextResponse.json({ success: true, template, generation: generationResult }); } catch (error) { console.error('Failed to create template:', error); return NextResponse.json({ error: error.message || 'Failed to create template' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/questions/tree/route.js ================================================ import { NextResponse } from 'next/server'; import { getQuestionsForTree, getQuestionsByTag } from '@/lib/db/questions'; /** * 获取项目的问题树形视图数据 * @param {Request} request - 请求对象 * @param {Object} params - 路由参数 * @returns {Promise} - 包含问题数据的响应 */ export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: '项目ID不能为空' }, { status: 400 }); } const { searchParams } = new URL(request.url); const tag = searchParams.get('tag'); const input = searchParams.get('input'); const tagsOnly = searchParams.get('tagsOnly') === 'true'; const isDistill = searchParams.get('isDistill') === 'true'; // 默认排除图片问题(label='image'),可通过 excludeImage=false 参数改变 const excludeImage = searchParams.get('excludeImage') !== 'false'; if (tag) { // 获取指定标签的问题数据(包含完整字段) const questions = await getQuestionsByTag(projectId, tag, input, isDistill, excludeImage); return NextResponse.json(questions); } else if (tagsOnly) { // 只获取标签信息(仅包含 id 和 label 字段) const treeData = await getQuestionsForTree(projectId, input, isDistill, excludeImage); return NextResponse.json(treeData); } else { // 兼容原有请求,获取树形视图数据(仅包含 id 和 label 字段) const treeData = await getQuestionsForTree(projectId, null, isDistill, excludeImage); return NextResponse.json(treeData); } } catch (error) { console.error('获取问题树形数据失败:', String(error)); return NextResponse.json({ error: error.message || '获取问题树形数据失败' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/route.js ================================================ // 获取项目详情 import { deleteProject, getProject, updateProject, getTaskConfig } from '@/lib/db/projects'; export async function GET(request, { params }) { try { const { projectId } = params; const project = await getProject(projectId); const taskConfig = await getTaskConfig(projectId); if (!project) { return Response.json({ error: '项目不存在' }, { status: 404 }); } return Response.json({ ...project, taskConfig }); } catch (error) { console.error('获取项目详情出错:', String(error)); return Response.json({ error: String(error) }, { status: 500 }); } } // 更新项目 export async function PUT(request, { params }) { try { const { projectId } = params; const projectData = await request.json(); const hasNameField = Object.prototype.hasOwnProperty.call(projectData, 'name'); const hasDefaultModelField = Object.prototype.hasOwnProperty.call(projectData, 'defaultModelConfigId'); // 至少允许更新名称或默认模型(defaultModelConfigId 可显式为 null) if (!hasNameField && !hasDefaultModelField) { return Response.json({ error: '项目名称不能为空' }, { status: 400 }); } if (hasNameField && !projectData.name && !hasDefaultModelField) { return Response.json({ error: '项目名称不能为空' }, { status: 400 }); } const updatedProject = await updateProject(projectId, projectData); if (!updatedProject) { return Response.json({ error: '项目不存在' }, { status: 404 }); } return Response.json(updatedProject); } catch (error) { console.error('更新项目出错:', String(error)); return Response.json({ error: String(error) }, { status: 500 }); } } // 删除项目 export async function DELETE(request, { params }) { try { const { projectId } = params; const success = await deleteProject(projectId); if (!success) { return Response.json({ error: '项目不存在' }, { status: 404 }); } return Response.json({ success: true }); } catch (error) { console.error('删除项目出错:', error); return Response.json({ error: error.message }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/split/route.js ================================================ import { NextResponse } from 'next/server'; import { splitProjectFile, getProjectChunks } from '@/lib/file/text-splitter'; import { getProject, updateProject } from '@/lib/db/projects'; import { getTags } from '@/lib/db/tags'; import { handleDomainTree } from '@/lib/util/domain-tree'; // 处理文本分割请求 export async function POST(request, { params }) { try { const { projectId } = params; // 获取请求体 const { fileNames, model, language, domainTreeAction = 'rebuild' } = await request.json(); if (!model) { return NextResponse.json({ error: 'Please Select Model' }, { status: 400 }); } const project = await getProject(projectId); let result = { totalChunks: 0, chunks: [], toc: '' }; for (let i = 0; i < fileNames.length; i++) { const fileName = fileNames[i]; // 分割文本 const { toc, chunks, totalChunks } = await splitProjectFile(projectId, fileName); result.toc += toc; result.chunks.push(...chunks); result.totalChunks += totalChunks; console.log(projectId, fileName, `Text split completed, ${domainTreeAction} domain tree`); } // 调用领域树处理模块 const tags = await handleDomainTree({ projectId, action: domainTreeAction, newToc: result.toc, model, language, fileNames, project }); if (!tags && domainTreeAction !== 'keep') { await updateProject(projectId, { ...project }); return NextResponse.json( { error: 'AI analysis failed, please check model configuration, delete file and retry!' }, { status: 400 } ); } return NextResponse.json({ ...result, tags }); } catch (error) { console.error('Text split error:', String(error)); return NextResponse.json({ error: error.message || 'Text split failed' }, { status: 500 }); } } // 获取项目中的所有文本块 export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); const filter = searchParams.get('filter'); // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'The project ID cannot be empty' }, { status: 400 }); } // 获取文本块详细信息 const result = await getProjectChunks(projectId, filter); const tags = await getTags(projectId); // 返回详细的文本块信息和文件结果(单个文件) return NextResponse.json({ chunks: result.chunks, ...result.fileResult, // 单个文件结果,而不是数组 tags }); } catch (error) { console.error('Failed to get text chunks:', String(error)); return NextResponse.json({ error: error.message || 'Failed to get text chunks' }, { status: 500 }); } } ================================================ FILE: app/api/projects/[projectId]/tags/route.js ================================================ import { NextResponse } from 'next/server'; import { getTags, createTag, updateTag, deleteTag } from '@/lib/db/tags'; import { getQuestionsByTagName } from '@/lib/db/questions'; // 获取项目的标签树 export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // 获取标签树 const tags = await getTags(projectId); return NextResponse.json({ tags }); } catch (error) { console.error('Failed to obtain the label tree:', String(error)); return NextResponse.json({ error: error.message || 'Failed to obtain the label tree' }, { status: 500 }); } } // 更新项目的标签树 export async function PUT(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // 获取请求体 const { tags } = await request.json(); if (tags.id === undefined || tags.id === null || tags.id === '') { console.log('createTag', tags); let res = await createTag(projectId, tags.label, tags.parentId); return NextResponse.json({ tags: res }); } else { let res = await updateTag(tags.label, tags.id); return NextResponse.json({ tags: res }); } } catch (error) { console.error('Failed to update tags:', String(error)); return NextResponse.json({ error: error.message || 'Failed to update tags' }, { status: 500 }); } } export async function POST(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } const { tagName } = await request.json(); console.log('tagName', tagName); let data = await getQuestionsByTagName(projectId, tagName); return NextResponse.json(data); } catch (error) { console.error('Failed to obtain the label tree:', String(error)); return NextResponse.json({ error: error.message || 'Failed to obtain the label tree' }, { status: 500 }); } } export async function DELETE(request, { params }) { try { const { projectId } = params; // 验证项目ID if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // 获取要删除的标签ID const { searchParams } = new URL(request.url); const id = searchParams.get('id'); if (!id) { return NextResponse.json({ error: '标签 ID 是必需的' }, { status: 400 }); } console.log(`正在删除标签: ${id}`); const result = await deleteTag(id); console.log(`删除标签成功: ${id}`); return NextResponse.json({ success: true, message: '删除标签成功', data: result }); } catch (error) { console.error('删除标签失败:', String(error)); return NextResponse.json( { error: error.message || '删除标签失败', success: false }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/tasks/[taskId]/route.js ================================================ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // 获取任务详情 export async function GET(request, { params }) { try { const { projectId, taskId } = params; // 验证必填参数 if (!projectId || !taskId) { return NextResponse.json( { code: 400, error: '缺少必要参数' }, { status: 400 } ); } // 查询任务详情 const task = await prisma.task.findUnique({ where: { id: taskId, projectId } }); if (!task) { return NextResponse.json( { code: 404, error: '任务不存在' }, { status: 404 } ); } return NextResponse.json({ code: 0, data: task, message: '获取任务详情成功' }); } catch (error) { console.error('获取任务详情失败:', String(error)); return NextResponse.json( { code: 500, error: '获取任务详情失败', message: error.message }, { status: 500 } ); } } // 更新任务状态 export async function PATCH(request, { params }) { try { const { projectId, taskId } = params; const data = await request.json(); // 验证必填参数 if (!projectId || !taskId) { return NextResponse.json( { code: 400, error: '缺少必要参数' }, { status: 400 } ); } // 获取要更新的字段 const { status, completedCount, totalCount, detail, note, endTime } = data; // 构建更新数据 const updateData = {}; if (status !== undefined) { updateData.status = status; } if (completedCount !== undefined) { updateData.completedCount = completedCount; } if (totalCount !== undefined) { updateData.totalCount = totalCount; } if (detail !== undefined) { updateData.detail = detail; } if (note !== undefined) { updateData.note = note; } // 如果状态变为已完成、失败或已中断,自动添加结束时间 if (status === 1 || status === 2 || status === 3) { updateData.endTime = endTime || new Date(); } // 更新任务 const updatedTask = await prisma.task.update({ where: { id: taskId }, data: updateData }); return NextResponse.json({ code: 0, data: updatedTask, message: '更新任务状态成功' }); } catch (error) { console.error('更新任务状态失败:', String(error)); return NextResponse.json( { code: 500, error: '更新任务状态失败', message: error.message }, { status: 500 } ); } } // 删除任务 export async function DELETE(request, { params }) { try { const { projectId, taskId } = params; // 验证必填参数 if (!projectId || !taskId) { return NextResponse.json( { code: 400, error: '缺少必要参数' }, { status: 400 } ); } // 删除任务 await prisma.task.delete({ where: { id: taskId, projectId } }); return NextResponse.json({ code: 0, message: '删除任务成功' }); } catch (error) { console.error('删除任务失败:', String(error)); return NextResponse.json( { code: 500, error: '删除任务失败', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/tasks/list/route.js ================================================ import { NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // 获取项目的所有任务列表 export async function GET(request, { params }) { try { const { projectId } = params; const { searchParams } = new URL(request.url); // 可选参数: 任务类型和任务状态 const taskType = searchParams.get('taskType'); const statusStr = searchParams.get('status'); // 分页参数 const page = parseInt(searchParams.get('page') || '0'); const limit = parseInt(searchParams.get('limit') || '10'); // 构建查询条件 const where = { projectId }; if (taskType) { where.taskType = taskType; } if (statusStr && !isNaN(parseInt(statusStr))) { where.status = parseInt(statusStr); } // 获取任务总数 const total = await prisma.task.count({ where }); // 获取任务列表,按创建时间降序排序,并应用分页 const tasks = await prisma.task.findMany({ where, orderBy: { createAt: 'desc' }, skip: page * limit, take: limit }); return NextResponse.json({ code: 0, data: tasks, total, page, limit, message: '任务列表获取成功' }); } catch (error) { console.error('获取任务列表失败:', String(error)); return NextResponse.json( { code: 500, error: '获取任务列表失败', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/[projectId]/tasks/route.js ================================================ import { NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs/promises'; import { getProjectRoot } from '@/lib/db/base'; import { getTaskConfig } from '@/lib/db/projects'; import { processTask } from '@/lib/services/tasks'; import { db } from '@/lib/db/index'; function normalizeModelEndpoint(endpoint = '') { let normalizedEndpoint = String(endpoint).trim(); if (!normalizedEndpoint) { return ''; } if (normalizedEndpoint.includes('/chat/completions')) { normalizedEndpoint = normalizedEndpoint.replace('/chat/completions', ''); } return normalizedEndpoint; } function normalizeTaskModelInfo(modelInfo) { if (!modelInfo) { return {}; } let parsedModelInfo = modelInfo; if (typeof modelInfo === 'string') { try { parsedModelInfo = JSON.parse(modelInfo); } catch (error) { return {}; } } if (parsedModelInfo && typeof parsedModelInfo === 'object' && parsedModelInfo.endpoint) { parsedModelInfo.endpoint = normalizeModelEndpoint(parsedModelInfo.endpoint); } return parsedModelInfo; } // 获取任务配置 export async function GET(request, { params }) { try { const { projectId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'Project does not exist' + projectPath }, { status: 404 }); } const taskConfig = await getTaskConfig(projectId); return NextResponse.json(taskConfig); } catch (error) { console.error('Failed to obtain task configuration:', String(error)); return NextResponse.json({ error: 'Failed to obtain task configuration' }, { status: 500 }); } } // 更新任务配置 export async function PUT(request, { params }) { try { const { projectId } = params; // 验证项目 ID if (!projectId) { return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }); } // 获取请求体 const taskConfig = await request.json(); // 验证请求体 if (!taskConfig) { return NextResponse.json({ error: 'Task configuration cannot be empty' }, { status: 400 }); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 检查项目是否存在 try { await fs.access(projectPath); } catch (error) { return NextResponse.json({ error: 'Project does not exist' }, { status: 404 }); } // 获取任务配置文件路径 const taskConfigPath = path.join(projectPath, 'task-config.json'); // 写入任务配置文件 await fs.writeFile(taskConfigPath, JSON.stringify(taskConfig, null, 2), 'utf-8'); return NextResponse.json({ message: 'Task configuration updated successfully' }); } catch (error) { console.error('Failed to update task configuration:', String(error)); return NextResponse.json({ error: 'Failed to update task configuration' }, { status: 500 }); } } // 创建新任务 export async function POST(request, { params }) { try { const { projectId } = params; const data = await request.json(); // 验证必填字段 const { taskType, modelInfo, language, detail = '', totalCount = 0, note } = data; if (!taskType) { return NextResponse.json( { code: 400, error: 'Missing required parameter: taskType' }, { status: 400 } ); } // 创建新任务 const newTask = await db.task.create({ data: { projectId, taskType, status: 0, // 初始状态: 处理中 modelInfo: JSON.stringify(normalizeTaskModelInfo(modelInfo)), language: language || 'zh-CN', detail: detail || '', totalCount, note: note ? JSON.stringify(note) : '', completedCount: 0 } }); // 异步启动任务处理 processTask(newTask.id).catch(err => { console.error(`Task startup failed: ${newTask.id}`, String(err)); }); return NextResponse.json({ code: 0, data: newTask, message: 'Task created successfully' }); } catch (error) { console.error('Failed to create task:', String(error)); return NextResponse.json( { code: 500, error: 'Failed to create task', message: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/delete-directory/route.js ================================================ import { getProjectRoot } from '@/lib/db/base'; import { NextResponse } from 'next/server'; import path from 'path'; import fs from 'fs'; import { promisify } from 'util'; const rmdir = promisify(fs.rm); /** * Delete project directory * @returns {Promise} Operation result response */ export async function POST(request) { try { const { projectId } = await request.json(); if (!projectId) { return NextResponse.json( { success: false, error: 'Project ID is required' }, { status: 400 } ); } // Get project root directory const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // Check if directory exists if (!fs.existsSync(projectPath)) { return NextResponse.json( { success: false, error: 'Project directory not found' }, { status: 404 } ); } // Recursively remove directory await rmdir(projectPath, { recursive: true, force: true }); return NextResponse.json({ success: true, message: 'Project directory deleted' }); } catch (error) { console.error('Failed to delete project directory:', String(error)); return NextResponse.json( { success: false, error: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/migrate/route.js ================================================ import { NextResponse } from 'next/server'; import { main } from '@/lib/db/fileToDb'; // Store migration task states const migrationTasks = new Map(); /** * Start a migration task */ export async function POST() { try { // Generate a unique task ID const taskId = Date.now().toString(); // Initialize task state migrationTasks.set(taskId, { status: 'running', progress: 0, total: 0, completed: 0, error: null, startTime: Date.now() }); // Execute migration asynchronously executeMigration(taskId); // Return task ID return NextResponse.json({ success: true, taskId }); } catch (error) { console.error('Failed to start migration task:', String(error)); return NextResponse.json( { success: false, error: error.message }, { status: 500 } ); } } /** * Get migration task status */ export async function GET(request) { try { // Get task ID from URL const { searchParams } = new URL(request.url); const taskId = searchParams.get('taskId'); if (!taskId) { return NextResponse.json( { success: false, error: 'Missing taskId' }, { status: 400 } ); } // Read task state const task = migrationTasks.get(taskId); if (!task) { return NextResponse.json( { success: false, error: 'Task not found' }, { status: 404 } ); } // Return task state return NextResponse.json({ success: true, task }); } catch (error) { console.error('Failed to get migration task status:', String(error)); return NextResponse.json( { success: false, error: error.message }, { status: 500 } ); } } /** * Execute migration task asynchronously * @param {string} taskId Task ID */ async function executeMigration(taskId) { try { // Read task state const task = migrationTasks.get(taskId); if (!task) { console.error(`Task not found: ${taskId}`); return; } // Reset task state to running task.status = 'running'; task.progress = 0; task.completed = 0; task.total = 0; task.startTime = Date.now(); // Persist task state once per second so clients can poll progress const statusUpdateInterval = setInterval(() => { // Only update while still running if (task.status === 'running') { migrationTasks.set(taskId, { ...task }); console.log( `Migration task status updated: ${taskId}, progress: ${task.progress}%, completed: ${task.completed}/${task.total}` ); } else { // Stop updating when task ends clearInterval(statusUpdateInterval); } }, 1000); // Run migration and let main(task) mutate progress fields const count = await main(task); // Clear status update timer clearInterval(statusUpdateInterval); // Mark as completed task.status = 'completed'; task.progress = 100; task.completed = count; if (task.total === 0) task.total = count; task.endTime = Date.now(); // Persist final task state migrationTasks.set(taskId, { ...task }); // Clean up task state after 30 minutes setTimeout( () => { migrationTasks.delete(taskId); console.log(`Migration task state cleaned up: ${taskId}`); }, 30 * 60 * 1000 ); } catch (error) { console.error(`Failed to execute migration task: ${taskId}`, String(error)); // Read task state const task = migrationTasks.get(taskId); if (task) { // Mark as failed task.status = 'failed'; task.error = error.message; task.endTime = Date.now(); // Persist task state migrationTasks.set(taskId, task); } } } ================================================ FILE: app/api/projects/open-directory/route.js ================================================ import { getProjectRoot } from '@/lib/db/base'; import { NextResponse } from 'next/server'; import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); /** * Open project directory * @returns {Promise} Operation result response */ export async function POST(request) { try { const { projectId } = await request.json(); if (!projectId) { return NextResponse.json( { success: false, error: 'Project ID is required' }, { status: 400 } ); } // Get project root directory const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // Open directory based on OS const platform = process.platform; let command; if (platform === 'win32') { // Windows command = `explorer "${projectPath}"`; } else if (platform === 'darwin') { // macOS command = `open "${projectPath}"`; } else { // Linux and others command = `xdg-open "${projectPath}"`; } await execAsync(command); return NextResponse.json({ success: true, message: 'Project directory opened' }); } catch (error) { console.error('Failed to open project directory:', String(error)); return NextResponse.json( { success: false, error: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/projects/route.js ================================================ import { createProject, getProjects, isExistByName } from '@/lib/db/projects'; import { createInitModelConfig, getModelConfigByProjectId } from '@/lib/db/model-config'; export async function POST(request) { try { const projectData = await request.json(); if (!projectData.name) { return Response.json({ error: 'Project name is required' }, { status: 400 }); } if (await isExistByName(projectData.name)) { return Response.json({ error: 'Project name already exists' }, { status: 400 }); } const newProject = await createProject(projectData); if (projectData.reuseConfigFrom) { let data = await getModelConfigByProjectId(projectData.reuseConfigFrom); let newData = data.map(item => { delete item.id; return { ...item, projectId: newProject.id }; }); await createInitModelConfig(newData); } return Response.json(newProject, { status: 201 }); } catch (error) { console.error('Failed to create project:', String(error)); return Response.json({ error: String(error) }, { status: 500 }); } } export async function GET(request) { try { const projects = await getProjects(); return Response.json(projects); } catch (error) { console.error('Failed to get project list:', String(error)); return Response.json({ error: String(error) }, { status: 500 }); } } ================================================ FILE: app/api/projects/unmigrated/route.js ================================================ import { getProjectRoot } from '@/lib/db/base'; import { db } from '@/lib/db/index'; import fs from 'fs'; import path from 'path'; import { NextResponse } from 'next/server'; /** * Get list of unmigrated projects * @returns {Promise} Response containing unmigrated project IDs */ export async function GET(request) { // Read query params from request URL const { searchParams } = new URL(request.url); // Force a unique value per request const timestamp = searchParams.get('_t') || Date.now(); try { // Get project root directory const projectRoot = await getProjectRoot(); // Read all folders under project root (each folder represents a project) const files = await fs.promises.readdir(projectRoot, { withFileTypes: true }); // Filter directories const projectDirs = files.filter(file => file.isDirectory()); // Return empty list if no project directories exist if (projectDirs.length === 0) { return NextResponse.json({ success: true, data: [] }); } // Collect all project IDs const projectIds = projectDirs.map(dir => dir.name); // Batch query migrated projects const existingProjects = await db.projects.findMany({ where: { id: { in: projectIds } }, select: { id: true } }); // Convert to Set for fast lookups const existingProjectIds = new Set(existingProjects.map(p => p.id)); // Filter unmigrated projects const unmigratedProjectDirs = projectDirs.filter(dir => !existingProjectIds.has(dir.name)); // Build unmigrated project ID list const unmigratedProjects = unmigratedProjectDirs.map(dir => dir.name); return NextResponse.json({ success: true, data: unmigratedProjects, projectRoot, number: Date.now(), timestamp }); } catch (error) { console.error('Failed to get unmigrated project list:', String(error)); return NextResponse.json( { success: false, error: error.message }, { status: 500 } ); } } ================================================ FILE: app/api/update/route.js ================================================ import { NextResponse } from 'next/server'; import { exec } from 'child_process'; import path from 'path'; import fs from 'fs'; export async function POST() { try { const desktopDir = path.join(process.cwd(), 'desktop'); const updaterPath = path.join(desktopDir, 'scripts', 'updater.js'); if (!fs.existsSync(updaterPath)) { return NextResponse.json( { success: false, message: 'The update feature is only available in the client environment' }, { status: 400 } ); } // Run update script return new Promise(resolve => { const updaterProcess = exec(`node "${updaterPath}"`, { cwd: process.cwd() }); let output = ''; updaterProcess.stdout.on('data', data => { output += data.toString(); console.log(`Update output: ${data}`); }); updaterProcess.stderr.on('data', data => { output += data.toString(); console.error(`Update error: ${data}`); }); updaterProcess.on('close', code => { console.log(`Update process exit, exit code: ${code}`); if (code === 0) { resolve( NextResponse.json({ success: true, message: 'Update successful, application will restart' }) ); } else { resolve( NextResponse.json( { success: false, message: `Update failed, exit code: ${code}, output: ${output}` }, { status: 500 } ) ); } }); }); } catch (error) { console.error('Failed to execute update:', String(error)); return NextResponse.json( { success: false, message: `Failed to execute update: ${error.message}` }, { status: 500 } ); } } ================================================ FILE: app/dataset-square/page.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Container, Typography, Paper, useTheme, alpha } from '@mui/material'; import StorageIcon from '@mui/icons-material/Storage'; import Navbar from '@/components/Navbar/index'; import { DatasetSearchBar } from '@/components/dataset-square/DatasetSearchBar'; import { DatasetSiteList } from '@/components/dataset-square/DatasetSiteList'; import { useTranslation } from 'react-i18next'; export default function DatasetSquarePage() { const [projects, setProjects] = useState([]); const theme = useTheme(); const { t } = useTranslation(); // 获取项目列表和模型列表 useEffect(() => { async function fetchData() { try { // 获取用户创建的项目详情 const response = await fetch('/api/projects'); if (response.ok) { const projectsData = await response.json(); setProjects(projectsData); } } catch (error) { console.error('获取数据失败:', error); } } fetchData(); }, []); return (
{/* 导航栏 */} {/* 头部区域 */} {/* 背景装饰 */} {t('datasetSquare.title')} {t('datasetSquare.subtitle')} {/* 搜索栏组件 */} {/* 内容区域 */} {/* 数据集网站列表组件 */}
); } ================================================ FILE: app/globals.css ================================================ * { box-sizing: border-box; padding: 0; margin: 0; } html, body { width: 100%; max-width: 100%; overflow-x: hidden; height: 100%; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* 避免根滚动条显隐导致页面横向抖动 */ html { overflow-y: auto; } a { color: inherit; text-decoration: none; } /* 渐变文本样式 */ .gradient-text { background: linear-gradient(90deg, #2a5caa 0%, #8b5cf6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-fill-color: transparent; } /* 页面容器下间距 */ main { min-height: calc(100vh - 64px); } /* 自定义滚动条 */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.2); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background-color: rgba(0, 0, 0, 0.3); } /* 暗色模式滚动条 */ [data-theme='dark'] ::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.2); } [data-theme='dark'] ::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, 0.3); } /* 方便的间距类 */ .mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; } .mt-3 { margin-top: 24px; } .mt-4 { margin-top: 32px; } .mb-1 { margin-bottom: 8px; } .mb-2 { margin-bottom: 16px; } .mb-3 { margin-bottom: 24px; } .mb-4 { margin-bottom: 32px; } /* 响应式样式 */ @media (max-width: 600px) { .hide-on-mobile { display: none !important; } } /* 输入框和选择框边框简化 */ .plain-select .MuiOutlinedInput-notchedOutline, .plain-input .MuiOutlinedInput-notchedOutline { border-color: transparent !important; } /* 卡片悬停效果 */ .hover-card { transition: transform 0.2s ease, box-shadow 0.2s ease; } .hover-card:hover { transform: translateY(-4px); box-shadow: 0 12px 20px rgba(0, 0, 0, 0.1); } [data-theme='dark'] .hover-card:hover { box-shadow: 0 12px 20px rgba(0, 0, 0, 0.3); } ================================================ FILE: app/layout.js ================================================ import './globals.css'; import ThemeRegistry from '@/components/ThemeRegistry'; import I18nProvider from '@/components/I18nProvider'; import { Toaster } from 'sonner'; import { Provider } from 'jotai'; export const metadata = { title: 'Easy Dataset', description: '一个强大的 LLM 数据集生成工具', icons: { icon: '/imgs/logo.ico' // 更新为正确的文件名 } }; export default function RootLayout({ children }) { return ( {children} ); } ================================================ FILE: app/monitoring/components/Charts.js ================================================ import React from 'react'; import { Card, CardContent, Typography, Box, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'; export default function Charts({ trendData, modelDistribution }) { const theme = useTheme(); const { t } = useTranslation(); const COLORS = [ theme.palette.primary.main, theme.palette.secondary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.error.main, theme.palette.info.main ]; return ( {/* 趋势图 */} {t('monitoring.charts.tokenTrend')} {t('monitoring.charts.inputLegend')} {t('monitoring.charts.outputLegend')} {/* 模型分布图 */} {t('monitoring.charts.distributionTitle')} {t('monitoring.charts.distributionSubtitle')} {modelDistribution.map((entry, index) => ( ))} t('monitoring.charts.tokensTooltip', { value: (value / 1000).toFixed(1) })} contentStyle={{ backgroundColor: theme.palette.background.paper, border: `1px solid ${theme.palette.divider}`, borderRadius: 8 }} /> ( {value} )} /> {/* 中间文字 */} {/* {modelDistribution.length} Models */} ); } ================================================ FILE: app/monitoring/components/StatsCards.js ================================================ import React from 'react'; import { Box, Card, CardContent, Grid, Typography, Stack, useTheme, alpha } from '@mui/material'; import { Storage as StorageIcon, Balance as BalanceIcon, Bolt as BoltIcon, AccessTime as AccessTimeIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; function StatCard({ title, value, subValue, icon: Icon, color }) { const theme = useTheme(); return ( {title} {value} {subValue && ( {subValue} )} ); } export default function StatsCards({ data }) { const theme = useTheme(); const { t } = useTranslation(); // 格式化数字 const formatNumber = num => { if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; return num; }; return ( {/* 总 Token 消耗 */} {/* 平均 Token 消耗/次 */} {/* 总调用次数 */} {t('monitoring.stats.successCalls', { count: formatNumber(data.successCalls) })} · {t('monitoring.stats.failedCalls', { count: formatNumber(data.failedCalls) })} {data.totalCalls > 0 && ( ({t('monitoring.stats.failureRate', { rate: ((data.failureRate || 0) * 100).toFixed(1) })}) )} } icon={BoltIcon} color={theme.palette.success.main} /> {/* 平均响应耗时 */} 0 ? t('monitoring.stats.basedOnSuccessCalls', { count: formatNumber(data.successCalls) }) : t('monitoring.stats.noSuccessCalls') } icon={AccessTimeIcon} color={theme.palette.warning.main} /> ); } ================================================ FILE: app/monitoring/components/UsageTable.js ================================================ import React, { useState } from 'react'; import { Box, Card, CardContent, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, TextField, InputAdornment, TablePagination, useTheme, alpha, Tooltip } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import { useTranslation } from 'react-i18next'; const statusColors = { SUCCESS: 'success', FAILED: 'error' }; export default function UsageTable({ data, total, page, pageSize, onPageChange, onPageSizeChange, searchTerm, onSearchChange }) { const theme = useTheme(); const { t } = useTranslation(); const handleChangePage = (event, newPage) => { onPageChange(newPage + 1); // MUI uses 0-indexed, our API uses 1-indexed }; const handleChangeRowsPerPage = event => { onPageSizeChange(parseInt(event.target.value, 10)); }; const handleSearchChange = event => { onSearchChange(event.target.value); }; // 直接使用传入的数据,分页和搜索已在后端完成 return ( {t('monitoring.table.title')} ) }} sx={{ width: 300 }} /> {t('monitoring.table.columns.projectName')} {t('monitoring.table.columns.provider')} {t('monitoring.table.columns.model')} {t('monitoring.table.columns.status')} {t('monitoring.table.columns.failureReason')} {t('monitoring.table.columns.inputTokens')} {t('monitoring.table.columns.outputTokens')} {t('monitoring.table.columns.totalTokens')} {t('monitoring.table.columns.calls')} {t('monitoring.table.columns.avgLatency')} {data.map((row, index) => ( {row.projectName} {row.model} {row.failureReason ? ( 20 ? row.failureReason.slice(0, 20) + '...' : row.failureReason } size="small" color="error" variant="soft" sx={{ maxWidth: 200, bgcolor: alpha(theme.palette.error.main, 0.1), color: theme.palette.error.dark, cursor: 'pointer' }} /> ) : ( '-' )} {row.inputTokens.toLocaleString()} {row.outputTokens.toLocaleString()} {row.totalTokens.toLocaleString()} {row.calls} {row.avgLatency} ))} {data.length === 0 && ( {t('monitoring.table.empty')} )}
); } ================================================ FILE: app/monitoring/hooks/useMonitoringData.js ================================================ import { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; export function useMonitoringData() { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [summaryData, setSummaryData] = useState({ summary: { totalTokens: 0, inputTokens: 0, outputTokens: 0, totalCalls: 0, successCalls: 0, failedCalls: 0, totalLatency: 0, avgLatency: 0, avgTokensPerCall: 0, failureRate: 0 }, trend: [], modelDistribution: [], projects: [], providers: [] }); const [logsData, setLogsData] = useState({ details: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }); const [filters, setFilters] = useState({ timeRange: '7d', projectId: 'all', provider: 'all', status: 'all' }); const [pagination, setPagination] = useState({ page: 1, pageSize: 10 }); const [searchTerm, setSearchTerm] = useState(''); // 获取汇总数据 const fetchSummary = useCallback(async () => { try { const response = await axios.get('/api/monitoring/summary', { params: filters }); setSummaryData(response.data); } catch (error) { console.error('Failed to fetch monitoring summary:', error); toast.error(t('monitoring.errors.fetchSummaryFailed')); } }, [filters, t]); // 获取日志列表 const fetchLogs = useCallback(async () => { try { const response = await axios.get('/api/monitoring/logs', { params: { ...filters, page: pagination.page, pageSize: pagination.pageSize, search: searchTerm } }); setLogsData(response.data); } catch (error) { console.error('Failed to fetch monitoring logs:', error); toast.error(t('monitoring.errors.fetchLogsFailed')); } }, [filters, pagination, searchTerm, t]); // 初始加载 useEffect(() => { const fetchData = async () => { setLoading(true); await Promise.all([fetchSummary(), fetchLogs()]); setLoading(false); }; fetchData(); }, [fetchSummary, fetchLogs]); const handleFilterChange = (key, value) => { setFilters(prev => ({ ...prev, [key]: value })); setPagination(prev => ({ ...prev, page: 1 })); // 重置到第一页 }; const handlePageChange = newPage => { setPagination(prev => ({ ...prev, page: newPage })); }; const handlePageSizeChange = newPageSize => { setPagination({ page: 1, pageSize: newPageSize }); }; const handleSearchChange = term => { setSearchTerm(term); setPagination(prev => ({ ...prev, page: 1 })); // 重置到第一页 }; const refresh = useCallback(async () => { setLoading(true); await Promise.all([fetchSummary(), fetchLogs()]); setLoading(false); }, [fetchSummary, fetchLogs]); return { loading, summaryData, logsData, filters, pagination, searchTerm, handleFilterChange, handlePageChange, handlePageSizeChange, handleSearchChange, refresh }; } ================================================ FILE: app/monitoring/page.js ================================================ 'use client'; import React, { useState, useEffect } from 'react'; import { Box, Container, Stack, Button, FormControl, Select, MenuItem, ToggleButton, ToggleButtonGroup, CircularProgress, useTheme } from '@mui/material'; import { Download as DownloadIcon, FilterList as FilterListIcon, CloudQueue as CloudQueueIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import Navbar from '@/components/Navbar/index'; import StatsCards from './components/StatsCards'; import Charts from './components/Charts'; import UsageTable from './components/UsageTable'; import { useMonitoringData } from './hooks/useMonitoringData'; export default function MonitoringPage() { const theme = useTheme(); const { t } = useTranslation(); const [projects, setProjects] = useState([]); const { loading, summaryData, logsData, filters, pagination, searchTerm, handleFilterChange, handlePageChange, handlePageSizeChange, handleSearchChange } = useMonitoringData(); // 获取项目列表用于 Navbar useEffect(() => { async function fetchProjects() { try { const response = await fetch('/api/projects'); if (response.ok) { const data = await response.json(); setProjects(data); } } catch (error) { console.error('Failed to fetch projects:', error); } } fetchProjects(); }, []); const handleTimeRangeChange = (event, newRange) => { if (newRange !== null) { handleFilterChange('timeRange', newRange); } }; const handleExport = () => { // 简单的导出功能实现,将当前 logsData.details 导出为 CSV if (!logsData.details || logsData.details.length === 0) return; const headers = [ t('monitoring.table.columns.projectName'), t('monitoring.table.columns.provider'), t('monitoring.table.columns.model'), t('monitoring.table.columns.status'), t('monitoring.table.columns.failureReason'), t('monitoring.table.columns.inputTokens'), t('monitoring.table.columns.outputTokens'), t('monitoring.table.columns.totalTokens'), t('monitoring.table.columns.calls'), t('monitoring.table.columns.avgLatency') ]; const csvContent = [ headers.join(','), ...logsData.details.map(row => [ row.projectName, row.provider, row.model, row.status, (row.failureReason || '').replace(/,/g, ' '), row.inputTokens, row.outputTokens, row.totalTokens, row.calls, row.avgLatency ].join(',') ) ].join('\n'); const blob = new Blob([`\uFEFF${csvContent}`], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `llm-monitoring-export-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); }; return ( <> {/* Header Area */} {/* Time Range Selector */} {t('monitoring.timeRange.24h')} {t('monitoring.timeRange.7d')} {t('monitoring.timeRange.30d')} {/* Filters & Actions */} {loading ? ( ) : ( {/* 统计卡片 */} {/* 图表区域 */} {/* 详细表格 */} )} ); } ================================================ FILE: app/page.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Container, Box, Typography, CircularProgress, Stack, useTheme } from '@mui/material'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import Navbar from '@/components/Navbar/index'; import HeroSection from '@/components/home/HeroSection'; import ProjectList from '@/components/home/ProjectList'; import CreateProjectDialog from '@/components/home/CreateProjectDialog'; import MigrationDialog from '@/components/home/MigrationDialog'; import { motion } from 'framer-motion'; import { useTranslation } from 'react-i18next'; export default function Home() { const { t } = useTranslation(); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [unmigratedProjects, setUnmigratedProjects] = useState([]); const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); useEffect(() => { async function fetchProjects() { try { setLoading(true); // 获取用户创建的项目详情 const response = await fetch(`/api/projects`); if (!response.ok) { throw new Error(t('projects.fetchFailed')); } const data = await response.json(); setProjects(data); // 检查是否有未迁移的项目 await checkUnmigratedProjects(); } catch (error) { console.error(t('projects.fetchError'), String(error)); setError(String(error)); } finally { setLoading(false); } } // 检查未迁移的项目 async function checkUnmigratedProjects() { try { const response = await fetch('/api/projects/unmigrated'); if (!response.ok) { console.error('检查未迁移项目失败'); return; } const { success, data } = await response.json(); if (success && Array.isArray(data) && data.length > 0) { setUnmigratedProjects(data); setMigrationDialogOpen(true); } } catch (error) { console.error('检查未迁移项目出错', error); } } fetchProjects(); }, []); const theme = useTheme(); return (
setCreateDialogOpen(true)} /> {/* */} {loading && ( {t('projects.loading')} )} {error && !loading && ( {t('projects.fetchFailed')}: {error} )} {!loading && ( setCreateDialogOpen(true)} /> )} setCreateDialogOpen(false)} /> {/* 项目迁移对话框 */} setMigrationDialogOpen(false)} projectIds={unmigratedProjects} />
); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/[taskId]/page.js ================================================ 'use client'; import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { Box, Container, Button, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'; import StopIcon from '@mui/icons-material/Stop'; import { useTranslation } from 'react-i18next'; import useBlindTestDetail from '../hooks/useBlindTestDetail'; import BlindTestHeader from '../components/BlindTestHeader'; import ResultSummary from '../components/ResultSummary'; import ResultDetailList from '../components/ResultDetailList'; import BlindTestInProgress from '../components/BlindTestInProgress'; export default function BlindTestDetailPage() { const { projectId, taskId } = useParams(); const router = useRouter(); const { t } = useTranslation(); const { task, loading, error, setError, currentQuestion, leftAnswer, rightAnswer, answersLoading, streamingA, streamingB, voting, completed, fetchCurrentQuestion, submitVote, interruptTask, getResultStats } = useBlindTestDetail(projectId, taskId); const [interruptDialog, setInterruptDialog] = useState(false); const handleBack = () => router.push(`/projects/${projectId}/blind-test-tasks`); const handleVote = async vote => { await submitVote(vote); }; const handleInterrupt = async () => { await interruptTask(); setInterruptDialog(false); }; // 加载中 if (loading) { return ( ); } // 任务不存在 if (!task) { return ( {t('blindTest.taskNotFound', '任务不存在')} ); } const isResultView = completed || task.status !== 0; const stats = getResultStats(); // 结果展示页面(已完成或已中断) if (isResultView) { return ( ); } // 盲测进行中页面 return ( } onClick={() => setInterruptDialog(true)} size="small" > {t('blindTest.interrupt', '中断任务')} } /> {error && ( setError('')}> {error} )} {/* 中断确认对话框 */} setInterruptDialog(false)}> {t('blindTest.interruptConfirmTitle', '确认中断')} {t('blindTest.interruptConfirmMessage', '确定要中断这个盲测任务吗?已完成的评判结果将保留。')} ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/components/BlindTestHeader.js ================================================ import { Box, Typography, IconButton, Chip, Button } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; import { useTranslation } from 'react-i18next'; import { useTheme, alpha } from '@mui/material/styles'; import { blindTestStyles } from '@/styles/blindTest'; export default function BlindTestHeader({ title, status, onBack, actions }) { const { t } = useTranslation(); const theme = useTheme(); const styles = blindTestStyles(theme); const getStatusConfig = s => { switch (s) { case 1: return { label: 'blindTest.statusCompleted', color: 'success' }; case 3: return { label: 'blindTest.statusInterrupted', color: 'warning' }; default: return { label: 'blindTest.statusProcessing', color: 'primary' }; } }; const statusConfig = status !== undefined ? getStatusConfig(status) : null; return ( {title} {statusConfig && ( )} {actions} ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/components/BlindTestInProgress.js ================================================ import { useState, useRef, useEffect } from 'react'; import { Box, Paper, Typography, Button, LinearProgress, CircularProgress, Alert, Chip, Collapse, IconButton, Tooltip, Fade, Avatar } from '@mui/material'; import { useTheme, alpha } from '@mui/material/styles'; import ThumbUpIcon from '@mui/icons-material/ThumbUp'; import ThumbDownIcon from '@mui/icons-material/ThumbDown'; import ThumbsUpDownIcon from '@mui/icons-material/ThumbsUpDown'; import RefreshIcon from '@mui/icons-material/Refresh'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import PsychologyIcon from '@mui/icons-material/Psychology'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import AssignmentIcon from '@mui/icons-material/Assignment'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import 'github-markdown-css/github-markdown-light.css'; import { blindTestStyles } from '@/styles/blindTest'; function AnswerBox({ title, modelLabel, answer, streaming, showThinking, setShowThinking, scrollRef, styles, theme }) { const { t } = useTranslation(); const isLeft = modelLabel === 'A'; const avatarColor = isLeft ? 'primary.main' : 'secondary.main'; return ( {modelLabel} {title} {streaming && } {answer?.duration > 0 && !streaming && ( )} {answer?.error ? ( {answer.error} ) : ( {/* 思维链渲染 */} {answer?.thinking && ( setShowThinking(!showThinking)} > {answer.isThinking ? ( ) : ( )} {t('playground.reasoningProcess', '推理过程')} {showThinking ? : } {answer.thinking} )} {answer?.content ? (
{answer.content}
) : streaming ? ( {t('blindTest.generatingAnswers', '正在生成回答...')} ) : null}
)}
); } export default function BlindTestInProgress({ task, currentQuestion, leftAnswer, rightAnswer, streamingA, streamingB, answersLoading, voting, onVote, onReload }) { const { t } = useTranslation(); const theme = useTheme(); const styles = blindTestStyles(theme); const [showThinkingLeft, setShowThinkingLeft] = useState(true); const [showThinkingRight, setShowThinkingRight] = useState(true); // 自动滚动引用 const leftScrollRef = useRef(null); const rightScrollRef = useRef(null); // 处理自动滚动 useEffect(() => { if (streamingA && leftScrollRef.current) { leftScrollRef.current.scrollTop = leftScrollRef.current.scrollHeight; } }, [leftAnswer?.content, leftAnswer?.thinking, streamingA]); useEffect(() => { if (streamingB && rightScrollRef.current) { rightScrollRef.current.scrollTop = rightScrollRef.current.scrollHeight; } }, [rightAnswer?.content, rightAnswer?.thinking, streamingB]); const progress = task ? (task.completedCount / task.totalCount) * 100 : 0; if (answersLoading && !currentQuestion) { return ( {t('blindTest.generatingAnswers', '正在准备题目...')} ); } if (!currentQuestion) { return ( ); } return ( {/* 顶部进度和问题 */} {t('blindTest.progress', '进度')} {task.completedCount + 1}/{task.totalCount} {currentQuestion.question} {/* 回答区域 */} {/* 底部投票区域 */} {t('blindTest.referenceAnswer', '参考答案')} {currentQuestion.answer} ) : ( t('blindTest.noReferenceAnswer', '暂无参考答案') ) } arrow placement="top" TransitionComponent={Fade} TransitionProps={{ timeout: 600 }} componentsProps={{ tooltip: { sx: { bgcolor: theme.palette.mode === 'dark' ? 'grey.900' : 'background.paper', color: 'text.primary', boxShadow: theme.shadows[8], border: `1px solid ${theme.palette.divider}`, p: 0, '& .MuiTooltip-arrow': { color: theme.palette.mode === 'dark' ? 'grey.900' : 'background.paper', '&::before': { border: `1px solid ${theme.palette.divider}` } } } } }} > ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/components/BlindTestTaskCard.js ================================================ 'use client'; import { Box, Card, CardContent, Typography, Chip, IconButton, Menu, MenuItem, LinearProgress, Avatar, Grid, Tooltip, Divider } from '@mui/material'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import DeleteIcon from '@mui/icons-material/Delete'; import StopIcon from '@mui/icons-material/Stop'; import VisibilityIcon from '@mui/icons-material/Visibility'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { alpha, useTheme } from '@mui/material/styles'; const STATUS_MAP = { 0: { label: 'blindTest.statusProcessing', color: 'primary', bgColor: 'primary.main' }, 1: { label: 'blindTest.statusCompleted', color: 'success', bgColor: 'success.main' }, 2: { label: 'blindTest.statusFailed', color: 'error', bgColor: 'error.main' }, 3: { label: 'blindTest.statusInterrupted', color: 'warning', bgColor: 'warning.main' } }; export default function BlindTestTaskCard({ task, onView, onDelete, onInterrupt, onContinue }) { const { t } = useTranslation(); const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const handleMenuOpen = e => { e.stopPropagation(); setAnchorEl(e.currentTarget); }; const handleMenuClose = () => { setAnchorEl(null); }; const handleView = e => { e?.stopPropagation?.(); handleMenuClose(); onView?.(task); }; const handleDelete = e => { e?.stopPropagation?.(); handleMenuClose(); onDelete?.(task); }; const handleInterrupt = e => { e?.stopPropagation?.(); handleMenuClose(); onInterrupt?.(task); }; const handleContinue = e => { e?.stopPropagation?.(); handleMenuClose(); onContinue?.(task); }; const statusConfig = STATUS_MAP[task.status] || STATUS_MAP[0]; const progress = task.totalCount > 0 ? (task.completedCount / task.totalCount) * 100 : 0; const isProcessing = task.status === 0; const isCompleted = task.status === 1; // 计算模型得分 const results = task.detail?.results || []; const modelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0); const modelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0); const totalScore = modelAScore + modelBScore; // Calculate win percentages for visual bar const modelAPercent = totalScore > 0 ? (modelAScore / totalScore) * 100 : 50; const modelBPercent = totalScore > 0 ? (modelBScore / totalScore) * 100 : 50; const winner = isCompleted ? (modelAScore > modelBScore ? 'A' : modelBScore > modelAScore ? 'B' : 'Tie') : null; return ( handleView(e)} > {/* Status & Time */} {new Date(task.createAt).toLocaleDateString()} {/* Model Comparison Area */} {/* Model A */} A {task.modelInfo?.modelA?.modelName || 'Model A'} {task.modelInfo?.modelA?.providerName} {isCompleted && winner === 'A' && } {/* Center Status/Score */} {isCompleted ? ( {modelAScore.toFixed(1)} : {modelBScore.toFixed(1)} ) : ( VS {Math.round(progress)}% )} {/* Model B */} B {task.modelInfo?.modelB?.modelName || 'Model B'} {task.modelInfo?.modelB?.providerName} {isCompleted && winner === 'B' && } {/* Menu */} {/* 菜单 */} {t('blindTest.viewDetails', '查看详情')} {isProcessing && ( {t('blindTest.continue', '继续盲测')} )} {isProcessing && ( {t('blindTest.interrupt', '中断任务')} )} {t('common.delete', '删除')} ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/components/CreateBlindTestDialog.js ================================================ 'use client'; import { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, FormControl, InputLabel, Select, MenuItem, Alert, CircularProgress, Chip, Divider, TextField, OutlinedInput, Checkbox, ListItemText, Avatar, Paper } from '@mui/material'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import { useTranslation } from 'react-i18next'; import { alpha, useTheme } from '@mui/material/styles'; export default function CreateBlindTestDialog({ open, onClose, projectId, onCreate }) { const { t } = useTranslation(); const theme = useTheme(); // 模型选择 const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelA, setModelA] = useState(null); const [modelB, setModelB] = useState(null); // 题目选择 const [questionTypes, setQuestionTypes] = useState(['short_answer', 'open_ended']); const [selectedTags, setSelectedTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [questionCount, setQuestionCount] = useState(0); const [filteredCount, setFilteredCount] = useState(0); const [countLoading, setCountLoading] = useState(false); // 提交状态 const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); // 加载模型列表 useEffect(() => { if (!open || !projectId) return; const fetchModels = async () => { try { setModelsLoading(true); const response = await fetch(`/api/projects/${projectId}/model-config`); const result = await response.json(); if (result.data) { setModels(result.data); } } catch (err) { console.error('加载模型失败:', err); } finally { setModelsLoading(false); } }; fetchModels(); }, [open, projectId]); // 加载标签和题目数量 useEffect(() => { if (!open || !projectId) return; const fetchStats = async () => { try { const response = await fetch(`/api/projects/${projectId}/eval-datasets?page=1&pageSize=1&includeStats=true`); const result = await response.json(); if (result.stats?.byTag) { setAvailableTags(Object.keys(result.stats.byTag).sort()); } } catch (err) { console.error('加载统计失败:', err); } }; fetchStats(); }, [open, projectId]); // 获取符合条件的题目数量 const fetchFilteredCount = useCallback(async () => { if (!projectId) return; try { setCountLoading(true); const params = new URLSearchParams(); // 只查询主观题 questionTypes.forEach(t => params.append('questionTypes', t)); selectedTags.forEach(t => params.append('tags', t)); const response = await fetch(`/api/projects/${projectId}/eval-datasets/count?${params.toString()}`); const result = await response.json(); if (result.code === 0) { setFilteredCount(result.data?.total || 0); } } catch (err) { console.error('获取题目数量失败:', err); } finally { setCountLoading(false); } }, [projectId, questionTypes, selectedTags]); useEffect(() => { if (open) { fetchFilteredCount(); } }, [open, fetchFilteredCount]); // 重置表单 const resetForm = () => { setModelA(null); setModelB(null); setQuestionTypes(['short_answer', 'open_ended']); setSelectedTags([]); setQuestionCount(0); setError(''); }; // 关闭对话框 const handleClose = () => { if (submitting) return; resetForm(); onClose(); }; // 提交创建 const handleSubmit = async () => { // 验证 if (!modelA) { setError(t('blindTest.errorSelectModelA', '请选择模型A')); return; } if (!modelB) { setError(t('blindTest.errorSelectModelB', '请选择模型B')); return; } if (modelA.id === modelB.id) { setError(t('blindTest.errorSameModel', '两个模型不能相同')); return; } if (filteredCount === 0) { setError(t('blindTest.errorNoQuestions', '没有符合条件的题目')); return; } try { setSubmitting(true); setError(''); // 获取题目ID列表 const params = new URLSearchParams(); questionTypes.forEach(t => params.append('questionTypes', t)); selectedTags.forEach(t => params.append('tags', t)); const pageSize = questionCount > 0 ? questionCount : filteredCount; params.append('pageSize', pageSize.toString()); const response = await fetch(`/api/projects/${projectId}/eval-datasets?${params.toString()}`); const result = await response.json(); if (!result.items || result.items.length === 0) { setError(t('blindTest.errorNoQuestions', '没有符合条件的题目')); return; } // 随机选择题目(如果指定了数量) let selectedIds = result.items.map(item => item.id); if (questionCount > 0 && questionCount < selectedIds.length) { // 随机抽取 selectedIds = selectedIds.sort(() => Math.random() - 0.5).slice(0, questionCount); } // 创建任务 const createResult = await onCreate({ modelA: { modelId: modelA.modelId, providerId: modelA.providerId, id: modelA.id }, modelB: { modelId: modelB.modelId, providerId: modelB.providerId, id: modelB.id }, evalDatasetIds: selectedIds }); if (createResult.success) { handleClose(); } else { setError(createResult.error || '创建失败'); } } catch (err) { console.error('创建任务失败:', err); setError('创建任务失败'); } finally { setSubmitting(false); } }; const QUESTION_TYPES = [ { value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' }, { value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' } ]; return ( {t('blindTest.createTitle', '创建盲测任务')} {error && ( setError('')}> {error} )} {/* 模型选择 */} {t('blindTest.selectModels', '选择对比模型')} {/* 模型A */} A {t('blindTest.modelA', '模型 A')} VS {/* 模型B */} B {t('blindTest.modelB', '模型 B')} {models.length === 0 && !modelsLoading && ( {t('blindTest.noModelsAvailable', '暂无可用模型,请先在设置中配置模型')} )} {/* 题目筛选 */} {t('blindTest.selectQuestions', '选择测试题目')} {t('blindTest.questionTypeHint', '盲测任务仅支持简答题和开放题')} {/* 题型筛选 */} {t('blindTest.questionType', '题型')} {/* 标签筛选 */} {availableTags.length > 0 && ( {t('blindTest.filterByTag', '按标签筛选')} )} {/* 题目数量 */} setQuestionCount(Math.max(0, parseInt(e.target.value) || 0))} inputProps={{ min: 0, max: filteredCount }} sx={{ width: 150, bgcolor: 'background.paper' }} /> {countLoading ? ( ) : ( t('blindTest.availableQuestions', '可用题目:{{count}} 道', { count: filteredCount }) )} {filteredCount > 0 && ( )} {questionCount === 0 ? t('blindTest.useAllQuestions', '使用全部筛选结果') : t('blindTest.randomSample', '将随机抽取 {{count}} 道题目', { count: questionCount })} ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/components/ResultDetailList.js ================================================ import { Box, Paper, Typography, Chip, Collapse, IconButton, Avatar, Divider, Grid } from '@mui/material'; import ReactMarkdown from 'react-markdown'; import { useTranslation } from 'react-i18next'; import { useTheme, alpha } from '@mui/material/styles'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import PsychologyIcon from '@mui/icons-material/Psychology'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CancelIcon from '@mui/icons-material/Cancel'; import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'; import HelpIcon from '@mui/icons-material/Help'; import { useState } from 'react'; import 'github-markdown-css/github-markdown-light.css'; // 解析包含 标签的内容 const parseAnswerContent = text => { if (!text) return { thinking: '', content: '' }; // 匹配 ... 内容 const thinkMatch = text.match(/([\s\S]*?)<\/think>/); if (thinkMatch) { return { thinking: thinkMatch[1].trim(), content: text.replace(/[\s\S]*?<\/think>/, '').trim() }; } return { thinking: '', content: text }; }; function ResultAnswerSection({ title, rawContent, isWinner, modelLabel, t, theme }) { const { thinking, content } = parseAnswerContent(rawContent); const [showThinking, setShowThinking] = useState(false); const isLeft = modelLabel.includes('A') || title.includes('左'); const avatarColor = isLeft ? 'primary.main' : 'secondary.main'; return ( {modelLabel} {title} {isWinner && ( } label={t('blindTest.winner', '胜出')} size="small" color={isLeft ? 'primary' : 'secondary'} sx={{ fontWeight: 600 }} /> )} {/* 思维链展示 */} {thinking && ( setShowThinking(!showThinking)} > {t('playground.reasoningProcess', '推理过程')} {showThinking ? ( ) : ( )} {thinking} )} {/* 正文内容 */}
{content || '-'}
); } function ResultItem({ result, index, task, question }) { const { t } = useTranslation(); const theme = useTheme(); const [expanded, setExpanded] = useState(false); // Determine vote icon and color let VoteIcon = HelpIcon; let voteColor = 'default'; let voteLabel = ''; switch (result.vote) { case 'left': VoteIcon = CheckCircleIcon; voteColor = 'primary'; voteLabel = t('blindTest.leftBetter', '左边更好'); break; case 'right': VoteIcon = CheckCircleIcon; voteColor = 'secondary'; voteLabel = t('blindTest.rightBetter', '右边更好'); break; case 'both_good': VoteIcon = CheckCircleIcon; voteColor = 'success'; voteLabel = t('blindTest.bothGood', '都好'); break; case 'both_bad': VoteIcon = CancelIcon; voteColor = 'error'; voteLabel = t('blindTest.bothBad', '都不好'); break; default: VoteIcon = RemoveCircleIcon; voteLabel = t('blindTest.ties', '平局'); } // Determine Model labels based on swap status const leftModelName = result.isSwapped ? task.modelInfo?.modelB?.modelName : task.modelInfo?.modelA?.modelName; const rightModelName = result.isSwapped ? task.modelInfo?.modelA?.modelName : task.modelInfo?.modelB?.modelName; const leftModelLabel = result.isSwapped ? 'B' : 'A'; const rightModelLabel = result.isSwapped ? 'A' : 'B'; return ( {/* 头部摘要 */} setExpanded(!expanded)} > #{index + 1} {question?.question || result.questionId} } label={voteLabel} color={voteColor === 'default' ? 'default' : voteColor} variant={result.vote === 'both_good' || result.vote === 'both_bad' ? 'outlined' : 'filled'} sx={{ fontWeight: 600 }} /> {/* 展开详情 */} QUESTION {question?.question} {/* 左侧详情 */} {/* 右侧详情 */} ); } export default function ResultDetailList({ task }) { const { t } = useTranslation(); return ( {t('blindTest.detailResults', '详细结果')} {task.detail?.results?.map((result, index) => { const question = task.evalDatasets?.find(q => q.id === result.questionId); return ; })} ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/components/ResultSummary.js ================================================ import { Box, Paper, Typography, Card, CardContent, Chip, Grid, Avatar } from '@mui/material'; import { useTheme, alpha } from '@mui/material/styles'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import { useTranslation } from 'react-i18next'; import { blindTestStyles } from '@/styles/blindTest'; export default function ResultSummary({ stats, modelInfo }) { const { t } = useTranslation(); const theme = useTheme(); if (!stats) return null; const totalScore = stats.modelAScore + stats.modelBScore; const modelAPercent = totalScore > 0 ? (stats.modelAScore / totalScore) * 100 : 50; const modelBPercent = totalScore > 0 ? (stats.modelBScore / totalScore) * 100 : 50; const winner = stats.modelAScore > stats.modelBScore ? 'A' : stats.modelBScore > stats.modelAScore ? 'B' : 'tie'; return ( {t('blindTest.resultSummary', '评测结果汇总')} {/* Model A */} {winner === 'A' && ( WINNER )} A {modelInfo?.modelA?.modelName || 'Model A'} {stats.modelAScore.toFixed(1)} {t('blindTest.wins', '胜出')}: {stats.modelAWins} {/* VS / Progress */} VS {/* Model B */} {winner === 'B' && ( WINNER )} B {modelInfo?.modelB?.modelName || 'Model B'} {stats.modelBScore.toFixed(1)} {t('blindTest.wins', '胜出')}: {stats.modelBWins} {/* 底部统计条 */} {stats.totalQuestions} {t('blindTest.totalQuestions', '总题数')} {stats.bothGood} {t('blindTest.bothGood', '都好')} {stats.bothBad} {t('blindTest.bothBad', '都不好')} {stats.ties} {t('blindTest.ties', '平局')} ); } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/hooks/useBlindTestDetail.js ================================================ 'use client'; import { useState, useCallback, useEffect, useRef } from 'react'; /** * 盲测任务详情和盲测过程管理 Hook */ export default function useBlindTestDetail(projectId, taskId) { // 任务详情 const [task, setTask] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); // 当前题目状态 const [currentQuestion, setCurrentQuestion] = useState(null); const [leftAnswer, setLeftAnswer] = useState(null); const [rightAnswer, setRightAnswer] = useState(null); const [isSwapped, setIsSwapped] = useState(false); const [answersLoading, setAnswersLoading] = useState(false); // 流式输出状态 const [streamingA, setStreamingA] = useState(false); const [streamingB, setStreamingB] = useState(false); const abortControllerRef = useRef(null); const hasAutoLoadedRef = useRef(false); // 投票状态 const [voting, setVoting] = useState(false); const [completed, setCompleted] = useState(false); // 加载任务详情 const loadTask = useCallback( async (silent = false) => { if (!projectId || !taskId) return; try { if (!silent) setLoading(true); setError(''); // 添加时间戳防止缓存 const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}?t=${Date.now()}`, { cache: 'no-store', headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache' } }); const result = await response.json(); if (result.code === 0) { console.log('任务状态更新:', result.data.completedCount, '/', result.data.totalCount); setTask(result.data); // 检查任务是否已完成 (0=进行中, 1=已完成, 2=失败, 3=已中断) if (result.data.status !== 0) { setCompleted(true); } } else { if (!silent) setError(result.error || '加载任务详情失败'); } } catch (err) { console.error('加载任务详情失败:', err); if (!silent) setError('加载任务详情失败'); } finally { if (!silent) setLoading(false); } }, [projectId, taskId] ); // 流式获取当前题目和模型回答 const fetchCurrentQuestion = useCallback(async () => { if (!projectId || !taskId) return; // 取消上一次的请求 if (abortControllerRef.current) { abortControllerRef.current.abort(); } const controller = new AbortController(); abortControllerRef.current = controller; try { setAnswersLoading(true); setError(''); setCurrentQuestion(null); setLeftAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null }); setRightAnswer({ fullContent: '', content: '', thinking: '', isThinking: false, duration: 0, error: null }); // 1. 先获取题目信息 const questionRes = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/question`, { signal: controller.signal, cache: 'no-store' }); if (!questionRes.ok) throw new Error('获取题目失败'); const questionData = await questionRes.json(); if (questionData.completed) { setCompleted(true); return; } setCurrentQuestion({ id: questionData.questionId, question: questionData.question, answer: questionData.answer, index: questionData.questionIndex, total: questionData.totalQuestions }); setIsSwapped(questionData.isSwapped); setCompleted(false); // 2. 并行调用两个模型的流式接口 setStreamingA(true); setStreamingB(true); const processStream = async (modelType, setAnswer, setStreaming) => { const modelStartTime = Date.now(); try { const streamUrl = `/api/projects/${projectId}/blind-test-tasks/${taskId}/stream-model?model=${modelType}`; const response = await fetch(streamUrl, { signal: controller.signal }); if (!response.ok) { throw new Error(`模型${modelType}调用失败: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullContent = ''; let currentContent = ''; let currentThinking = ''; let isInThinking = false; let pendingBuffer = ''; // 用于处理跨 chunk 的标签识别 while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); pendingBuffer += chunk; // 处理缓冲区中的内容 while (pendingBuffer.length > 0) { // 如果正在思考中,寻找结束标签 if (isInThinking) { const endTagIndex = pendingBuffer.indexOf('
'); if (endTagIndex !== -1) { const thinkingPart = pendingBuffer.substring(0, endTagIndex); currentThinking += thinkingPart; fullContent += thinkingPart + '
'; isInThinking = false; pendingBuffer = pendingBuffer.substring(endTagIndex + 8); continue; } else { // 没有找到结束标签,但可能缓冲区末尾包含了部分结束标签 // 保留最后 7 个字符("
" 长度为 8)以防被截断 const safeLength = Math.max(0, pendingBuffer.length - 7); const processingPart = pendingBuffer.substring(0, safeLength); currentThinking += processingPart; fullContent += processingPart; pendingBuffer = pendingBuffer.substring(safeLength); break; // 等待下一个 chunk } } else { // 不在思考中,寻找开始标签 const startTagIndex = pendingBuffer.indexOf(''); if (startTagIndex !== -1) { const contentPart = pendingBuffer.substring(0, startTagIndex); currentContent += contentPart; fullContent += contentPart + ''; isInThinking = true; pendingBuffer = pendingBuffer.substring(startTagIndex + 7); continue; } else { // 没有找到开始标签,保留最后 6 个字符以防开始标签被截断 const safeLength = Math.max(0, pendingBuffer.length - 6); const processingPart = pendingBuffer.substring(0, safeLength); currentContent += processingPart; fullContent += processingPart; pendingBuffer = pendingBuffer.substring(safeLength); break; // 等待下一个 chunk } } } setAnswer(prev => ({ ...prev, fullContent, content: currentContent, thinking: currentThinking, isThinking: isInThinking })); } const modelDuration = Date.now() - modelStartTime; setAnswer(prev => ({ ...prev, duration: modelDuration })); setStreaming(false); } catch (err) { if (err.name === 'AbortError') return; console.error(`模型${modelType}错误:`, err); const modelDuration = Date.now() - modelStartTime; setAnswer(prev => ({ ...prev, error: err.message, duration: modelDuration })); setStreaming(false); } }; // 根据是否交换决定左右对应的模型 const leftModel = questionData.isSwapped ? 'B' : 'A'; const rightModel = questionData.isSwapped ? 'A' : 'B'; await Promise.all([ processStream(leftModel, setLeftAnswer, setStreamingA), processStream(rightModel, setRightAnswer, setStreamingB) ]); } catch (err) { if (err.name === 'AbortError') return; console.error('获取题目失败:', err); setError(err.message || '获取当前题目失败'); setStreamingA(false); setStreamingB(false); } finally { // 只有当前请求未被取消时才重置loading if (abortControllerRef.current === controller) { setAnswersLoading(false); } } }, [projectId, taskId]); // 提交投票 const submitVote = useCallback( async vote => { if (!projectId || !taskId || !currentQuestion) return { success: false }; try { setVoting(true); setError(''); const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vote, questionId: currentQuestion.id, isSwapped, // 使用 fullContent 提交,包含思考过程 leftAnswer: leftAnswer?.fullContent || leftAnswer?.content || '', rightAnswer: rightAnswer?.fullContent || rightAnswer?.content || '' }) }); const result = await response.json(); if (result.code === 0) { // 等待任务状态更新(进度条) await loadTask(true); if (result.data.isCompleted) { setCompleted(true); } else { // 获取下一题 await fetchCurrentQuestion(); } return { success: true, data: result.data }; } else { setError(result.error || '提交投票失败'); return { success: false, error: result.error }; } } catch (err) { console.error('提交投票失败:', err); setError('提交投票失败'); return { success: false, error: '提交投票失败' }; } finally { setVoting(false); } }, [projectId, taskId, currentQuestion, isSwapped, leftAnswer, rightAnswer, loadTask, fetchCurrentQuestion] ); // 中断任务 const interruptTask = useCallback(async () => { if (!projectId || !taskId) return false; try { const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'interrupt' }) }); const result = await response.json(); if (result.code === 0) { setCompleted(true); loadTask(); return true; } else { setError(result.error || '中断任务失败'); return false; } } catch (err) { console.error('中断任务失败:', err); setError('中断任务失败'); return false; } }, [projectId, taskId, loadTask]); // 初始加载 useEffect(() => { loadTask(); }, [loadTask]); // 任务加载完成后,如果任务进行中,自动获取当前题目(只执行一次) useEffect(() => { if (task && task.status === 0 && !completed && !hasAutoLoadedRef.current && projectId && taskId) { hasAutoLoadedRef.current = true; fetchCurrentQuestion(); } }, [task, completed, projectId, taskId, fetchCurrentQuestion]); // 计算结果统计 const getResultStats = useCallback(() => { if (!task?.detail?.results) return null; const results = task.detail.results; const totalModelAScore = results.reduce((sum, r) => sum + (r.modelAScore || 0), 0); const totalModelBScore = results.reduce((sum, r) => sum + (r.modelBScore || 0), 0); const leftWins = results.filter(r => r.vote === 'left').length; const rightWins = results.filter(r => r.vote === 'right').length; const bothGood = results.filter(r => r.vote === 'both_good').length; const bothBad = results.filter(r => r.vote === 'both_bad').length; // 计算实际模型胜出次数(需要考虑 swap) const modelAWins = results.filter(r => { if (r.vote === 'left' && !r.isSwapped) return true; if (r.vote === 'right' && r.isSwapped) return true; return false; }).length; const modelBWins = results.filter(r => { if (r.vote === 'left' && r.isSwapped) return true; if (r.vote === 'right' && !r.isSwapped) return true; return false; }).length; return { totalQuestions: results.length, modelAScore: totalModelAScore, modelBScore: totalModelBScore, modelAWins, modelBWins, ties: bothGood + bothBad, bothGood, bothBad, leftWins, rightWins }; }, [task]); // 组件卸载时取消请求 useEffect(() => { return () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, []); return { // 任务详情 task, loading, error, setError, loadTask, // 当前题目状态 currentQuestion, leftAnswer, rightAnswer, answersLoading, // 流式状态 streamingA, streamingB, // 投票状态 voting, completed, // 操作 fetchCurrentQuestion, submitVote, interruptTask, // 结果统计 getResultStats }; } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/hooks/useBlindTestTasks.js ================================================ 'use client'; import { useState, useCallback, useEffect } from 'react'; /** * 盲测任务列表管理 Hook */ export default function useBlindTestTasks(projectId) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(6); const [total, setTotal] = useState(0); // 加载任务列表 const loadTasks = useCallback( async (isRefresh = false) => { if (!projectId) return; try { if (!isRefresh) setLoading(true); setError(''); const response = await fetch(`/api/projects/${projectId}/blind-test-tasks?page=${page}&pageSize=${pageSize}`); const result = await response.json(); if (result.code === 0) { setTasks(result.data.items || []); setTotal(result.data.total || 0); } else { setError(result.error || '加载失败'); } } catch (err) { console.error('加载盲测任务失败:', err); setError('加载失败'); } finally { if (!isRefresh) setLoading(false); } }, [projectId, page, pageSize] ); // 初始加载和分页变化加载 useEffect(() => { loadTasks(); }, [loadTasks]); // 删除任务 const deleteTask = useCallback( async taskId => { try { const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, { method: 'DELETE' }); const result = await response.json(); if (result.code === 0) { loadTasks(); return true; } else { setError(result.error || '删除失败'); return false; } } catch (err) { console.error('删除任务失败:', err); setError('删除失败'); return false; } }, [projectId, loadTasks] ); // 中断任务 const interruptTask = useCallback( async taskId => { try { const response = await fetch(`/api/projects/${projectId}/blind-test-tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'interrupt' }) }); const result = await response.json(); if (result.code === 0) { loadTasks(); return true; } else { setError(result.error || '中断失败'); return false; } } catch (err) { console.error('中断任务失败:', err); setError('中断失败'); return false; } }, [projectId, loadTasks] ); // 创建任务 const createTask = useCallback( async taskData => { try { const response = await fetch(`/api/projects/${projectId}/blind-test-tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(taskData) }); const result = await response.json(); if (result.code === 0) { loadTasks(); return { success: true, data: result.data }; } else { return { success: false, error: result.error || '创建失败' }; } } catch (err) { console.error('创建任务失败:', err); return { success: false, error: '创建失败' }; } }, [projectId, loadTasks] ); return { tasks, loading, error, setError, loadTasks, deleteTask, interruptTask, createTask, page, setPage, pageSize, setPageSize, total }; } ================================================ FILE: app/projects/[projectId]/blind-test-tasks/page.js ================================================ 'use client'; import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { Box, Container, Typography, Button, Grid, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, TablePagination } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; import { useTranslation } from 'react-i18next'; import useBlindTestTasks from './hooks/useBlindTestTasks'; import BlindTestTaskCard from './components/BlindTestTaskCard'; import CreateBlindTestDialog from './components/CreateBlindTestDialog'; export default function BlindTestTasksPage() { const { projectId } = useParams(); const router = useRouter(); const { t } = useTranslation(); const { tasks, loading, error, setError, deleteTask, interruptTask, createTask, page, setPage, pageSize, setPageSize, total } = useBlindTestTasks(projectId); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null }); const [interruptDialog, setInterruptDialog] = useState({ open: false, task: null }); const handleView = task => router.push(`/projects/${projectId}/blind-test-tasks/${task.id}`); const handleContinue = task => router.push(`/projects/${projectId}/blind-test-tasks/${task.id}`); const handleDelete = task => setDeleteDialog({ open: true, task }); const handleInterrupt = task => setInterruptDialog({ open: true, task }); const handlePageChange = (event, newPage) => { setPage(newPage + 1); }; const handlePageSizeChange = event => { setPageSize(parseInt(event.target.value, 10)); setPage(1); }; const confirmDelete = async () => { if (deleteDialog.task) { await deleteTask(deleteDialog.task.id); } setDeleteDialog({ open: false, task: null }); }; const confirmInterrupt = async () => { if (interruptDialog.task) { await interruptTask(interruptDialog.task.id); } setInterruptDialog({ open: false, task: null }); }; const handleCreate = async taskData => { const result = await createTask(taskData); if (result.success) { // 创建成功后跳转到任务详情页开始盲测 router.push(`/projects/${projectId}/blind-test-tasks/${result.data.id}`); } return result; }; return ( {/* 页面标题 */} {t('blindTest.title', '人工盲测任务')} {/* 错误提示 */} {error && ( setError('')}> {error} )} {/* 加载状态 */} {loading && ( )} {/* 空状态 */} {!loading && tasks.length === 0 && ( {t('blindTest.noTasks', '暂无盲测任务')} {t('blindTest.noTasksHint', '创建盲测任务来对比两个模型的回答质量')} )} {/* 任务列表 */} {!loading && tasks.length > 0 && ( <> {tasks.map(task => ( ))} )} {/* 创建对话框 */} setCreateDialogOpen(false)} projectId={projectId} onCreate={handleCreate} /> {/* 删除确认对话框 */} setDeleteDialog({ open: false, task: null })}> {t('blindTest.deleteConfirmTitle', '确认删除')} {t('blindTest.deleteConfirmMessage', '确定要删除这个盲测任务吗?此操作不可撤销。')} {/* 中断确认对话框 */} setInterruptDialog({ open: false, task: null })}> {t('blindTest.interruptConfirmTitle', '确认中断')} {t('blindTest.interruptConfirmMessage', '确定要中断这个盲测任务吗?已完成的评判结果将保留。')} ); } ================================================ FILE: app/projects/[projectId]/datasets/[datasetId]/page.js ================================================ 'use client'; import { Container, Box, Typography, Alert, Snackbar, Paper } from '@mui/material'; import { useEffect } from 'react'; import ChunkViewDialog from '@/components/text-split/ChunkViewDialog'; import DatasetHeader from '@/components/datasets/DatasetHeader'; import DatasetMetadata from '@/components/datasets/DatasetMetadata'; import EditableField from '@/components/datasets/EditableField'; import OptimizeDialog from '@/components/datasets/OptimizeDialog'; import DatasetRatingSection from '@/components/datasets/DatasetRatingSection'; import useDatasetDetails from '@/app/projects/[projectId]/datasets/[datasetId]/useDatasetDetails'; import { useTranslation } from 'react-i18next'; /** * 数据集详情页面 */ export default function DatasetDetailsPage({ params }) { const { projectId, datasetId } = params; const { t } = useTranslation(); // 使用自定义Hook管理状态和逻辑 const { currentDataset, loading, editingAnswer, editingCot, editingQuestion, answerValue, cotValue, questionValue, snackbar, confirming, unconfirming, optimizeDialog, viewDialogOpen, viewChunk, datasetsAllCount, datasetsConfirmCount, answerTokens, cotTokens, shortcutsEnabled, setShortcutsEnabled, setSnackbar, setAnswerValue, setCotValue, setQuestionValue, setEditingAnswer, setEditingCot, setEditingQuestion, handleNavigate, handleConfirm, handleUnconfirm, handleSave, handleDelete, handleOpenOptimizeDialog, handleCloseOptimizeDialog, handleOptimize, handleViewChunk, handleCloseViewDialog } = useDatasetDetails(projectId, datasetId); // 加载状态 if (loading) { return ( {t('datasets.loadingDataset')} ); } // 无数据状态 if (!currentDataset) { return ( {t('datasets.datasetNotFound')} ); } return ( {/* 顶部导航栏 */} {/* 主要布局:左右分栏 */} {/* 左侧主要内容区域 */} setEditingQuestion(true)} onChange={e => setQuestionValue(e.target.value)} onSave={() => handleSave('question', questionValue)} dataset={currentDataset} onCancel={() => { setEditingQuestion(false); setQuestionValue(currentDataset.question); }} /> setEditingAnswer(true)} onChange={e => setAnswerValue(e.target.value)} onSave={() => handleSave('answer', answerValue)} onCancel={() => { setEditingAnswer(false); setAnswerValue(currentDataset.answer); }} dataset={currentDataset} onOptimize={handleOpenOptimizeDialog} tokenCount={answerTokens} optimizing={optimizeDialog.loading} /> setEditingCot(true)} onChange={e => setCotValue(e.target.value)} onSave={() => handleSave('cot', cotValue)} dataset={currentDataset} onCancel={() => { setEditingCot(false); setCotValue(currentDataset.cot || ''); }} tokenCount={cotTokens} /> {/* 右侧固定侧边栏 */} {/* 数据集元数据信息 */} {/* 评分、标签、备注区域 */} { // 更新成功后刷新数据,保持页面状态同步 // 这里可以调用 useDatasetDetails 的刷新逻辑 }} currentDataset={currentDataset} /> {/* 消息提示 */} setSnackbar(prev => ({ ...prev, open: false }))} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbar(prev => ({ ...prev, open: false }))} severity={snackbar.severity} sx={{ width: '100%' }} > {snackbar.message} {/* AI优化对话框 */} {/* 文本块详情对话框 */} ); } ================================================ FILE: app/projects/[projectId]/datasets/[datasetId]/useDatasetDetails.js ================================================ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; import axios from 'axios'; import { toast } from 'sonner'; import i18n from '@/lib/i18n'; /** * 数据集详情页面业务逻辑 Hook */ export default function useDatasetDetails(projectId, datasetId) { const router = useRouter(); const [datasets, setDatasets] = useState([]); const [currentDataset, setCurrentDataset] = useState(null); const [loading, setLoading] = useState(true); const [editingAnswer, setEditingAnswer] = useState(false); const [editingCot, setEditingCot] = useState(false); const [editingQuestion, setEditingQuestion] = useState(false); const [answerValue, setAnswerValue] = useState(''); const [cotValue, setCotValue] = useState(''); const [questionValue, setQuestionValue] = useState(''); const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }); const [confirming, setConfirming] = useState(false); const [unconfirming, setUnconfirming] = useState(false); const [optimizeDialog, setOptimizeDialog] = useState({ open: false, loading: false }); const [viewDialogOpen, setViewDialogOpen] = useState(false); const [viewChunk, setViewChunk] = useState(null); const [datasetsAllCount, setDatasetsAllCount] = useState(0); const [datasetsConfirmCount, setDatasetsConfirmCount] = useState(0); const [answerTokens, setAnswerTokens] = useState(0); const [cotTokens, setCotTokens] = useState(0); const model = useAtomValue(selectedModelInfoAtom); const [shortcutsEnabled, setShortcutsEnabled] = useState(() => { const storedValue = localStorage.getItem('shortcutsEnabled'); return storedValue !== null ? storedValue === 'true' : false; }); // 输入环境判断,避免在输入框/可编辑区域误触快捷键 const isEditableTarget = el => { if (!el) return false; const tag = el.tagName?.toLowerCase(); if (tag && ['input', 'textarea', 'select'].includes(tag)) return true; if (el.isContentEditable) return true; // 兼容嵌套的可编辑区域与常见富文本编辑器 return !!el.closest?.('[contenteditable="true"], .ProseMirror, .ql-editor'); }; // 简单节流,避免连续触发 const lastShortcutRef = useRef(0); // 异步获取Token数量 const fetchTokenCount = async () => { try { const response = await fetch(`/api/projects/${projectId}/datasets/${datasetId}/token-count`); if (response.ok) { const data = await response.json(); if (data.answerTokens !== undefined) { setAnswerTokens(data.answerTokens); } if (data.cotTokens !== undefined) { setCotTokens(data.cotTokens); } } } catch (error) { console.error('获取Token数量失败:', error); // Token加载失败不阻塞主界面或显示错误提示 } }; // 获取数据集详情 const fetchDatasets = async () => { try { const response = await fetch(`/api/projects/${projectId}/datasets/${datasetId}`); if (!response.ok) throw new Error('获取数据集详情失败'); const data = await response.json(); setCurrentDataset(data.datasets); setCotValue(data.datasets?.cot); setAnswerValue(data.datasets?.answer); setQuestionValue(data.datasets?.question); setDatasetsAllCount(data.total); setDatasetsConfirmCount(data.confirmedCount); // 数据加载完成后,异步获取Token数量 fetchTokenCount(); } catch (error) { setSnackbar({ open: true, message: error.message, severity: 'error' }); } finally { setLoading(false); } }; // 确认并保存数据集 const handleConfirm = async () => { try { setConfirming(true); const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirmed: true }) }); if (!response.ok) { throw new Error('操作失败'); } setCurrentDataset(prev => ({ ...prev, confirmed: true })); setSnackbar({ open: true, message: '操作成功', severity: 'success' }); // 导航到下一个数据集 handleNavigate('next'); } catch (error) { setSnackbar({ open: true, message: error.message || '操作失败', severity: 'error' }); } finally { setConfirming(false); } }; // 取消确认数据集 const handleUnconfirm = async () => { try { setUnconfirming(true); const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirmed: false }) }); if (!response.ok) { throw new Error('操作失败'); } setCurrentDataset(prev => ({ ...prev, confirmed: false })); setSnackbar({ open: true, message: '已取消确认', severity: 'success' }); } catch (error) { setSnackbar({ open: true, message: error.message || '取消确认失败', severity: 'error' }); } finally { setUnconfirming(false); } }; // 导航到其他数据集 const handleNavigate = async direction => { const response = await axios.get(`/api/projects/${projectId}/datasets/${datasetId}?operateType=${direction}`); if (response.data) { router.push(`/projects/${projectId}/datasets/${response.data.id}`); } else { toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条数据了`); } }; // 保存编辑 const handleSave = async (field, value) => { try { const response = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: value }) }); if (!response.ok) { throw new Error('保存失败'); } const data = await response.json(); setCurrentDataset(prev => ({ ...prev, [field]: value })); setSnackbar({ open: true, message: '保存成功', severity: 'success' }); // 重置编辑状态 if (field === 'answer') setEditingAnswer(false); if (field === 'cot') setEditingCot(false); if (field === 'question') setEditingQuestion(false); } catch (error) { setSnackbar({ open: true, message: error.message || '保存失败', severity: 'error' }); } }; // 删除数据集 const handleDelete = async () => { if (!confirm('确定要删除这条数据吗?此操作不可撤销。')) return; try { // 尝试获取下一个数据集,在删除前先确保有可导航的目标 const nextResponse = await axios.get(`/api/projects/${projectId}/datasets/${datasetId}?operateType=next`); const hasNextDataset = !!nextResponse.data; const nextDatasetId = hasNextDataset ? nextResponse.data.id : null; // 删除当前数据集 const deleteResponse = await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, { method: 'DELETE' }); if (!deleteResponse.ok) { throw new Error('删除失败'); } // 导航逻辑:有下一个就跳转下一个,没有则返回列表页 if (hasNextDataset) { router.push(`/projects/${projectId}/datasets/${nextDatasetId}`); } else { // 没有更多数据集,返回列表页面 router.push(`/projects/${projectId}/datasets`); } toast.success('删除成功'); } catch (error) { setSnackbar({ open: true, message: error.message || '删除失败', severity: 'error' }); } }; // 优化对话框相关操作 const handleOpenOptimizeDialog = () => { setOptimizeDialog({ open: true, loading: false }); }; const handleCloseOptimizeDialog = () => { setOptimizeDialog(prev => { // 如果正在优化,不允许关闭 if (prev.loading) { return prev; } return { open: false, loading: false }; }); }; // 优化操作 const handleOptimize = async advice => { if (!model) { setSnackbar({ open: true, message: '请先选择模型,可以在顶部导航栏选择', severity: 'error' }); return; } // 立即关闭对话框,并设置优化中状态 setOptimizeDialog(prev => { const newState = { open: false, loading: true }; return newState; }); toast.info('已开始优化,请稍候...'); // 异步后台处理,不等待结果 (async () => { try { const language = i18n.language === 'zh-CN' ? '中文' : 'en'; const response = await fetch(`/api/projects/${projectId}/datasets/optimize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ datasetId, model, advice, language }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || '优化失败'); } // 优化成功后,重新查询数据以获取最新状态 await fetchDatasets(); // 优化可能改变了文本内容,重新获取Token计数 fetchTokenCount(); toast.success('AI智能优化成功'); } catch (error) { toast.error(error.message); } finally { setOptimizeDialog({ open: false, loading: false }); } })(); }; // 查看文本块详情 const handleViewChunk = async chunkContent => { try { setViewChunk(chunkContent); setViewDialogOpen(true); } catch (error) { console.error('查看文本块出错', error); setSnackbar({ open: true, message: error.message, severity: 'error' }); setViewDialogOpen(false); } }; // 关闭文本块详情对话框 const handleCloseViewDialog = () => { setViewDialogOpen(false); }; // 初始化和快捷键事件 useEffect(() => { fetchDatasets(); }, [projectId, datasetId]); // 快捷键状态变化 useEffect(() => { localStorage.setItem('shortcutsEnabled', shortcutsEnabled); }, [shortcutsEnabled]); // 监听键盘事件 useEffect(() => { const handleKeyDown = event => { if (!shortcutsEnabled) return; // 在输入框或可编辑区域时不触发 const activeEl = typeof document !== 'undefined' ? document.activeElement : null; if (isEditableTarget(event.target) || isEditableTarget(activeEl)) { return; } // 仅要求 Shift 修饰键,降低误触且更简单 if (!event.shiftKey) return; // 简单节流,过滤极短时间内重复触发 const now = Date.now(); if (now - (lastShortcutRef.current || 0) < 250) { return; } lastShortcutRef.current = now; switch (event.key) { case 'ArrowLeft': // 上一个(Shift + ArrowLeft) event.preventDefault(); handleNavigate('prev'); break; case 'ArrowRight': // 下一个(Shift + ArrowRight) event.preventDefault(); handleNavigate('next'); break; case 'y': // 确认(Shift + Y) case 'Y': if (!confirming && currentDataset && !currentDataset.confirmed) { event.preventDefault(); handleConfirm(); } break; case 'd': // 删除(Shift + D) case 'D': event.preventDefault(); handleDelete(); break; default: break; } }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [shortcutsEnabled, confirming, currentDataset]); return { loading, currentDataset, answerValue, cotValue, questionValue, editingAnswer, editingCot, editingQuestion, confirming, unconfirming, snackbar, optimizeDialog, viewDialogOpen, viewChunk, datasetsAllCount, datasetsConfirmCount, answerTokens, cotTokens, shortcutsEnabled, setShortcutsEnabled, setSnackbar, setAnswerValue, setCotValue, setQuestionValue, setEditingAnswer, setEditingCot, setEditingQuestion, handleNavigate, handleConfirm, handleUnconfirm, handleSave, handleDelete, handleOpenOptimizeDialog, handleCloseOptimizeDialog, handleOptimize, handleViewChunk, handleCloseViewDialog }; } ================================================ FILE: app/projects/[projectId]/datasets/components/ActionBar.js ================================================ 'use client'; import { Box, Button } from '@mui/material'; import AssessmentIcon from '@mui/icons-material/Assessment'; import FileUploadIcon from '@mui/icons-material/FileUpload'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { useTranslation } from 'react-i18next'; const ActionBar = ({ onBatchEvaluate, onImport, onExport, batchEvaluating = false }) => { const { t } = useTranslation(); return ( ); }; export default ActionBar; ================================================ FILE: app/projects/[projectId]/datasets/components/DatasetList.js ================================================ 'use client'; import { Box, Typography, IconButton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Divider, useTheme, alpha, Tooltip, Checkbox, TablePagination, TextField, Card, CircularProgress } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import VisibilityIcon from '@mui/icons-material/Visibility'; import AssessmentIcon from '@mui/icons-material/Assessment'; import StarIcon from '@mui/icons-material/Star'; import { useTranslation } from 'react-i18next'; import { getRatingConfigI18n, formatScore } from '@/components/datasets/utils/ratingUtils'; // 数据集列表组件 const DatasetList = ({ datasets, onViewDetails, onDelete, onEvaluate, page, rowsPerPage, onPageChange, onRowsPerPageChange, total, selectedIds, onSelectAll, onSelectItem, evaluatingIds = [], loading = false }) => { const theme = useTheme(); const { t } = useTranslation(); const bgColor = theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light; const color = theme.palette.mode === 'dark' ? theme.palette.getContrastText(theme.palette.primary.main) : theme.palette.getContrastText(theme.palette.primary.contrastText); const RatingChip = ({ score }) => { const config = getRatingConfigI18n(score, t); return ( } label={`${formatScore(score)} ${config.label}`} size="small" sx={{ backgroundColor: config.backgroundColor, color: config.color, fontWeight: 'medium', '& .MuiChip-icon': { color: config.color } }} /> ); }; return ( 0 && selectedIds.length < total} checked={total > 0 && selectedIds.length === total} onChange={onSelectAll} /> {t('datasets.question')} {t('datasets.rating', '评分')} {t('datasets.model')} {t('datasets.domainTag')} {t('datasets.createdAt')} {t('common.actions')} {datasets.map((dataset, index) => ( <> onViewDetails(dataset.id)} > { e.stopPropagation(); onSelectItem(dataset.id); }} onClick={e => e.stopPropagation()} /> {dataset.question} {dataset.confirmed && ( )} {dataset.questionLabel ? ( ) : ( {t('datasets.noTag')} )} {new Date(dataset.createAt).toLocaleDateString('zh-CN')} { e.stopPropagation(); onViewDetails(dataset.id); }} sx={{ color: theme.palette.primary.main, '&:hover': { backgroundColor: alpha(theme.palette.primary.main, 0.1) } }} > { e.stopPropagation(); onEvaluate && onEvaluate(dataset); }} sx={{ color: theme.palette.secondary.main, '&:hover': { backgroundColor: alpha(theme.palette.secondary.main, 0.1) } }} > {evaluatingIds.includes(dataset.id) ? ( ) : ( )} { e.stopPropagation(); onDelete(dataset); }} sx={{ color: theme.palette.error.main, '&:hover': { backgroundColor: alpha(theme.palette.error.main, 0.1) } }} > ))} {datasets.length === 0 && ( {t('datasets.noData')} )}
{loading && ( {t('datasets.loading')} )}
t('datasets.pagination', { from, to, count })} sx={{ '.MuiTablePagination-selectLabel, .MuiTablePagination-displayedRows': { fontWeight: 'medium' }, border: 'none' }} /> {t('common.jumpTo')}: { if (e.key === 'Enter') { const pageNum = parseInt(e.target.value, 10); if (pageNum >= 1 && pageNum <= Math.ceil(total / rowsPerPage)) { onPageChange(null, pageNum - 1); e.target.value = ''; } } }} />
); }; export default DatasetList; ================================================ FILE: app/projects/[projectId]/datasets/components/DeleteConfirmDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Typography, Paper, Box, LinearProgress, Button, useTheme, alpha } from '@mui/material'; import { useTranslation } from 'react-i18next'; const DeleteConfirmDialog = ({ open, datasets, onClose, onConfirm, batch, progress, deleting }) => { const theme = useTheme(); const { t } = useTranslation(); const dataset = datasets?.[0]; return ( {t('common.confirmDelete')} {batch ? t('datasets.batchconfirmDeleteMessage', { count: datasets.length }) : t('common.confirmDeleteDataSet')} {batch ? ( '' ) : ( {t('datasets.question')}: {dataset?.question} )} {deleting && progress ? ( {progress.percentage}% {t('datasets.deletingProgress', '正在删除 {{completed}}/{{total}} 个数据集...', { completed: progress.completed, total: progress.total })} ) : null} ); }; export default DeleteConfirmDialog; ================================================ FILE: app/projects/[projectId]/datasets/components/FilterDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Box, Typography, Select, MenuItem, Slider, TextField, Button, InputAdornment } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import { useTranslation } from 'react-i18next'; const FilterDialog = ({ open, onClose, filterConfirmed, filterHasCot, filterIsDistill, filterScoreRange, filterCustomTag, filterNoteKeyword, filterChunkName, availableTags, onFilterConfirmedChange, onFilterHasCotChange, onFilterIsDistillChange, onFilterScoreRangeChange, onFilterCustomTagChange, onFilterNoteKeywordChange, onFilterChunkNameChange, onResetFilters, onApplyFilters }) => { const { t } = useTranslation(); return ( {t('datasets.filtersTitle')} {t('datasets.filterConfirmationStatus')} {t('datasets.filterCotStatus')} {t('datasets.filterDistill')} {t('datasets.filterScoreRange')} onFilterScoreRangeChange(newValue)} valueLabelDisplay="auto" min={0} max={5} step={0.5} marks={[ { value: 0, label: '0' }, { value: 2.5, label: '2.5' }, { value: 5, label: '5' } ]} sx={{ mt: 1 }} /> {t('datasets.scoreRange', '{{min}} - {{max}} 分', { min: filterScoreRange[0], max: filterScoreRange[1] })} {t('datasets.filterCustomTag')} {t('datasets.filterNoteKeyword')} onFilterNoteKeywordChange(e.target.value)} placeholder={t('datasets.filterNoteKeywordPlaceholder')} fullWidth size="small" sx={{ mt: 1 }} InputProps={{ startAdornment: ( ) }} /> {t('datasets.filterChunkName')} onFilterChunkNameChange(e.target.value)} placeholder={t('datasets.filterChunkNamePlaceholder')} fullWidth size="small" sx={{ mt: 1 }} InputProps={{ startAdornment: ( ) }} /> ); }; export default FilterDialog; ================================================ FILE: app/projects/[projectId]/datasets/components/SearchBar.js ================================================ 'use client'; import { Box, Paper, IconButton, InputBase, Select, MenuItem, Button, Badge } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import FilterListIcon from '@mui/icons-material/FilterList'; import { useTranslation } from 'react-i18next'; const SearchBar = ({ searchQuery, searchField, onSearchQueryChange, onSearchFieldChange, onMoreFiltersClick, activeFilterCount = 0 }) => { const { t } = useTranslation(); return ( onSearchQueryChange(e.target.value)} endAdornment={ } /> ); }; export default SearchBar; ================================================ FILE: app/projects/[projectId]/datasets/hooks/useDatasetEvaluation.js ================================================ 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; /** * 数据集评估相关的自定义 Hook * 封装单个评估和批量评估的逻辑 */ const useDatasetEvaluation = (projectId, onEvaluationComplete) => { const router = useRouter(); const { t } = useTranslation(); const model = useAtomValue(selectedModelInfoAtom); // 评估状态管理 const [evaluatingIds, setEvaluatingIds] = useState([]); const [batchEvaluating, setBatchEvaluating] = useState(false); /** * 检查模型是否已配置 */ const checkModelConfiguration = () => { if (!model || !model.modelName) { toast.error(t('datasets.selectModelFirst', '请先选择模型')); return false; } return true; }; /** * 处理单个数据集评估 * @param {Object} dataset - 要评估的数据集对象 */ const handleEvaluateDataset = async dataset => { // 检查模型配置 if (!checkModelConfiguration()) { return; } try { // 添加到评估中的ID列表 setEvaluatingIds(prev => [...prev, dataset.id]); // 调用评估接口 const evaluateResponse = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}/evaluate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, language: 'zh-CN' }) }); const result = await evaluateResponse.json(); if (result.success) { toast.success( t('datasets.evaluateSuccess', '评估完成!评分:{{score}}/5', { score: result.data.score }) ); // 调用回调函数通知评估完成(通常用于刷新数据列表) if (onEvaluationComplete) { await onEvaluationComplete(); } } else { toast.error(result.message || t('datasets.evaluateFailed', '评估失败')); } } catch (error) { console.error('评估失败:', error); toast.error( t('datasets.evaluateError', '评估失败: {{error}}', { error: error.message }) ); } finally { // 从评估中的ID列表移除 setEvaluatingIds(prev => prev.filter(id => id !== dataset.id)); } }; /** * 处理批量评估 */ const handleBatchEvaluate = async () => { // 检查模型配置 if (!checkModelConfiguration()) { return; } try { setBatchEvaluating(true); // 调用批量评估接口 const response = await fetch(`/api/projects/${projectId}/datasets/batch-evaluate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, language: 'zh-CN' }) }); const result = await response.json(); if (result.success) { toast.success(t('datasets.batchEvaluateStarted', '批量评估任务已启动,将在后台进行处理')); // 跳转到任务页面查看进度 router.push(`/projects/${projectId}/tasks`); } else { toast.error(result.message || t('datasets.batchEvaluateStartFailed', '启动批量评估失败')); } } catch (error) { console.error('批量评估失败:', error); toast.error( t('datasets.batchEvaluateFailed', '批量评估失败: {{error}}', { error: error.message }) ); } finally { setBatchEvaluating(false); } }; /** * 检查指定数据集是否正在评估中 * @param {string} datasetId - 数据集ID * @returns {boolean} 是否正在评估中 */ const isEvaluating = datasetId => { return evaluatingIds.includes(datasetId); }; /** * 获取当前正在评估的数据集数量 * @returns {number} 正在评估的数据集数量 */ const getEvaluatingCount = () => { return evaluatingIds.length; }; return { // 状态 evaluatingIds, batchEvaluating, // 方法 handleEvaluateDataset, handleBatchEvaluate, // 工具方法 isEvaluating, getEvaluatingCount, // 模型信息(便于组件使用) model }; }; export default useDatasetEvaluation; ================================================ FILE: app/projects/[projectId]/datasets/hooks/useDatasetExport.js ================================================ 'use client'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import axios from 'axios'; const useDatasetExport = projectId => { const { t } = useTranslation(); // 优化的流式导出 - 使用 WritableStream 避免内存溢出 const exportDatasetsStreaming = async (exportOptions, onProgress) => { try { const batchSize = exportOptions.batchSize || 1000; let offset = 0; let hasMore = true; let totalProcessed = 0; let isFirstBatch = true; // 确定文件格式 const fileFormat = exportOptions.fileFormat || 'json'; const formatType = exportOptions.formatType || 'alpaca'; // 生成文件名 const formatSuffixMap = { alpaca: 'alpaca', multilingualthinking: 'multilingual-thinking', sharegpt: 'sharegpt', custom: 'custom' }; const formatSuffix = formatSuffixMap[formatType] || formatType || 'export'; const balanceSuffix = exportOptions.balanceMode ? '-balanced' : ''; const dateStr = new Date().toISOString().slice(0, 10); const fileName = `datasets-${projectId}-${formatSuffix}${balanceSuffix}-${dateStr}.${fileFormat}`; // 创建可写流 let fileStream; let writer; try { // 使用 showSaveFilePicker API(现代浏览器) if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: fileName, types: [ { description: 'Dataset File', accept: { 'application/json': [`.${fileFormat}`] } } ] }); fileStream = await handle.createWritable(); } else { // 降级方案:使用内存缓冲区(但分块处理) fileStream = null; } } catch (err) { // 用户取消或不支持,使用降级方案 fileStream = null; } // 如果不支持流式写入,使用分块累积方案 let chunks = []; let chunkCount = 0; const MAX_CHUNKS_IN_MEMORY = 5; // 最多在内存中保留5批数据 // 写入文件头(JSON数组开始或CSV表头) if (fileFormat === 'json') { if (fileStream) { await fileStream.write('[\n'); } else { chunks.push('[\n'); } } else if (fileFormat === 'csv') { // 写入CSV表头 const headers = getCSVHeaders(formatType, exportOptions); const headerLine = headers.join(',') + '\n'; if (fileStream) { await fileStream.write(headerLine); } else { chunks.push(headerLine); } } // 分批获取和写入数据 while (hasMore) { const apiUrl = `/api/projects/${projectId}/datasets/export`; const requestBody = { batchMode: true, offset: offset, batchSize: batchSize }; // 如果有选中的数据集 ID,传递 ID 列表 if (exportOptions.selectedIds && exportOptions.selectedIds.length > 0) { requestBody.selectedIds = exportOptions.selectedIds; } else if (exportOptions.confirmedOnly) { requestBody.status = 'confirmed'; } // 检查是否是平衡导出模式 if (exportOptions.balanceMode && exportOptions.balanceConfig) { requestBody.balanceMode = true; requestBody.balanceConfig = exportOptions.balanceConfig; } const response = await axios.post(apiUrl, requestBody); const batchResult = response.data; // 如果需要包含文本块内容,批量查询并填充 if (exportOptions.customFields?.includeChunk && batchResult.data.length > 0) { const chunkNames = batchResult.data.map(item => item.chunkName).filter(name => name); if (chunkNames.length > 0) { try { const chunkResponse = await axios.post(`/api/projects/${projectId}/chunks/batch-content`, { chunkNames }); const chunkContentMap = chunkResponse.data; batchResult.data.forEach(item => { if (item.chunkName && chunkContentMap[item.chunkName]) { item.chunkContent = chunkContentMap[item.chunkName]; } }); } catch (chunkError) { console.error('获取文本块内容失败:', chunkError); } } } // 转换当前批次数据 const formattedBatch = formatDataBatch(batchResult.data, exportOptions); // 写入当前批次 if (fileFormat === 'json') { // 保持与原逻辑一致:JSON 导出为“格式化后的 JSON 数组”(2空格缩进) // 每条记录单独 stringify + 缩进,并在数组级别拼接,避免一次性 stringify 全量数据导致内存暴涨 const batchContent = formattedBatch .map(item => { const pretty = JSON.stringify(item, null, 2); // 将对象的每一行整体再缩进 2 个空格,以符合数组元素缩进 return ' ' + pretty.replace(/\n/g, '\n '); }) .join(',\n'); const content = isFirstBatch ? batchContent : ',\n' + batchContent; if (fileStream) { await fileStream.write(content); } else { chunks.push(content); chunkCount++; } } else if (fileFormat === 'jsonl') { const batchContent = formattedBatch.map(item => JSON.stringify(item)).join('\n') + '\n'; if (fileStream) { await fileStream.write(batchContent); } else { chunks.push(batchContent); chunkCount++; } } else if (fileFormat === 'csv') { const batchContent = formatBatchToCSV(formattedBatch, formatType, exportOptions); if (fileStream) { await fileStream.write(batchContent); } else { chunks.push(batchContent); chunkCount++; } } // 如果使用内存缓冲且累积了足够多的块,触发部分下载 if (!fileStream && chunkCount >= MAX_CHUNKS_IN_MEMORY) { // 这里我们仍然需要等到最后才能下载,但至少限制了内存使用 // 可以考虑使用 Blob 分片 } hasMore = batchResult.hasMore; offset = batchResult.offset; totalProcessed += batchResult.data.length; isFirstBatch = false; // 通知进度更新 if (onProgress) { onProgress({ processed: totalProcessed, currentBatch: batchResult.data.length, hasMore }); } // 避免过快请求 if (hasMore) { await new Promise(resolve => setTimeout(resolve, 50)); } } // 写入文件尾 if (fileFormat === 'json') { if (fileStream) { await fileStream.write('\n]\n'); await fileStream.close(); } else { chunks.push('\n]\n'); } } else { if (fileStream) { await fileStream.close(); } } // 如果使用内存缓冲方案,现在触发下载 if (!fileStream) { downloadFromChunks(chunks, fileName); } toast.success(t('datasets.exportSuccess')); return true; } catch (error) { console.error('Streaming export failed:', error); toast.error(error.message || t('datasets.exportFailed')); return false; } }; // 从内存块下载文件(优化版本,使用 Blob 流) const downloadFromChunks = (chunks, fileName) => { // 使用 Blob 构造函数,它会自动处理大数据 const blob = new Blob(chunks, { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); // 延迟释放 URL,确保下载开始 setTimeout(() => URL.revokeObjectURL(url), 1000); }; // 获取CSV表头 const getCSVHeaders = (formatType, exportOptions) => { if (formatType === 'alpaca') { return ['instruction', 'input', 'output', 'system']; } else if (formatType === 'sharegpt') { return ['messages']; } else if (formatType === 'multilingualthinking') { return ['reasoning_language', 'developer', 'user', 'analysis', 'final', 'messages']; } else if (formatType === 'custom') { const { questionField, answerField, cotField, includeLabels, includeChunk, questionOnly } = exportOptions.customFields; const headers = [questionField]; if (!questionOnly) { headers.push(answerField); if (exportOptions.includeCOT && cotField) { headers.push(cotField); } } if (includeLabels) headers.push('label'); if (includeChunk) headers.push('chunk'); return headers; } return []; }; // 格式化数据批次 const formatDataBatch = (dataBatch, exportOptions) => { const formatType = exportOptions.formatType || 'alpaca'; if (formatType === 'alpaca') { if (exportOptions.alpacaFieldType === 'instruction') { return dataBatch.map(({ question, answer, cot }) => ({ instruction: question, input: '', output: cot && exportOptions.includeCOT ? `${cot}\n${answer}` : answer, system: exportOptions.systemPrompt || '' })); } else { return dataBatch.map(({ question, answer, cot }) => ({ instruction: exportOptions.customInstruction || '', input: question, output: cot && exportOptions.includeCOT ? `${cot}\n${answer}` : answer, system: exportOptions.systemPrompt || '' })); } } else if (formatType === 'sharegpt') { return dataBatch.map(({ question, answer, cot }) => { const messages = []; if (exportOptions.systemPrompt) { messages.push({ role: 'system', content: exportOptions.systemPrompt }); } messages.push({ role: 'user', content: question }); messages.push({ role: 'assistant', content: cot && exportOptions.includeCOT ? `${cot}\n${answer}` : answer }); return { messages }; }); } else if (formatType === 'multilingualthinking') { return dataBatch.map(({ question, answer, cot }) => ({ reasoning_language: exportOptions.reasoningLanguage || 'English', developer: exportOptions.systemPrompt || '', user: question, analysis: exportOptions.includeCOT && cot ? cot : null, final: answer, messages: [ { content: exportOptions.systemPrompt || '', role: 'system', thinking: null }, { content: question, role: 'user', thinking: null }, { content: answer, role: 'assistant', thinking: exportOptions.includeCOT && cot ? cot : null } ] })); } else if (formatType === 'custom') { const { questionField, answerField, cotField, includeLabels, includeChunk, questionOnly } = exportOptions.customFields; return dataBatch.map(({ question, answer, cot, questionLabel: labels, chunkContent }) => { const item = { [questionField]: question }; if (!questionOnly) { item[answerField] = answer; if (cot && exportOptions.includeCOT && cotField) { item[cotField] = cot; } } if (includeLabels && labels && labels.length > 0) { item.label = labels.split(' ')[1]; } if (includeChunk && chunkContent) { item.chunk = chunkContent; } return item; }); } return dataBatch; }; // 将批次格式化为CSV行 const formatBatchToCSV = (formattedBatch, formatType, exportOptions) => { const headers = getCSVHeaders(formatType, exportOptions); return ( formattedBatch .map(item => { return headers .map(header => { let field = item[header]?.toString() || ''; // 对于复杂对象,转换为JSON字符串 if (typeof item[header] === 'object') { field = JSON.stringify(item[header]); } // CSV转义 if (field.includes(',') || field.includes('\n') || field.includes('"')) { field = `"${field.replace(/"/g, '""')}"`; } return field; }) .join(','); }) .join('\n') + '\n' ); }; // 处理和下载数据的通用函数(保留用于小数据量) const processAndDownloadData = async (dataToExport, exportOptions) => { const formattedData = formatDataBatch(dataToExport, exportOptions); let content; let fileExtension; const fileFormat = exportOptions.fileFormat || 'json'; if (fileFormat === 'jsonl') { content = formattedData.map(item => JSON.stringify(item)).join('\n'); fileExtension = 'jsonl'; } else if (fileFormat === 'csv') { const headers = getCSVHeaders(exportOptions.formatType, exportOptions); const csvRows = [ headers.join(','), ...formattedData.map(item => headers .map(header => { let field = item[header]?.toString() || ''; if (typeof item[header] === 'object') { field = JSON.stringify(item[header]); } if (field.includes(',') || field.includes('\n') || field.includes('"')) { field = `"${field.replace(/"/g, '""')}"`; } return field; }) .join(',') ) ]; content = csvRows.join('\n'); fileExtension = 'csv'; } else { content = JSON.stringify(formattedData, null, 2); fileExtension = 'json'; } const blob = new Blob([content], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const formatSuffixMap = { alpaca: 'alpaca', multilingualthinking: 'multilingual-thinking', sharegpt: 'sharegpt', custom: 'custom' }; const formatSuffix = formatSuffixMap[exportOptions.formatType] || exportOptions.formatType || 'export'; const balanceSuffix = exportOptions.balanceMode ? '-balanced' : ''; const dateStr = new Date().toISOString().slice(0, 10); a.download = `datasets-${projectId}-${formatSuffix}${balanceSuffix}-${dateStr}.${fileExtension}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // 导出数据集(保持向后兼容的原有功能) const exportDatasets = async exportOptions => { try { const apiUrl = `/api/projects/${projectId}/datasets/export`; const requestBody = {}; if (exportOptions.selectedIds && exportOptions.selectedIds.length > 0) { requestBody.selectedIds = exportOptions.selectedIds; } else if (exportOptions.confirmedOnly) { requestBody.status = 'confirmed'; } if (exportOptions.balanceMode && exportOptions.balanceConfig) { requestBody.balanceMode = true; requestBody.balanceConfig = exportOptions.balanceConfig; } const response = await axios.post(apiUrl, requestBody); let dataToExport = response.data; await processAndDownloadData(dataToExport, exportOptions); toast.success(t('datasets.exportSuccess')); return true; } catch (error) { toast.error(error.message); return false; } }; // 导出平衡数据集 const exportBalancedDataset = async exportOptions => { const balancedOptions = { ...exportOptions, balanceMode: true, balanceConfig: exportOptions.balanceConfig }; return await exportDatasets(balancedOptions); }; return { exportDatasets, exportBalancedDataset, exportDatasetsStreaming }; }; export default useDatasetExport; export { useDatasetExport }; ================================================ FILE: app/projects/[projectId]/datasets/hooks/useDatasetFilters.js ================================================ 'use client'; import { useState, useEffect } from 'react'; /** * 数据集筛选条件持久化 Hook * 负责筛选条件的保存、恢复和管理 * @param {string} projectId - 项目ID * @returns {Object} 筛选条件和相关方法 */ export function useDatasetFilters(projectId) { const [filterConfirmed, setFilterConfirmed] = useState('all'); const [filterHasCot, setFilterHasCot] = useState('all'); const [filterIsDistill, setFilterIsDistill] = useState('all'); const [filterScoreRange, setFilterScoreRange] = useState([0, 5]); const [filterCustomTag, setFilterCustomTag] = useState(''); const [filterNoteKeyword, setFilterNoteKeyword] = useState(''); const [filterChunkName, setFilterChunkName] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchField, setSearchField] = useState('question'); const [page, setPage] = useState(1); const [rowsPerPage, setRowsPerPage] = useState(10); const [isInitialized, setIsInitialized] = useState(false); // 从 localStorage 恢复筛选条件 useEffect(() => { if (typeof window !== 'undefined') { try { const savedFilters = localStorage.getItem(`datasets-filters-${projectId}`); if (savedFilters) { const filters = JSON.parse(savedFilters); setFilterConfirmed(filters.filterConfirmed || 'all'); setFilterHasCot(filters.filterHasCot || 'all'); setFilterIsDistill(filters.filterIsDistill || 'all'); setFilterScoreRange(filters.filterScoreRange || [0, 5]); setFilterCustomTag(filters.filterCustomTag || ''); setFilterNoteKeyword(filters.filterNoteKeyword || ''); setFilterChunkName(filters.filterChunkName || ''); setSearchQuery(filters.searchQuery || ''); setSearchField(filters.searchField || 'question'); setPage(filters.page || 1); setRowsPerPage(filters.rowsPerPage || 10); } } catch (error) { console.error('恢复筛选条件失败:', error); } setIsInitialized(true); } }, [projectId]); // 保存筛选条件到 localStorage useEffect(() => { if (typeof window !== 'undefined' && isInitialized) { try { const filters = { filterConfirmed, filterHasCot, filterIsDistill, filterScoreRange, filterCustomTag, filterNoteKeyword, filterChunkName, searchQuery, searchField, page, rowsPerPage }; localStorage.setItem(`datasets-filters-${projectId}`, JSON.stringify(filters)); } catch (error) { console.error('保存筛选条件失败:', error); } } }, [ projectId, filterConfirmed, filterHasCot, filterIsDistill, filterScoreRange, filterCustomTag, filterNoteKeyword, filterChunkName, searchQuery, searchField, page, rowsPerPage, isInitialized ]); /** * 重置所有筛选条件为默认值 */ const resetFilters = () => { setFilterConfirmed('all'); setFilterHasCot('all'); setFilterIsDistill('all'); setFilterScoreRange([0, 5]); setFilterCustomTag(''); setFilterNoteKeyword(''); setFilterChunkName(''); setSearchQuery(''); setSearchField('question'); setPage(1); setRowsPerPage(10); }; /** * 清除 localStorage 中的筛选条件 */ const clearSavedFilters = () => { if (typeof window !== 'undefined') { try { localStorage.removeItem(`datasets-filters-${projectId}`); } catch (error) { console.error('清除筛选条件失败:', error); } } }; /** * 计算当前活跃的筛选条件数量 * @returns {number} 活跃筛选条件的数量 */ const getActiveFilterCount = () => { let count = 0; if (filterConfirmed !== 'all') count++; if (filterHasCot !== 'all') count++; if (filterIsDistill !== 'all') count++; if (filterScoreRange[0] > 0 || filterScoreRange[1] < 5) count++; if (filterCustomTag) count++; if (filterNoteKeyword) count++; if (filterChunkName) count++; return count; }; return { // 筛选条件状态 filterConfirmed, setFilterConfirmed, filterHasCot, setFilterHasCot, filterIsDistill, setFilterIsDistill, filterScoreRange, setFilterScoreRange, filterCustomTag, setFilterCustomTag, filterNoteKeyword, setFilterNoteKeyword, filterChunkName, setFilterChunkName, searchQuery, setSearchQuery, searchField, setSearchField, // 分页状态 page, setPage, rowsPerPage, setRowsPerPage, // 初始化状态 isInitialized, // 工具方法 resetFilters, clearSavedFilters, getActiveFilterCount }; } export default useDatasetFilters; ================================================ FILE: app/projects/[projectId]/datasets/page.js ================================================ 'use client'; import { useState, useEffect, useCallback } from 'react'; import { Container, Box, Typography, Button, Card, useTheme, alpha } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import { useRouter } from 'next/navigation'; import ExportDatasetDialog from '@/components/ExportDatasetDialog'; import ExportProgressDialog from '@/components/ExportProgressDialog'; import ImportDatasetDialog from '@/components/datasets/ImportDatasetDialog'; import { useTranslation } from 'react-i18next'; import DatasetList from './components/DatasetList'; import SearchBar from './components/SearchBar'; import ActionBar from './components/ActionBar'; import FilterDialog from './components/FilterDialog'; import DeleteConfirmDialog from './components/DeleteConfirmDialog'; import useDatasetExport from './hooks/useDatasetExport'; import useDatasetEvaluation from './hooks/useDatasetEvaluation'; import useDatasetFilters from './hooks/useDatasetFilters'; import { processInParallel } from '@/lib/util/async'; import axios from 'axios'; import { useDebounce } from '@/hooks/useDebounce'; import { toast } from 'sonner'; // 主页面组件 export default function DatasetsPage({ params }) { const { projectId } = params; const router = useRouter(); const theme = useTheme(); const [datasets, setDatasets] = useState({ data: [], total: 0, confirmedCount: 0 }); const [loading, setLoading] = useState(true); const [deleteDialog, setDeleteDialog] = useState({ open: false, datasets: null, batch: false, deleting: false }); const [exportDialog, setExportDialog] = useState({ open: false }); const [importDialog, setImportDialog] = useState({ open: false }); const [selectedIds, setselectedIds] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [filterDialogOpen, setFilterDialogOpen] = useState(false); const { t } = useTranslation(); // 使用 useDatasetFilters Hook 管理筛选条件 const { filterConfirmed, setFilterConfirmed, filterHasCot, setFilterHasCot, filterIsDistill, setFilterIsDistill, filterScoreRange, setFilterScoreRange, filterCustomTag, setFilterCustomTag, filterNoteKeyword, setFilterNoteKeyword, filterChunkName, setFilterChunkName, searchQuery, setSearchQuery, searchField, setSearchField, page, setPage, rowsPerPage, setRowsPerPage, isInitialized, getActiveFilterCount } = useDatasetFilters(projectId); const debouncedSearchQuery = useDebounce(searchQuery); // 删除进度状态 const [deleteProgress, setDeteleProgress] = useState({ total: 0, // 总删除问题数量 completed: 0, // 已删除完成的数量 percentage: 0 // 进度百分比 }); // 导出进度状态 const [exportProgress, setExportProgress] = useState({ show: false, // 是否显示进度 processed: 0, // 已处理数量 total: 0, // 总数量 hasMore: true // 是否还有更多数据 }); // 3. 添加打开导出对话框的处理函数 const handleOpenExportDialog = () => { setExportDialog({ open: true }); }; // 4. 添加关闭导出对话框的处理函数 const handleCloseExportDialog = () => { setExportDialog({ open: false }); }; // 5. 添加打开导入对话框的处理函数 const handleOpenImportDialog = () => { setImportDialog({ open: true }); }; // 6. 添加关闭导入对话框的处理函数 const handleCloseImportDialog = () => { setImportDialog({ open: false }); }; // 7. 导入成功后的处理函数 const handleImportSuccess = () => { // 刷新数据集列表 getDatasetsList(); toast.success(t('import.importSuccess', '数据集导入成功')); }; // 获取数据集列表 const getDatasetsList = useCallback( async ({ pageOverride } = {}) => { const effectivePage = pageOverride ?? page; try { setLoading(true); let url = `/api/projects/${projectId}/datasets?page=${effectivePage}&size=${rowsPerPage}`; if (filterConfirmed !== 'all') { url += `&status=${filterConfirmed}`; } if (debouncedSearchQuery) { url += `&input=${encodeURIComponent(debouncedSearchQuery)}&field=${searchField}`; } if (filterHasCot !== 'all') { url += `&hasCot=${filterHasCot}`; } if (filterIsDistill !== 'all') { url += `&isDistill=${filterIsDistill}`; } if (filterScoreRange[0] > 0 || filterScoreRange[1] < 5) { url += `&scoreRange=${filterScoreRange[0]}-${filterScoreRange[1]}`; } if (filterCustomTag) { url += `&customTag=${encodeURIComponent(filterCustomTag)}`; } if (filterNoteKeyword) { url += `¬eKeyword=${encodeURIComponent(filterNoteKeyword)}`; } if (filterChunkName) { url += `&chunkName=${encodeURIComponent(filterChunkName)}`; } const response = await axios.get(url); setDatasets(response.data || { data: [], total: 0, confirmedCount: 0 }); } catch (error) { toast.error(error.message); } finally { setLoading(false); } }, [ debouncedSearchQuery, filterConfirmed, filterCustomTag, filterHasCot, filterIsDistill, filterNoteKeyword, filterChunkName, filterScoreRange, page, projectId, rowsPerPage, searchField ] ); useEffect(() => { if (!isInitialized) return; getDatasetsList(); // 获取项目中所有使用过的标签 const fetchAvailableTags = async () => { try { const response = await fetch(`/api/projects/${projectId}/datasets/tags`); if (response.ok) { const data = await response.json(); setAvailableTags(data.tags || []); } } catch (error) { console.error('获取标签失败:', error); } }; fetchAvailableTags(); }, [projectId, page, rowsPerPage, debouncedSearchQuery, searchField, isInitialized]); // 处理页码变化 const handlePageChange = (_event, newPage) => { // MUI TablePagination 的页码从 0 开始,而我们的 API 从 1 开始 setPage(newPage + 1); }; // 处理每页行数变化 const handleRowsPerPageChange = event => { setPage(1); setRowsPerPage(parseInt(event.target.value, 10)); }; // 打开删除确认框 const handleOpenDeleteDialog = dataset => { setDeleteDialog({ open: true, datasets: [dataset] }); }; // 关闭删除确认框 const handleCloseDeleteDialog = () => { setDeleteDialog({ open: false, dataset: null }); }; const handleBatchDeleteDataset = async () => { const datasetsArray = selectedIds.map(id => ({ id })); setDeleteDialog({ open: true, datasets: datasetsArray, batch: true, count: selectedIds.length }); }; const resetProgress = () => { setDeteleProgress({ total: deleteDialog.count, completed: 0, percentage: 0 }); }; const handleDeleteConfirm = async () => { if (deleteDialog.batch) { setDeleteDialog({ ...deleteDialog, deleting: true }); await handleBatchDelete(); resetProgress(); } else { const [dataset] = deleteDialog.datasets; if (!dataset) return; await handleDelete(dataset); } setselectedIds([]); // 刷新数据 getDatasetsList(); // 关闭确认框 handleCloseDeleteDialog(); }; // 批量删除数据集 const handleBatchDelete = async () => { try { await processInParallel( selectedIds, async datasetId => { await fetch(`/api/projects/${projectId}/datasets?id=${datasetId}`, { method: 'DELETE' }); }, 3, (cur, total) => { setDeteleProgress({ total, completed: cur, percentage: Math.floor((cur / total) * 100) }); } ); toast.success(t('common.deleteSuccess')); } catch (error) { console.error('批量删除失败:', error); toast.error(error.message || t('common.deleteFailed')); } }; // 删除数据集 const handleDelete = async dataset => { try { const response = await fetch(`/api/projects/${projectId}/datasets?id=${dataset.id}`, { method: 'DELETE' }); if (!response.ok) throw new Error(t('datasets.deleteFailed')); toast.success(t('datasets.deleteSuccess')); } catch (error) { toast.error(error.message || t('datasets.deleteFailed')); } }; // 使用自定义 Hook 处理数据集导出逻辑 const { exportDatasets, exportDatasetsStreaming } = useDatasetExport(projectId); // 使用自定义 Hook 处理数据集评估逻辑 const { evaluatingIds, batchEvaluating, handleEvaluateDataset, handleBatchEvaluate } = useDatasetEvaluation( projectId, getDatasetsList ); // 处理导出数据集 - 智能选择导出方式 const handleExportDatasets = async exportOptions => { try { // 如果是平衡导出,则忽略选中项,按 balanceConfig 导出 const exportOptionsWithSelection = exportOptions.balanceMode ? { ...exportOptions } : { ...exportOptions, ...(selectedIds.length > 0 && { selectedIds }) }; // 获取数据总量: // 平衡导出时,按 balanceConfig 的总量计算; // 其他情况:如果有选中数据集则使用选中数量,否则使用当前筛选条件下的数据总量 const balancedTotal = Array.isArray(exportOptions.balanceConfig) ? exportOptions.balanceConfig.reduce((sum, c) => sum + (parseInt(c.maxCount) || 0), 0) : 0; const totalCount = exportOptions.balanceMode ? balancedTotal : selectedIds.length > 0 ? selectedIds.length : datasets.total || 0; // 设置阈值:超过1000条数据使用流式导出 const STREAMING_THRESHOLD = 1000; // 检查是否需要包含文本块内容 const needsChunkContent = exportOptions.formatType === 'custom' && exportOptions.customFields?.includeChunk; let success = false; // 如果数据量大于阈值或需要查询文本块内容,使用流式导出 if (totalCount > STREAMING_THRESHOLD || needsChunkContent) { // 使用流式导出,显示进度 setExportProgress({ show: true, processed: 0, total: totalCount }); success = await exportDatasetsStreaming(exportOptionsWithSelection, progress => { setExportProgress(prev => ({ ...prev, processed: progress.processed, hasMore: progress.hasMore })); }); // 隐藏进度 setExportProgress({ show: false, processed: 0, total: 0 }); } else { // 使用传统导出方式 success = await exportDatasets(exportOptionsWithSelection); } if (success) { // 关闭export对话框 handleCloseExportDialog(); } } catch (error) { console.error('Export failed:', error); setExportProgress({ show: false, processed: 0, total: 0 }); } }; // 查看详情 const handleViewDetails = id => { router.push(`/projects/${projectId}/datasets/${id}`); }; // 处理全选/取消全选 const handleSelectAll = async event => { if (event.target.checked) { // 获取所有符合当前筛选条件的数据,不受分页限制 let url = `/api/projects/${projectId}/datasets?selectedAll=1`; if (filterConfirmed !== 'all') { url += `&status=${filterConfirmed}`; } if (debouncedSearchQuery) { url += `&input=${encodeURIComponent(debouncedSearchQuery)}&field=${searchField}`; } if (filterHasCot !== 'all') { url += `&hasCot=${filterHasCot}`; } if (filterIsDistill !== 'all') { url += `&isDistill=${filterIsDistill}`; } if (filterScoreRange[0] > 0 || filterScoreRange[1] < 5) { url += `&scoreRange=${filterScoreRange[0]}-${filterScoreRange[1]}`; } if (filterCustomTag) { url += `&customTag=${encodeURIComponent(filterCustomTag)}`; } if (filterNoteKeyword) { url += `¬eKeyword=${encodeURIComponent(filterNoteKeyword)}`; } const response = await axios.get(url); setselectedIds(response.data.map(dataset => dataset.id)); } else { setselectedIds([]); } }; // 处理单个选择 const handleSelectItem = id => { setselectedIds(prev => { if (prev.includes(id)) { return prev.filter(item => item !== id); } else { return [...prev, id]; } }); }; const handleResetFilters = useCallback(() => { setFilterConfirmed('all'); setFilterHasCot('all'); setFilterIsDistill('all'); setFilterScoreRange([0, 5]); setFilterCustomTag(''); setFilterNoteKeyword(''); setFilterChunkName(''); setPage(1); getDatasetsList({ pageOverride: 1 }); }, [ getDatasetsList, setFilterConfirmed, setFilterHasCot, setFilterIsDistill, setFilterScoreRange, setFilterCustomTag, setFilterNoteKeyword, setFilterChunkName, setPage ]); const handleApplyFilters = useCallback(() => { setFilterDialogOpen(false); setPage(1); getDatasetsList({ pageOverride: 1 }); }, [getDatasetsList, setFilterDialogOpen, setPage]); const handleCloseFilterDialog = useCallback(() => setFilterDialogOpen(false), [setFilterDialogOpen]); return ( { setSearchQuery(value); setPage(1); }} onSearchFieldChange={value => { setSearchField(value); setPage(1); }} onMoreFiltersClick={() => setFilterDialogOpen(true)} activeFilterCount={getActiveFilterCount()} /> {selectedIds.length ? ( {t('datasets.selected', { count: selectedIds.length })} ) : ( '' )} {/* 导出进度对话框 */} ); } ================================================ FILE: app/projects/[projectId]/distill/autoDistillService.js ================================================ 'use client'; import axios from 'axios'; /** * 自动蒸馏服务 */ class AutoDistillService { /** * 执行自动蒸馆任务 * @param {Object} config - 配置信息 * @param {string} config.projectId - 项目ID * @param {string} config.topic - 蒸馆主题 * @param {number} config.levels - 标签层级 * @param {number} config.tagsPerLevel - 每层标签数量 * @param {number} config.questionsPerTag - 每个标签问题数量 * @param {Object} config.model - 模型信息 * @param {string} config.language - 语言 * @param {Function} config.onProgress - 进度回调 * @param {Function} config.onLog - 日志回调 * @returns {Promise} */ async executeDistillTask(config) { const { projectId, topic, levels, tagsPerLevel, questionsPerTag, model, language, datasetType = 'single-turn', // 新增数据集类型 concurrencyLimit = 5, onProgress, onLog } = config; // 项目名称存储,用于整个流程共享 this.projectName = ''; try { // 初始化进度信息 if (onProgress) { onProgress({ stage: 'initializing', tagsTotal: 0, tagsBuilt: 0, questionsTotal: 0, questionsBuilt: 0, datasetsTotal: 0, datasetsBuilt: 0 }); } // 获取项目名称,只需获取一次 try { const projectResponse = await axios.get(`/api/projects/${projectId}`); if (projectResponse && projectResponse.data && projectResponse.data.name) { this.projectName = projectResponse.data.name; this.addLog(onLog, `Using project name "${this.projectName}" as the top-level tag`); } else { this.projectName = topic; // 如果无法获取项目名称,则使用主题作为默认值 this.addLog(onLog, `Could not find project name, using topic "${topic}" as the top-level tag`); } } catch (error) { this.projectName = topic; // 出错时使用主题作为默认值 this.addLog(onLog, `Failed to get project name, using topic "${topic}" instead: ${error.message}`); } // 添加日志 this.addLog( onLog, `Starting to build tag tree for "${topic}", number of levels: ${levels}, tags per level: ${tagsPerLevel}, questions per tag: ${questionsPerTag}` ); // 从根节点开始构建标签树 await this.buildTagTree({ projectId, topic, levels, tagsPerLevel, model, language, onProgress, onLog }); // 所有标签构建完成后,生成问题 await this.generateQuestionsForTags({ projectId, levels, questionsPerTag, model, language, concurrencyLimit, onProgress, onLog }); // 根据数据集类型生成不同类型的数据集 if (datasetType === 'single-turn') { // 只生成单轮对话数据集 await this.generateDatasetsForQuestions({ projectId, model, language, concurrencyLimit, onProgress, onLog }); } else if (datasetType === 'multi-turn') { // 只生成多轮对话数据集 await this.generateMultiTurnDatasetsForQuestions({ projectId, model, language, concurrencyLimit, onProgress, onLog }); } else if (datasetType === 'both') { // 先生成单轮对话数据集 await this.generateDatasetsForQuestions({ projectId, model, language, concurrencyLimit, onProgress, onLog }); // 再生成多轮对话数据集 await this.generateMultiTurnDatasetsForQuestions({ projectId, model, language, concurrencyLimit, onProgress, onLog }); } // 任务完成 if (onProgress) { onProgress({ stage: 'completed' }); } this.addLog(onLog, 'Auto distillation task completed'); } catch (error) { console.error('自动蒸馏任务执行失败:', error); this.addLog(onLog, `Task execution error: ${error.message || 'Unknown error'}`); throw error; } } /** * 构建标签树 * @param {Object} config - 配置信息 * @param {string} config.projectId - 项目ID * @param {string} config.topic - 蒸馆主题 * @param {number} config.levels - 标签层级 * @param {number} config.tagsPerLevel - 每层标签数量 * @param {Object} config.model - 模型信息 * @param {string} config.language - 语言 * @param {Function} config.onProgress - 进度回调 * @param {Function} config.onLog - 日志回调 * @returns {Promise} */ async buildTagTree(config) { const { projectId, topic, levels, tagsPerLevel, model, language, onProgress, onLog } = config; // 使用已经获取的项目名称,如果未获取到,则使用主题 const projectName = this.projectName || topic; try { // 设置初始阶段 if (onProgress) { onProgress({ stage: 'level1' }); } // 获取所有现有标签 let allTags = []; try { const response = await axios.get(`/api/projects/${projectId}/distill/tags/all`); allTags = response.data; } catch (error) { console.error('获取标签失败:', error); this.addLog(onLog, `Failed to get tags: ${error.message}`); return; } // 获取叶子节点总数,更新进度条 const leafTags = Math.pow(tagsPerLevel, levels); if (onProgress) { onProgress({ tagsTotal: leafTags }); } // 批量构建标签树 await this.batchBuildTagTree({ projectId, topic, levels, tagsPerLevel, model, language, projectName, allTags, onProgress, onLog }); } catch (error) { console.error('构建标签树失败:', error); this.addLog(onLog, `Failed to build tag tree: ${error.message}`); throw error; } } /** * 批量构建标签树 * @param {Object} config - 配置信息 * @returns {Promise} */ async batchBuildTagTree(config) { const { projectId, topic, levels, tagsPerLevel, model, language, projectName, allTags: initialTags, onProgress, onLog } = config; // 创建一个本地标签缓存,避免频繁请求服务器 let allTags = [...initialTags]; // 构建父子关系映射 const childrenMap = {}; const parentMap = {}; allTags.forEach(tag => { parentMap[tag.id] = tag; if (tag.parentId) { if (!childrenMap[tag.parentId]) { childrenMap[tag.parentId] = []; } childrenMap[tag.parentId].push(tag); } }); // 按层级分组标签,提高查找效率 const tagsByLevel = {}; allTags.forEach(tag => { const depth = this.getTagDepth(tag, parentMap); if (!tagsByLevel[depth]) { tagsByLevel[depth] = []; } tagsByLevel[depth].push(tag); }); // 批量创建各层级标签 for (let level = 1; level <= levels; level++) { // 设置当前阶段 if (onProgress) { onProgress({ stage: `level${level}` }); } // 确定当前层级的父标签 let parentTags = []; if (level === 1) { // 第一层标签没有父标签 parentTags = [null]; } else { // 获取上一层的标签作为父标签 parentTags = tagsByLevel[level - 1] || []; } const batch = parentTags; const creationPromises = []; for (const parentTag of batch) { // 获取当前父标签下的子标签 let currentLevelTags = []; if (parentTag) { currentLevelTags = childrenMap[parentTag.id] || []; } else { // 根标签(没有父标签的标签) currentLevelTags = allTags.filter(tag => !tag.parentId); } // 计算需要创建的标签数量 const needToCreate = Math.max(0, tagsPerLevel - currentLevelTags.length); if (needToCreate > 0) { // 构建标签路径 let tagPathWithProjectName; if (level === 1) { // 第一层使用项目名称 tagPathWithProjectName = projectName; } else { // 其他层构建完整路径 const parentTagName = parentTag?.label || ''; const parentTagPath = this.getTagPath(parentTag, parentMap); if (!parentTagPath) { tagPathWithProjectName = projectName; } else if (!parentTagPath.startsWith(projectName)) { tagPathWithProjectName = `${projectName} > ${parentTagPath}`; } else { tagPathWithProjectName = parentTagPath; } } // 创建标签的Promise const createPromise = axios .post(`/api/projects/${projectId}/distill/tags`, { parentTag: level === 1 ? topic : parentTag?.label || '', parentTagId: parentTag ? parentTag.id : null, tagPath: tagPathWithProjectName || (level === 1 ? projectName : ''), count: needToCreate, model, language }) .then(response => { // 更新本地标签缓存 const newTags = response.data; allTags = [...allTags, ...newTags]; // 更新父子关系映射 if (parentTag) { if (!childrenMap[parentTag.id]) { childrenMap[parentTag.id] = []; } childrenMap[parentTag.id].push(...newTags); } // 更新父标签映射 newTags.forEach(tag => { parentMap[tag.id] = tag; }); // 更新层级分组 if (!tagsByLevel[level]) { tagsByLevel[level] = []; } tagsByLevel[level].push(...newTags); // 更新构建的标签数量 if (onProgress) { onProgress({ tagsBuilt: newTags.length, updateType: 'increment' }); } // 添加日志 this.addLog( onLog, `Successfully created ${newTags.length} tags: ${newTags.map(tag => `"${tag.label}"`).join(', ')}` ); return newTags; }) .catch(error => { console.error(`创建${level}级标签失败:`, error); this.addLog(onLog, `Failed to create ${level} level tags: ${error.message || 'Unknown error'}`); return []; }); creationPromises.push(createPromise); } } // 并行执行当前批次的所有创建任务 await Promise.all(creationPromises); } } /** * 为标签生成问题 * @param {Object} config - 配置信息 * @param {string} config.projectId - 项目ID * @param {number} config.levels - 标签层级 * @param {number} config.questionsPerTag - 每个标签问题数量 * @param {Object} config.model - 模型信息 * @param {string} config.language - 语言 * @param {Function} config.onProgress - 进度回调 * @param {Function} config.onLog - 日志回调 * @returns {Promise} */ async generateQuestionsForTags(config) { const { projectId, levels, questionsPerTag, model, language, concurrencyLimit = 5, onProgress, onLog } = config; // 设置当前阶段 if (onProgress) { onProgress({ stage: 'questions' }); } this.addLog(onLog, 'Tag tree built, starting to generate questions for leaf tags...'); try { // 获取所有标签 const response = await axios.get(`/api/projects/${projectId}/distill/tags/all`); const allTags = response.data; // 找出所有叶子标签(没有子标签的标签) const leafTags = []; // 创建一个映射表,记录每个标签的子标签 const childrenMap = {}; const parentMap = {}; allTags.forEach(tag => { parentMap[tag.id] = tag; if (tag.parentId) { if (!childrenMap[tag.parentId]) { childrenMap[tag.parentId] = []; } childrenMap[tag.parentId].push(tag); } }); // 找出所有叶子标签 allTags.forEach(tag => { // 如果没有子标签,并且深度是最大层级,则为叶子标签 if (!childrenMap[tag.id] && this.getTagDepth(tag, parentMap) === levels) { leafTags.push(tag); } }); this.addLog(onLog, `Found ${leafTags.length} leaf tags, starting to generate questions...`); // 获取所有问题 const questionsResponse = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`); const allQuestions = questionsResponse.data; // 更新总问题数量 const totalQuestionsToGenerate = leafTags.length * questionsPerTag; if (onProgress) { onProgress({ questionsTotal: totalQuestionsToGenerate }); } // 准备并发任务 const generateQuestionTasks = []; const processedTags = []; // 准备所有需要生成问题的叶子标签任务 for (const tag of leafTags) { // 获取标签路径 const tagPath = this.getTagPath(tag, parentMap); // 计算已有问题数量 const existingQuestions = allQuestions.filter(q => q.label === tag.label); const needToCreate = Math.max(0, questionsPerTag - existingQuestions.length); if (needToCreate > 0) { // 只添加需要生成问题的标签任务 generateQuestionTasks.push({ tag, tagPath, needToCreate }); this.addLog(onLog, `Preparing to generate ${needToCreate} questions for tag "${tag.label}"...`); } else { this.addLog( onLog, `Tag "${tag.label}" already has ${existingQuestions.length} questions, no need to generate new questions` ); } } // 分批执行生成问题任务,控制并发数 this.addLog( onLog, `Total ${generateQuestionTasks.length} tags need questions, concurrency limit: ${concurrencyLimit}` ); // 使用分组批量处理 for (let i = 0; i < generateQuestionTasks.length; i += concurrencyLimit) { const batch = generateQuestionTasks.slice(i, i + concurrencyLimit); // 并行处理批次任务 await Promise.all( batch.map(async task => { const { tag, tagPath, needToCreate } = task; this.addLog(onLog, `Generating ${needToCreate} questions for tag "${tag.label}"...`); try { const response = await axios.post(`/api/projects/${projectId}/distill/questions`, { tagPath, currentTag: tag.label, tagId: tag.id, count: needToCreate, model, language }); // 更新生成的问题数量 if (onProgress) { onProgress({ questionsBuilt: response.data.length, updateType: 'increment' }); } this.addLog(onLog, `Successfully generated ${response.data.length} questions for tag "${tag.label}"`); } catch (error) { console.error(`为标签 "${tag.label}" 生成问题失败:`, error); this.addLog( onLog, `Failed to generate questions for tag "${tag.label}": ${error.message || 'Unknown error'}` ); } }) ); // 每完成一批,输出一次进度日志 this.addLog( onLog, `Completed batch ${Math.min(i + concurrencyLimit, generateQuestionTasks.length)}/${generateQuestionTasks.length} of question generation` ); } } catch (error) { console.error('获取标签失败:', error); this.addLog(onLog, `Failed to get tags: ${error.message || 'Unknown error'}`); } } /** * 为问题生成数据集 * @param {Object} config - 配置信息 * @param {string} config.projectId - 项目ID * @param {Object} config.model - 模型信息 * @param {string} config.language - 语言 * @param {Function} config.onProgress - 进度回调 * @param {Function} config.onLog - 日志回调 * @returns {Promise} */ async generateDatasetsForQuestions(config) { const { projectId, model, language, concurrencyLimit = 5, onProgress, onLog } = config; // 设置当前阶段 if (onProgress) { onProgress({ stage: 'datasets' }); } this.addLog(onLog, 'Question generation completed, starting to generate answers...'); try { // 获取所有问题 const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`); const allQuestions = response.data; // 找出未回答的问题 const unansweredQuestions = allQuestions.filter(q => !q.answered); const answeredQuestions = allQuestions.filter(q => q.answered); // 更新总数据集数量和已生成数量 if (onProgress) { onProgress({ datasetsTotal: allQuestions.length, // 总数据集数量应为总问题数量 datasetsBuilt: answeredQuestions.length // 已生成的数据集数量即已回答的问题数量 }); } this.addLog(onLog, `Found ${unansweredQuestions.length} unanswered questions, preparing to generate answers...`); this.addLog(onLog, `Dataset generation concurrency limit: ${concurrencyLimit}`); // 分批处理未回答的问题,控制并发数 for (let i = 0; i < unansweredQuestions.length; i += concurrencyLimit) { const batch = unansweredQuestions.slice(i, i + concurrencyLimit); // 并行处理批次任务 await Promise.all( batch.map(async question => { const questionContent = `${question.label} 下的问题ID:${question.id}`; this.addLog(onLog, `Generating answer for "${questionContent}"...`); try { // 调用生成数据集的函数 await this.generateSingleDataset({ projectId, questionId: question.id, questionInfo: question, model, language }); // 更新生成的数据集数量 if (onProgress) { onProgress({ datasetsBuilt: 1, updateType: 'increment' }); } this.addLog(onLog, `Successfully generated answer for question "${questionContent}"`); } catch (error) { console.error(`Failed to generate dataset for question "${question.id}":`, error); this.addLog( onLog, `Failed to generate answer for question "${questionContent}": ${error.message || 'Unknown error'}` ); } }) ); // 每完成一批,输出一次进度日志 this.addLog( onLog, `Completed batch ${Math.min(i + concurrencyLimit, unansweredQuestions.length)}/${unansweredQuestions.length} of dataset generation` ); } this.addLog(onLog, 'Dataset generation completed'); } catch (error) { console.error('Dataset generation failed:', error); this.addLog(onLog, `Dataset generation error: ${error.message}`); throw error; } } /** * 为问题生成多轮对话数据集 */ async generateMultiTurnDatasetsForQuestions(config) { const { projectId, model, language, concurrencyLimit = 2, onProgress, onLog } = config; // 设置当前阶段 if (onProgress) { onProgress({ stage: 'multi-turn-datasets' }); } this.addLog(onLog, 'Question generation completed, starting to generate multi-turn conversations...'); try { // 获取项目的多轮对话配置 const configResponse = await axios.get(`/api/projects/${projectId}/tasks`); const taskConfig = configResponse.data; const multiTurnConfig = { systemPrompt: taskConfig.multiTurnSystemPrompt || '', scenario: taskConfig.multiTurnScenario || '', rounds: taskConfig.multiTurnRounds || 3, roleA: taskConfig.multiTurnRoleA || '', roleB: taskConfig.multiTurnRoleB || '' }; // 检查是否已配置必要的多轮对话设置 if ( !multiTurnConfig.scenario || !multiTurnConfig.roleA || !multiTurnConfig.roleB || !multiTurnConfig.rounds || multiTurnConfig.rounds < 1 ) { throw new Error('项目未配置多轮对话参数,请先在项目设置中配置多轮对话相关参数'); } // 获取所有已回答的问题(多轮对话需要基于已有答案的问题) const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`); const allQuestions = response.data; const answeredQuestions = allQuestions; if (answeredQuestions.length === 0) { this.addLog(onLog, 'No answered questions found, skipping multi-turn conversation generation'); return; } // 获取已生成多轮对话的问题ID const conversationsResponse = await axios.get(`/api/projects/${projectId}/dataset-conversations?pageSize=1000`); const existingConversationIds = new Set( (conversationsResponse.data.conversations || []).map(conv => conv.questionId) ); // 筛选未生成多轮对话的问题 const questionsForMultiTurn = answeredQuestions.filter(q => !existingConversationIds.has(q.id)); // 更新多轮对话数据集总数和已生成数量 if (onProgress) { onProgress({ multiTurnDatasetsTotal: answeredQuestions.length, multiTurnDatasetsBuilt: answeredQuestions.length - questionsForMultiTurn.length }); } this.addLog( onLog, `Found ${questionsForMultiTurn.length} questions ready for multi-turn conversation generation...` ); this.addLog(onLog, `Multi-turn generation concurrency limit: ${concurrencyLimit}`); // 分批处理未生成多轮对话的问题,控制并发数 for (let i = 0; i < questionsForMultiTurn.length; i += concurrencyLimit) { const batch = questionsForMultiTurn.slice(i, i + concurrencyLimit); // 并行处理批次任务 await Promise.all( batch.map(async question => { const questionContent = `${question.label} 下的问题ID:${question.id}`; this.addLog(onLog, `Generating multi-turn conversation for "${questionContent}"...`); try { // 调用生成多轮对话的函数 await this.generateSingleMultiTurnDataset({ projectId, questionId: question.id, questionInfo: question, model, language, multiTurnConfig }); // 更新进度 if (onProgress) { onProgress({ multiTurnDatasetsBuilt: 1, updateType: 'increment' }); } this.addLog(onLog, `Multi-turn conversation generated for "${questionContent}"`); } catch (error) { this.addLog( onLog, `Failed to generate multi-turn conversation for "${questionContent}": ${error.message}` ); } }) ); } this.addLog(onLog, 'Multi-turn conversation generation completed'); } catch (error) { console.error('Multi-turn dataset generation failed:', error); this.addLog(onLog, `Multi-turn dataset generation error: ${error.message}`); throw error; } } /** * 生成单个问题的多轮对话数据集 */ async generateSingleMultiTurnDataset({ projectId, questionId, questionInfo, model, language, multiTurnConfig }) { try { const response = await axios.post(`/api/projects/${projectId}/dataset-conversations`, { questionId, ...multiTurnConfig, model, language }); return response.data; } catch (error) { console.error('Failed to generate multi-turn dataset:', error); throw new Error(`Failed to generate multi-turn dataset: ${error.message}`); } } /** * 生成单个问题的数据集 */ async generateSingleDataset({ projectId, questionId, questionInfo, model, language }) { try { // 获取问题信息 let question = questionInfo; if (!question) { const response = await axios.get(`/api/projects/${projectId}/questions/${questionId}`); question = response.data; } // 生成数据集 const response = await axios.post(`/api/projects/${projectId}/datasets`, { projectId, questionId, model, language: language || 'zh-CN' }); return response.data; } catch (error) { console.error('Failed to generate dataset:', error); throw new Error(`Failed to generate dataset: ${error.message}`); } } /** * 获取标签深度 * @param {Object} tag - 标签信息 * @param {Object} parentMap - 父标签映射 * @returns {number} - 标签深度 */ getTagDepth(tag, parentMap) { if (!tag) return 0; let depth = 1; let currentTag = tag; while (currentTag && currentTag.parentId) { depth++; currentTag = parentMap[currentTag.parentId]; } return depth; } /** * 获取标签路径,确保始终以项目名称开头 * @param {Object|null} tag - 标签对象 * @param {Object} parentMap - 父标签映射 * @returns {string} 标签路径 */ getTagPath(tag, parentMap) { if (!tag) return ''; // 使用已经获取的项目名称 const projectName = this.projectName || ''; // 构建标签路径 const path = []; let currentTag = tag; while (currentTag) { path.unshift(currentTag.label); if (currentTag.parentId) { currentTag = parentMap[currentTag.parentId]; } else { currentTag = null; } } // 确保路径以项目名称开头 if (projectName && path.length > 0 && path[0] !== projectName) { path.unshift(projectName); } return path.join(' > '); } /** * 添加日志 * @param {Function} onLog - 日志回调 * @param {string} message - 日志消息 */ addLog(onLog, message) { if (onLog && typeof onLog === 'function') { onLog(message); } } } export const autoDistillService = new AutoDistillService(); export default autoDistillService; ================================================ FILE: app/projects/[projectId]/distill/page.js ================================================ 'use client'; import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'next/navigation'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; import { Box, Typography, Paper, Container, Button, CircularProgress, Alert, IconButton, Tooltip } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import DistillTreeView from '@/components/distill/DistillTreeView'; import TagGenerationDialog from '@/components/distill/TagGenerationDialog'; import QuestionGenerationDialog from '@/components/distill/QuestionGenerationDialog'; import AutoDistillDialog from '@/components/distill/AutoDistillDialog'; import AutoDistillProgress from '@/components/distill/AutoDistillProgress'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import { autoDistillService } from './autoDistillService'; import axios from 'axios'; import { toast } from 'sonner'; export default function DistillPage() { const { t, i18n } = useTranslation(); const { projectId } = useParams(); const selectedModel = useAtomValue(selectedModelInfoAtom); const [project, setProject] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [tags, setTags] = useState([]); // 标签生成对话框相关状态 const [tagDialogOpen, setTagDialogOpen] = useState(false); const [questionDialogOpen, setQuestionDialogOpen] = useState(false); const [selectedTag, setSelectedTag] = useState(null); const [selectedTagPath, setSelectedTagPath] = useState(''); // 自动蒸馏相关状态 const [autoDistillDialogOpen, setAutoDistillDialogOpen] = useState(false); const [autoDistillProgressOpen, setAutoDistillProgressOpen] = useState(false); const [autoDistillRunning, setAutoDistillRunning] = useState(false); const [distillStats, setDistillStats] = useState({ tagsCount: 0, questionsCount: 0, datasetsCount: 0, multiTurnDatasetsCount: 0 }); const [distillProgress, setDistillProgress] = useState({ stage: 'initializing', tagsTotal: 0, tagsBuilt: 0, questionsTotal: 0, questionsBuilt: 0, datasetsTotal: 0, datasetsBuilt: 0, multiTurnDatasetsTotal: 0, // 新增多轮对话数据集总数 multiTurnDatasetsBuilt: 0, // 新增多轮对话数据集已生成数 logs: [] }); const treeViewRef = useRef(null); // 获取项目信息和标签列表 useEffect(() => { if (projectId) { fetchProject(); fetchTags(); fetchDistillStats(); } }, [projectId]); // 监听多轮对话数据集刷新事件 useEffect(() => { const handleRefreshStats = () => { fetchDistillStats(); }; if (typeof window !== 'undefined') { window.addEventListener('refreshDistillStats', handleRefreshStats); return () => { window.removeEventListener('refreshDistillStats', handleRefreshStats); }; } }, [projectId]); // 获取项目信息 const fetchProject = async () => { try { setLoading(true); const response = await axios.get(`/api/projects/${projectId}`); setProject(response.data); } catch (error) { console.error('获取项目信息失败:', error); setError(t('common.fetchError')); } finally { setLoading(false); } }; // 获取标签列表 const fetchTags = async () => { try { setLoading(true); const response = await axios.get(`/api/projects/${projectId}/distill/tags/all`); setTags(response.data); } catch (error) { console.error('获取标签列表失败:', error); setError(t('common.fetchError')); } finally { setLoading(false); } }; // 获取蒸馏统计信息 const fetchDistillStats = async () => { try { // 获取标签数量 const tagsResponse = await axios.get(`/api/projects/${projectId}/distill/tags/all`); const tagsCount = tagsResponse.data.length; // 获取问题数量 const questionsResponse = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`); const questionsCount = questionsResponse.data.length; // 获取数据集数量 const datasetsCount = questionsResponse.data.filter(q => q.answered).length; // 获取多轮对话数据集数量 let multiTurnDatasetsCount = 0; try { const conversationsResponse = await axios.get( `/api/projects/${projectId}/dataset-conversations?getAllIds=true` ); multiTurnDatasetsCount = (conversationsResponse.data.allConversationIds || []).length; } catch (error) { console.log('获取多轮对话数据集统计失败,可能是API不存在:', error.message); } setDistillStats({ tagsCount, questionsCount, datasetsCount, multiTurnDatasetsCount }); } catch (error) { console.error('获取蒸馏统计信息失败:', error); } }; // 打开生成标签对话框 const handleOpenTagDialog = (tag = null, tagPath = '') => { if (!selectedModel || Object.keys(selectedModel).length === 0) { setError(t('distill.selectModelFirst')); return; } setSelectedTag(tag); setSelectedTagPath(tagPath); setTagDialogOpen(true); }; // 打开生成问题对话框 const handleOpenQuestionDialog = (tag, tagPath) => { if (!selectedModel || Object.keys(selectedModel).length === 0) { setError(t('distill.selectModelFirst')); return; } setSelectedTag(tag); setSelectedTagPath(tagPath); setQuestionDialogOpen(true); }; // 处理标签生成完成 const handleTagGenerated = () => { fetchTags(); // 重新获取标签列表 setTagDialogOpen(false); }; // 处理问题生成完成 const handleQuestionGenerated = () => { // 关闭对话框 setQuestionDialogOpen(false); // 刷新标签数据 fetchTags(); fetchDistillStats(); // 如果 treeViewRef 存在且有 fetchQuestionsStats 方法,则调用它刷新问题统计信息 if (treeViewRef.current && typeof treeViewRef.current.fetchQuestionsStats === 'function') { treeViewRef.current.fetchQuestionsStats(); } }; // 打开自动蒸馏对话框 const handleOpenAutoDistillDialog = () => { if (!selectedModel || Object.keys(selectedModel).length === 0) { setError(t('distill.selectModelFirst')); return; } setAutoDistillDialogOpen(true); }; // 开始自动蒸馏任务(前台运行) const handleStartAutoDistill = async config => { setAutoDistillDialogOpen(false); setAutoDistillProgressOpen(true); setAutoDistillRunning(true); // 初始化进度信息 setDistillProgress({ stage: 'initializing', tagsTotal: config.estimatedTags, tagsBuilt: distillStats.tagsCount || 0, questionsTotal: config.estimatedQuestions, questionsBuilt: distillStats.questionsCount || 0, datasetsTotal: config.estimatedQuestions, // 初步设置数据集总数为问题数,后面会更新 datasetsBuilt: distillStats.datasetsCount || 0, // 根据当前已生成的数据集数量初始化 multiTurnDatasetsTotal: config.datasetType === 'multi-turn' || config.datasetType === 'both' ? config.estimatedQuestions : 0, multiTurnDatasetsBuilt: distillStats.multiTurnDatasetsCount || 0, logs: [t('distill.autoDistillStarted', { time: new Date().toLocaleTimeString() })] }); try { // 检查模型是否存在 if (!selectedModel || Object.keys(selectedModel).length === 0) { addLog(t('distill.selectModelFirst')); setAutoDistillRunning(false); return; } // 使用 autoDistillService 执行蒸馏任务 await autoDistillService.executeDistillTask({ projectId, topic: config.topic, levels: config.levels, tagsPerLevel: config.tagsPerLevel, questionsPerTag: config.questionsPerTag, datasetType: config.datasetType, // 新增数据集类型参数 model: selectedModel, language: i18n.language, concurrencyLimit: project?.taskConfig?.concurrencyLimit || 5, // 从项目配置中获取并发限制 onProgress: updateProgress, onLog: addLog }); // 更新任务状态 setAutoDistillRunning(false); } catch (error) { console.error('自动蒸馏任务执行失败:', error); addLog(t('distill.taskExecutionError', { error: error.message || t('common.unknownError') })); setAutoDistillRunning(false); } }; // 开始自动蒸馏任务(后台运行) const handleStartAutoDistillBackground = async config => { setAutoDistillDialogOpen(false); try { // 检查模型是否存在 if (!selectedModel || Object.keys(selectedModel).length === 0) { setError(t('distill.selectModelFirst')); return; } // 创建后台任务 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'data-distillation', modelInfo: selectedModel, language: i18n.language, detail: t('distill.autoDistillTaskDetail', { topic: config.topic }), totalCount: config.estimatedQuestions, note: { topic: config.topic, levels: config.levels, tagsPerLevel: config.tagsPerLevel, questionsPerTag: config.questionsPerTag, datasetType: config.datasetType, estimatedTags: config.estimatedTags, estimatedQuestions: config.estimatedQuestions } }); if (response.data.code === 0) { toast.success(t('distill.backgroundTaskCreated')); // 3秒后刷新统计信息 setTimeout(() => { fetchDistillStats(); }, 3000); } else { toast.error(response.data.message || t('distill.backgroundTaskFailed')); } } catch (error) { console.error('创建后台蒸馏任务失败:', error); toast.error(error.message || t('distill.backgroundTaskFailed')); } }; // 更新进度 const updateProgress = progressUpdate => { setDistillProgress(prev => { const newProgress = { ...prev }; // 更新阶段 if (progressUpdate.stage) { newProgress.stage = progressUpdate.stage; } // 更新标签总数 if (progressUpdate.tagsTotal) { newProgress.tagsTotal = progressUpdate.tagsTotal; } // 更新已构建标签数 if (progressUpdate.tagsBuilt) { if (progressUpdate.updateType === 'increment') { newProgress.tagsBuilt += progressUpdate.tagsBuilt; } else { newProgress.tagsBuilt = progressUpdate.tagsBuilt; } } // 更新问题总数 if (progressUpdate.questionsTotal) { newProgress.questionsTotal = progressUpdate.questionsTotal; } // 更新已生成问题数 if (progressUpdate.questionsBuilt) { if (progressUpdate.updateType === 'increment') { newProgress.questionsBuilt += progressUpdate.questionsBuilt; } else { newProgress.questionsBuilt = progressUpdate.questionsBuilt; } } // 更新数据集总数 if (progressUpdate.datasetsTotal) { newProgress.datasetsTotal = progressUpdate.datasetsTotal; } // 更新已生成数据集数 if (progressUpdate.datasetsBuilt) { if (progressUpdate.updateType === 'increment') { newProgress.datasetsBuilt += progressUpdate.datasetsBuilt; } else { newProgress.datasetsBuilt = progressUpdate.datasetsBuilt; } } // 更新多轮对话数据集总数 if (progressUpdate.multiTurnDatasetsTotal) { newProgress.multiTurnDatasetsTotal = progressUpdate.multiTurnDatasetsTotal; } // 更新已生成多轮对话数据集数 if (progressUpdate.multiTurnDatasetsBuilt) { if (progressUpdate.updateType === 'increment') { newProgress.multiTurnDatasetsBuilt += progressUpdate.multiTurnDatasetsBuilt; } else { newProgress.multiTurnDatasetsBuilt = progressUpdate.multiTurnDatasetsBuilt; } } return newProgress; }); }; // 添加日志,最多保留200条 const addLog = message => { setDistillProgress(prev => { const newLogs = [...prev.logs, message]; // 如果日志超过200条,只保留最新的200条 const limitedLogs = newLogs.length > 200 ? newLogs.slice(-200) : newLogs; return { ...prev, logs: limitedLogs }; }); }; // 关闭进度对话框 const handleCloseProgressDialog = () => { if (!autoDistillRunning) { setAutoDistillProgressOpen(false); // 刷新数据 fetchTags(); fetchDistillStats(); if (treeViewRef.current && typeof treeViewRef.current.fetchQuestionsStats === 'function') { treeViewRef.current.fetchQuestionsStats(); } } else { // 如果任务还在运行,可以展示一个确认对话框 // 这里简化处理,直接关闭 setAutoDistillProgressOpen(false); } }; if (!projectId) { return ( {t('common.projectIdRequired')} ); } return ( {t('distill.title')} { const helpUrl = i18n.language === 'en' ? 'https://docs.easy-dataset.com/ed/en/advanced/images-and-media' : 'https://docs.easy-dataset.com/jin-jie-shi-yong/images-and-media'; window.open(helpUrl, '_blank'); }} sx={{ color: 'text.secondary' }} > {error && ( setError('')}> {error} )} {loading ? ( ) : ( )} {/* 生成标签对话框 */} {tagDialogOpen && ( setTagDialogOpen(false)} onGenerated={handleTagGenerated} projectId={projectId} parentTag={selectedTag} tagPath={selectedTagPath} model={selectedModel} /> )} {/* 生成问题对话框 */} {questionDialogOpen && ( setQuestionDialogOpen(false)} onGenerated={handleQuestionGenerated} projectId={projectId} tag={selectedTag} tagPath={selectedTagPath} model={selectedModel} /> )} {/* 全自动蒸馏数据集配置对话框 */} setAutoDistillDialogOpen(false)} onStart={handleStartAutoDistill} onStartBackground={handleStartAutoDistillBackground} projectId={projectId} project={project} stats={distillStats} /> {/* 全自动蒸馏进度对话框 */} ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/[evalId]/page.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import { Box, Container, Grid, Paper, Chip, Typography, CircularProgress, Alert, Card, CardContent, Divider, Stack, RadioGroup, FormControlLabel, Radio, FormGroup, Checkbox as MuiCheckbox } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useTheme, alpha } from '@mui/material/styles'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; import ShortTextIcon from '@mui/icons-material/ShortText'; import NotesIcon from '@mui/icons-material/Notes'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import TagIcon from '@mui/icons-material/Tag'; import DescriptionIcon from '@mui/icons-material/Description'; import useEvalDatasetDetails from './useEvalDatasetDetails'; import EvalDatasetHeader from '../components/EvalDatasetHeader'; import EvalEditableField from '../components/EvalEditableField'; import TagSelector from '@/components/datasets/TagSelector'; // 题型图标和颜色映射 const QUESTION_TYPE_CONFIG = { true_false: { icon: CheckCircleIcon, color: 'success', bgColor: 'success.light' }, single_choice: { icon: RadioButtonCheckedIcon, color: 'primary', bgColor: 'primary.light' }, multiple_choice: { icon: CheckBoxIcon, color: 'secondary', bgColor: 'secondary.light' }, short_answer: { icon: ShortTextIcon, color: 'warning', bgColor: 'warning.light' }, open_ended: { icon: NotesIcon, color: 'info', bgColor: 'info.light' } }; export default function EvalDatasetDetailPage() { const { projectId, evalId } = useParams(); const { t } = useTranslation(); const theme = useTheme(); const [availableTags, setAvailableTags] = useState([]); const { data, loading, error, handleNavigate, handleSave, handleDelete } = useEvalDatasetDetails(projectId, evalId); // 获取项目中已使用的标签 useEffect(() => { const fetchAvailableTags = async () => { try { const response = await fetch(`/api/projects/${projectId}/eval-datasets/tags`); if (response.ok) { const result = await response.json(); setAvailableTags(result.tags || []); } } catch (error) { console.error('获取可用标签失败:', error); } }; if (projectId && !loading) { fetchAvailableTags(); } }, [projectId, loading]); if (loading) { return ( ); } if (error || !data) { return ( {error || t('eval.notFound')} ); } const typeConfig = QUESTION_TYPE_CONFIG[data.questionType] || QUESTION_TYPE_CONFIG.short_answer; const TypeIcon = typeConfig.icon; // 解析选项 let options = []; try { options = data.options ? (typeof data.options === 'string' ? JSON.parse(data.options) : data.options) : []; } catch (e) { options = []; } // 渲染选项预览 const renderOptionsPreview = value => { let opts = []; try { opts = value ? (typeof value === 'string' ? JSON.parse(value) : value) : []; } catch (e) { return Invalid JSON format; } if (!Array.isArray(opts) || opts.length === 0) { return {t('common.noData')}; } return ( {opts.map((option, index) => { const optionLabel = String.fromCharCode(65 + index); const isCorrect = data.questionType === 'multiple_choice' ? (Array.isArray(data.correctAnswer) ? data.correctAnswer : JSON.parse(data.correctAnswer || '[]') ).includes(optionLabel) : data.correctAnswer === optionLabel; return ( {optionLabel}. {option} {isCorrect && ( )} ); })} ); }; // 渲染答案编辑组件 const renderAnswerEditor = (currentValue, onChange) => { if (data.questionType === 'true_false') { return ( onChange(e.target.value)} row> } label={t('eval.correct')} /> } label={t('eval.wrong')} /> ); } if (data.questionType === 'single_choice') { return ( onChange(e.target.value)}> {options.map((_, index) => { const label = String.fromCharCode(65 + index); return ( } label={`${label}. ${options[index]}`} /> ); })} ); } if (data.questionType === 'multiple_choice') { const selected = Array.isArray(currentValue) ? currentValue : JSON.parse(currentValue || '[]'); const handleChange = label => { const newSelected = selected.includes(label) ? selected.filter(i => i !== label) : [...selected, label].sort(); onChange(JSON.stringify(newSelected)); }; return ( {options.map((_, index) => { const label = String.fromCharCode(65 + index); return ( handleChange(label)} />} label={`${label}. ${options[index]}`} /> ); })} ); } return null; // 简答题和开放题保持默认文本框 }; return ( {/* 左侧主要内容 */} {/* 题型标识 */} } label={t(`eval.questionTypes.${data.questionType}`)} color={typeConfig.color} sx={{ fontWeight: 600, fontSize: '0.9rem', py: 0.5, height: 32 }} /> {new Date(data.createAt).toLocaleString()} {/* 问题 */} handleSave('question', val)} placeholder={t('eval.questionPlaceholder')} /> {/* 选项 (仅选择题) */} {(data.questionType === 'single_choice' || data.questionType === 'multiple_choice') && ( handleSave('options', val)} placeholder={'["Option A", "Option B", ...]'} renderPreview={() => renderOptionsPreview(data.options)} /> )} {/* 答案 */} handleSave('correctAnswer', val)} placeholder={t('eval.answerPlaceholder')} renderEditor={(val, setVal) => renderAnswerEditor(val, setVal)} /> {/* 右侧侧边栏 */} {/* 来源信息 */} {t('eval.sourceChunk')} {data.chunks ? ( <> {data.chunks.content && ( {data.chunks.content} )} ) : ( {t('common.noData')} )} {/* 标签和备注 */} {t('eval.tags')} t.trim()) .filter(Boolean) : [] : [] } onChange={newTags => handleSave('tags', newTags.join(', '))} availableTags={availableTags} placeholder={t('eval.tagsPlaceholder')} /> {t('eval.note')} } value={data.note} onSave={val => handleSave('note', val)} placeholder={t('eval.notePlaceholder')} /> ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/[evalId]/useEvalDatasetDetails.js ================================================ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import axios from 'axios'; export default function useEvalDatasetDetails(projectId, evalId) { const router = useRouter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 编辑状态 const [editingField, setEditingField] = useState(null); // 'question', 'options', 'correctAnswer', 'note', 'tags' const [fieldValue, setFieldValue] = useState(''); // 获取详情 const fetchData = useCallback(async () => { try { setLoading(true); setError(null); const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`); if (!response.ok) { if (response.status === 404) { throw new Error('未找到该题目'); } throw new Error('获取数据失败'); } const result = await response.json(); setData(result); } catch (err) { console.error(err); setError(err.message); } finally { setLoading(false); } }, [projectId, evalId]); useEffect(() => { fetchData(); }, [fetchData]); // 导航 const handleNavigate = async direction => { try { const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=${direction}`); if (response.ok) { const neighbor = await response.json(); if (neighbor && neighbor.id) { router.push(`/projects/${projectId}/eval-datasets/${neighbor.id}`); } else { toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条数据了`); } } } catch (err) { console.error('Navigation error:', err); } }; // 开始编辑 const handleStartEdit = (field, value) => { setEditingField(field); // 对于 options,如果是数组则转为 JSON 字符串编辑,或者在组件层面处理 // 这里假设 value 已经是适合编辑的格式 setFieldValue(value); }; // 取消编辑 const handleCancelEdit = () => { setEditingField(null); setFieldValue(''); }; // 保存编辑 const handleSave = async (field, value) => { try { const response = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: value }) }); if (!response.ok) throw new Error('保存失败'); const updated = await response.json(); setData(prev => ({ ...prev, ...updated })); // 更新本地数据 setEditingField(null); toast.success('保存成功'); } catch (err) { toast.error(err.message); } }; // 删除 const handleDelete = async () => { if (!confirm('确定要删除这条数据吗?此操作不可撤销。')) return; try { // 先尝试获取下一条,以便删除后跳转 const nextResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=next`); let nextId = null; if (nextResponse.ok) { const next = await nextResponse.json(); if (next && next.id) nextId = next.id; } // 如果没有下一条,尝试获取上一条 if (!nextId) { const prevResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}?operateType=prev`); if (prevResponse.ok) { const prev = await prevResponse.json(); if (prev && prev.id) nextId = prev.id; } } // 删除 const deleteResponse = await fetch(`/api/projects/${projectId}/eval-datasets/${evalId}`, { method: 'DELETE' }); if (!deleteResponse.ok) throw new Error('删除失败'); toast.success('删除成功'); if (nextId) { router.replace(`/projects/${projectId}/eval-datasets/${nextId}`); } else { router.push(`/projects/${projectId}/eval-datasets`); } } catch (err) { toast.error(err.message); } }; return { data, loading, error, editingField, fieldValue, setFieldValue, handleNavigate, handleStartEdit, handleCancelEdit, handleSave, handleDelete }; } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/BuiltinDatasetDialog.js ================================================ 'use client'; import { useState, useMemo } from 'react'; import { Dialog, DialogContent, DialogActions, Button, Box, Typography, TextField, Card, CardActionArea, Chip, IconButton, Tooltip, InputAdornment, CircularProgress, DialogTitle, DialogContentText } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import SearchIcon from '@mui/icons-material/Search'; import StorageIcon from '@mui/icons-material/Storage'; import { useTranslation } from 'react-i18next'; import { alpha, useTheme } from '@mui/material/styles'; import { StyledDialogTitle } from './ImportDialog.styles'; import { DATA_SETS } from '../constants'; export default function BuiltinDatasetDialog({ open, onClose, projectId, onSuccess }) { const { t, i18n } = useTranslation(); const theme = useTheme(); const [keyword, setKeyword] = useState(''); const [selectedDataset, setSelectedDataset] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [downloading, setDownloading] = useState(false); const isZh = i18n.language.startsWith('zh'); // 过滤数据集 const filteredDatasets = useMemo(() => { if (!keyword) return DATA_SETS; const lowerKeyword = keyword.toLowerCase(); return DATA_SETS.filter( ds => ds.zh.toLowerCase().includes(lowerKeyword) || ds.en.toLowerCase().includes(lowerKeyword) || ds.type.toLowerCase().includes(lowerKeyword) ); }, [keyword]); const handleCardClick = dataset => { setSelectedDataset(dataset); setConfirmOpen(true); }; const handleConfirmClose = () => { setConfirmOpen(false); setSelectedDataset(null); }; const handleImport = async () => { if (!selectedDataset) return; setDownloading(true); setConfirmOpen(false); try { const cdnUrl = `https://raw.githubusercontent.com/ConardLi/easy-dataset-eval/main/${selectedDataset.file}`; const response = await fetch(cdnUrl); if (!response.ok) { throw new Error(`Failed to fetch dataset: ${response.statusText}`); } const jsonData = await response.blob(); const formData = new FormData(); const file = new File([jsonData], `${selectedDataset.en}.json`, { type: 'application/json' }); formData.append('file', file); formData.append('questionType', selectedDataset.type); const tags = `[${selectedDataset.level}] ${selectedDataset.en}`; formData.append('tags', tags); const importResponse = await fetch(`/api/projects/${projectId}/eval-datasets/import`, { method: 'POST', body: formData }); const result = await importResponse.json(); if (result.code === 0) { onSuccess?.(result.data); handleClose(); } else { console.error(result.error); alert(result.error || t('evalDatasets.import.failed')); } } catch (error) { console.error('Import failed:', error); alert(error.message || t('evalDatasets.import.failed')); } finally { setDownloading(false); setSelectedDataset(null); } }; const handleClose = () => { if (downloading) return; setKeyword(''); setSelectedDataset(null); setConfirmOpen(false); onClose(); }; return ( <> {t('evalDatasets.import.builtinTitle', '选择内置数据集')} {/* 搜索栏 */} setKeyword(e.target.value)} InputProps={{ startAdornment: ( ), sx: { borderRadius: 2 } }} /> {/* 数据集列表 */} {downloading ? ( {t('evalDatasets.import.downloading', '下载并导入中...')} ) : ( {filteredDatasets.map((ds, index) => { const difficultyColor = ds.level === 'easy' ? 'success.main' : 'warning.main'; const typeLabel = t(`eval.questionTypes.${ds.type}`, ds.type); const tooltipTitle = ( ); return ( handleCardClick(ds)} > {isZh ? ds.zh : ds.en} ); })} )} {t('evalDatasets.import.confirmImportTitle', '确认导入')} {selectedDataset && t('evalDatasets.import.confirmImportMessage', { name: isZh ? selectedDataset.zh : selectedDataset.en })} ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/EvalDatasetCard.js ================================================ 'use client'; import { Card, CardContent, Box, Typography, Chip, Checkbox, IconButton, Tooltip, Divider } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import CheckBoxIcon from '@mui/icons-material/CheckBox'; import ShortTextIcon from '@mui/icons-material/ShortText'; import NotesIcon from '@mui/icons-material/Notes'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import { useTheme, alpha } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; // 题型图标和颜色映射 const QUESTION_TYPE_CONFIG = { true_false: { icon: CheckCircleIcon, color: 'success', bgColor: 'success.light' }, single_choice: { icon: RadioButtonCheckedIcon, color: 'primary', bgColor: 'primary.light' }, multiple_choice: { icon: CheckBoxIcon, color: 'secondary', bgColor: 'secondary.light' }, short_answer: { icon: ShortTextIcon, color: 'warning', bgColor: 'warning.light' }, open_ended: { icon: NotesIcon, color: 'info', bgColor: 'info.light' } }; export default function EvalDatasetCard({ item, selected, onSelect, onEdit, onDelete, projectId }) { const theme = useTheme(); const { t } = useTranslation(); const router = useRouter(); const typeConfig = QUESTION_TYPE_CONFIG[item.questionType] || QUESTION_TYPE_CONFIG.short_answer; const TypeIcon = typeConfig.icon; // 解析选项 const options = item.options ? typeof item.options === 'string' ? JSON.parse(item.options || '[]') : item.options : []; // 解析答案 const correctAnswer = item.correctAnswer; const handleCardClick = e => { // 如果点击的是复选框或按钮,不跳转 if (e.target.closest('.MuiCheckbox-root') || e.target.closest('.MuiIconButton-root')) { return; } router.push(`/projects/${projectId}/eval-datasets/${item.id}`); }; return ( {/* 头部:题型标签和操作 */} { e.stopPropagation(); onSelect(item.id); }} sx={{ p: 0.5, ml: -0.5 }} /> } label={t(`eval.questionTypes.${item.questionType}`)} size="small" color={typeConfig.color} variant="outlined" sx={{ fontWeight: 600, borderWidth: '1.5px', bgcolor: alpha(theme.palette[typeConfig.color].main, 0.05) }} /> { e.stopPropagation(); onEdit(item); }} sx={{ color: 'text.secondary', '&:hover': { color: 'primary.main', bgcolor: alpha(theme.palette.primary.main, 0.1) } }} > { e.stopPropagation(); onDelete(item.id); }} sx={{ color: 'text.secondary', '&:hover': { color: 'error.main', bgcolor: alpha(theme.palette.error.main, 0.1) } }} > {/* 问题内容 */} {item.questionType === 'true_false' && correctAnswer} {item.question} {/* 选项列表(仅单选/多选显示) */} {(item.questionType === 'single_choice' || item.questionType === 'multiple_choice') && options.length > 0 && ( {(item.questionType === 'multiple_choice' ? options : options.slice(0, 4)).map((option, index) => { const optionLabel = String.fromCharCode(65 + index); // A, B, C, D // 解析多选题答案,支持多种格式:数组、JSON字符串、逗号分隔字符串 const parseMultipleAnswers = answer => { if (Array.isArray(answer)) return answer; if (!answer) return []; // 尝试解析 JSON 数组 if (answer.startsWith('[')) { try { return JSON.parse(answer); } catch (e) { return []; } } // 逗号分隔字符串格式,如 "A,B,D" return answer.split(',').map(s => s.trim()); }; const isCorrect = item.questionType === 'multiple_choice' ? parseMultipleAnswers(correctAnswer).includes(optionLabel) : correctAnswer === optionLabel; return ( {optionLabel}. {option} ); })} {item.questionType === 'single_choice' && options.length > 4 && ( ... +{options.length - 4} {t('eval.moreOptions')} )} )} {/* 非选择题且非判断题答案 */} {item.questionType !== 'single_choice' && item.questionType !== 'multiple_choice' && item.questionType !== 'true_false' && correctAnswer && ( {t('eval.answer')}: {correctAnswer} )} {/* 底部元信息 */} {item.chunks ? ( ) : ( )} {item.tags && ( {item.tags .split(/[,,]/) .slice(0, 2) .map((tag, index) => ( ))} {item.tags.split(/[,,]/).length > 2 && ( +{item.tags.split(/[,,]/).length - 2} )} )} ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/EvalDatasetHeader.js ================================================ 'use client'; import { Box, Button, Divider, Typography, IconButton, Paper, Tooltip } from '@mui/material'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import DeleteIcon from '@mui/icons-material/Delete'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; export default function EvalDatasetHeader({ projectId, onNavigate, onDelete }) { const router = useRouter(); const { t } = useTranslation(); return ( {t('eval.detail')} onNavigate('prev')} title={t('common.prev')}> onNavigate('next')} title={t('common.next')}> ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/EvalDatasetList.js ================================================ 'use client'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Checkbox, IconButton, Chip, Typography, Tooltip, Box } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import VisibilityIcon from '@mui/icons-material/Visibility'; import { useTranslation } from 'react-i18next'; export default function EvalDatasetList({ items, selectedIds, onSelect, onSelectAll, onEdit, onDelete, onView }) { const { t } = useTranslation(); const isAllSelected = items.length > 0 && selectedIds.length === items.length; const isIndeterminate = selectedIds.length > 0 && selectedIds.length < items.length; // 题型颜色映射 const getTypeColor = type => { const colors = { true_false: 'success', single_choice: 'primary', multiple_choice: 'secondary', short_answer: 'warning', open_ended: 'info' }; return colors[type] || 'default'; }; // 格式化答案显示 const formatAnswer = item => { const { questionType, correctAnswer, options } = item; if (questionType === 'true_false') { return correctAnswer; } if (questionType === 'single_choice' || questionType === 'multiple_choice') { return correctAnswer; } // 非选择题,截断显示 if (correctAnswer && correctAnswer.length > 50) { return correctAnswer.substring(0, 50) + '...'; } return correctAnswer || '-'; }; return ( {t('eval.questionType')} {t('eval.question')} {t('eval.answer')} {t('eval.sourceChunk')} {t('common.actions')} {items.map(item => ( onSelect(item.id)} /> {item.question} {formatAnswer(item)} {item.chunks ? ( ) : ( - )} onView(item)}> onDelete(item.id)}> ))} {items.length === 0 && ( {t('common.noData')} )}
); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/EvalEditableField.js ================================================ 'use client'; import { useState } from 'react'; import { Box, Typography, Button, TextField, IconButton, Paper } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; import { useTheme, alpha } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; export default function EvalEditableField({ label, value, multiline = true, onSave, placeholder, renderPreview, // Optional custom preview renderer renderEditor // Optional custom editor renderer (currentValue, onChange) => ReactNode }) { const { t } = useTranslation(); const theme = useTheme(); const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(''); const handleStartEdit = () => { setEditValue(value || ''); setEditing(true); }; const handleCancel = () => { setEditing(false); setEditValue(''); }; const handleSave = async () => { if (onSave) { await onSave(editValue); } setEditing(false); }; return ( {label} {!editing && ( )} {editing ? ( {renderEditor && renderEditor(editValue, setEditValue) ? ( {renderEditor(editValue, setEditValue)} ) : ( setEditValue(e.target.value)} placeholder={placeholder} variant="outlined" size="small" sx={{ mb: 2 }} /> )} ) : ( {renderPreview ? ( renderPreview(value) ) : ( {value || t('common.noData')} )} )} ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/EvalToolbar.js ================================================ 'use client'; import { Box, IconButton, ToggleButton, Tooltip, Divider, Autocomplete, TextField, Menu, MenuItem } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewListIcon from '@mui/icons-material/ViewList'; import DeleteIcon from '@mui/icons-material/DeleteOutline'; // 使用 Outline 版本更精致 import CheckCircleIcon from '@mui/icons-material/CheckCircleOutline'; // 统一使用 Outline 风格图标 import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import CheckBoxIcon from '@mui/icons-material/CheckBoxOutlineBlank'; // 或者 CheckBox import ShortTextIcon from '@mui/icons-material/ShortText'; import NotesIcon from '@mui/icons-material/Notes'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import StorageIcon from '@mui/icons-material/Storage'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { useTranslation } from 'react-i18next'; import { useTheme, alpha } from '@mui/material/styles'; import { useState } from 'react'; import { ToolbarContainer, FilterGroup, FilterButton, SearchWrapper, StyledInputBase, ActionGroup, ActionButton, DeleteActionButton, StyledToggleButtonGroup } from './EvalToolbar.styles'; const STATS_CONFIG = [ { key: 'true_false', icon: CheckCircleIcon, color: 'success' }, { key: 'single_choice', icon: RadioButtonCheckedIcon, color: 'primary' }, { key: 'multiple_choice', icon: CheckBoxIcon, color: 'secondary' }, { key: 'short_answer', icon: ShortTextIcon, color: 'warning' }, { key: 'open_ended', icon: NotesIcon, color: 'info' } ]; export default function EvalToolbar({ keyword, onKeywordChange, viewMode, onViewModeChange, selectedCount, onDeleteSelected, stats, questionType, onTypeChange, tags, onTagsChange, onImport, onBuiltinImport, onExport }) { const { t } = useTranslation(); const theme = useTheme(); const [importAnchorEl, setImportAnchorEl] = useState(null); const handleImportClick = event => { setImportAnchorEl(event.currentTarget); }; const handleImportClose = () => { setImportAnchorEl(null); }; const handleCustomImport = () => { handleImportClose(); onImport?.(); }; const handleBuiltinImport = () => { handleImportClose(); onBuiltinImport?.(); }; const tagOptions = stats?.byTag ? Object.keys(stats.byTag).map(tag => ({ label: tag, count: stats.byTag[tag] })) : []; return ( {/* 顶部:题型统计筛选 */} {stats && STATS_CONFIG.map(({ key, icon: Icon, color }) => { const count = stats.byType?.[key] || 0; const isActive = questionType === key; return ( } active={isActive} colorType={color} onClick={() => onTypeChange(isActive ? '' : key)} > {t(`eval.questionTypes.${key}`)} ({count}) ); })} {/* 底部:筛选和操作 */} {/* 左侧:筛选器组 */} {/* 搜索框 */} onKeywordChange(e.target.value)} /> {/* 标签筛选 */} `${option.label} (${option.count})`} value={tagOptions.filter(o => tags.includes(o.label))} onChange={(e, newValue) => onTagsChange(newValue.map(v => v.label))} renderInput={params => ( )} sx={{ '& .MuiAutocomplete-tag': { height: 24, borderRadius: 1 } }} /> {/* 右侧:操作按钮组 */} {/* 导入按钮下拉菜单 */} } endIcon={} onClick={handleImportClick} > {t('common.import', '导入')} {t('evalDatasets.import.custom', '导入自定义数据集')} {t('evalDatasets.import.builtin', '导入内置数据集')} {/* 导出按钮 */} } onClick={onExport}> {t('common.export', '导出')} {selectedCount > 0 && ( } onClick={onDeleteSelected}> {t('eval.deleteSelectedCount', `删除选中 (${selectedCount})`, { count: selectedCount })} )} value && onViewModeChange(value)} size="small" > ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/EvalToolbar.styles.js ================================================ import { styled, alpha } from '@mui/material/styles'; import { Box, Paper, Button, ToggleButton, ToggleButtonGroup, InputBase } from '@mui/material'; export const ToolbarContainer = styled(Paper)(({ theme }) => ({ padding: theme.spacing(2, 2.5), marginBottom: theme.spacing(3), borderRadius: theme.shape.borderRadius * 2, border: `1px solid ${theme.palette.divider}`, backgroundColor: theme.palette.background.paper, boxShadow: '0 2px 12px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', gap: theme.spacing(2) })); export const FilterGroup = styled(Box)(({ theme }) => ({ display: 'flex', alignItems: 'center', gap: theme.spacing(1.5), flexWrap: 'wrap' })); export const FilterButton = styled(Button, { shouldForwardProp: prop => prop !== 'active' && prop !== 'colorType' })(({ theme, active, colorType }) => { const colorMap = { success: theme.palette.success, primary: theme.palette.primary, secondary: theme.palette.secondary, warning: theme.palette.warning, info: theme.palette.info }; const mainColor = colorMap[colorType] || theme.palette.primary; return { padding: theme.spacing(0.75, 2), borderRadius: theme.shape.borderRadius * 5, // Pill shape border: '1px solid', borderColor: active ? mainColor.main : theme.palette.divider, backgroundColor: active ? alpha(mainColor.main, 0.1) : 'transparent', color: active ? mainColor.main : theme.palette.text.secondary, fontSize: '0.875rem', fontWeight: active ? 600 : 400, minWidth: 'auto', textTransform: 'none', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { backgroundColor: active ? alpha(mainColor.main, 0.15) : alpha(theme.palette.text.primary, 0.04), borderColor: active ? mainColor.main : theme.palette.text.secondary, transform: 'translateY(-1px)' }, '& .MuiButton-startIcon': { marginRight: theme.spacing(0.8), color: active ? mainColor.main : theme.palette.text.disabled, width: 18, height: 18 } }; }); export const SearchWrapper = styled(Paper)(({ theme }) => ({ padding: '2px 4px', display: 'flex', alignItems: 'center', width: 280, height: 42, borderRadius: theme.shape.borderRadius * 1.5, border: `1px solid ${theme.palette.divider}`, backgroundColor: theme.palette.background.paper, boxShadow: 'none', transition: 'all 0.2s ease', '&:hover': { borderColor: theme.palette.text.secondary, backgroundColor: alpha(theme.palette.action.hover, 0.05) }, '&:focus-within': { borderColor: theme.palette.primary.main, boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`, backgroundColor: theme.palette.background.paper } })); export const StyledInputBase = styled(InputBase)(({ theme }) => ({ marginLeft: theme.spacing(1), flex: 1, fontSize: '0.875rem', '& input': { '&::placeholder': { color: theme.palette.text.disabled, opacity: 1 } } })); export const ActionGroup = styled(Box)(({ theme }) => ({ display: 'flex', alignItems: 'center', gap: theme.spacing(1.5) })); export const ActionButton = styled(Button)(({ theme }) => ({ borderRadius: theme.shape.borderRadius * 1.5, height: 40, paddingLeft: theme.spacing(3), paddingRight: theme.spacing(3), borderColor: theme.palette.divider, color: theme.palette.text.secondary, '&:hover': { borderColor: theme.palette.text.primary, color: theme.palette.text.primary, backgroundColor: theme.palette.action.hover } })); export const DeleteActionButton = styled(Button)(({ theme }) => ({ borderRadius: theme.shape.borderRadius * 1.5, height: 40, paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), backgroundColor: alpha(theme.palette.error.main, 0.1), color: theme.palette.error.main, '&:hover': { backgroundColor: alpha(theme.palette.error.main, 0.2) } })); export const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ height: 40, backgroundColor: theme.palette.action.hover, // Slightly darker than paper padding: 4, borderRadius: theme.shape.borderRadius * 1.5, border: 'none', gap: 4, '& .MuiToggleButton-root': { border: 'none', borderRadius: theme.shape.borderRadius, width: 36, color: theme.palette.text.secondary, '&.Mui-selected': { backgroundColor: theme.palette.background.paper, color: theme.palette.primary.main, boxShadow: '0 2px 4px rgba(0,0,0,0.05)', '&:hover': { backgroundColor: theme.palette.background.paper } }, '&:hover': { backgroundColor: 'rgba(0,0,0,0.04)' } } })); ================================================ FILE: app/projects/[projectId]/eval-datasets/components/ExportEvalDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, TextField, FormControl, InputLabel, Select, MenuItem, Chip, OutlinedInput, Checkbox, ListItemText, Alert, CircularProgress, IconButton, ToggleButton, ToggleButtonGroup, Divider } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; import FilterAltIcon from '@mui/icons-material/FilterAlt'; import ClearIcon from '@mui/icons-material/Clear'; import { useTranslation } from 'react-i18next'; const QUESTION_TYPES = [ { value: 'true_false', labelKey: 'eval.questionTypes.true_false' }, { value: 'single_choice', labelKey: 'eval.questionTypes.single_choice' }, { value: 'multiple_choice', labelKey: 'eval.questionTypes.multiple_choice' }, { value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' }, { value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' } ]; const EXPORT_FORMATS = [ { value: 'json', label: 'JSON', description: 'evalDatasets.export.jsonDesc' }, { value: 'jsonl', label: 'JSONL', description: 'evalDatasets.export.jsonlDesc' }, { value: 'csv', label: 'CSV', description: 'evalDatasets.export.csvDesc' } ]; export default function ExportEvalDialog({ open, onClose, exporting, error, format, setFormat, questionTypes, setQuestionTypes, selectedTags, setSelectedTags, keyword, setKeyword, previewTotal, previewLoading, availableTags, resetFilters, onExport }) { const { t } = useTranslation(); const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || keyword; return ( {t('evalDatasets.export.title', '导出评估数据集')} {error && ( {}}> {error} )} {/* 导出格式选择 */} {t('evalDatasets.export.formatLabel', '导出格式')} newFormat && setFormat(newFormat)} fullWidth size="small" > {EXPORT_FORMATS.map(f => ( {f.label} {t(f.description, f.label)} ))} {/* 筛选条件 */} {t('evalDatasets.export.filterLabel', '筛选条件')} {hasFilters && ( )} {/* 关键字搜索 */} setKeyword(e.target.value)} /> {/* 题型和标签筛选 */} {/* 题型筛选 */} {t('evalTasks.filterByTypeLabel', '题型筛选')} {/* 标签筛选 */} {t('evalTasks.filterByTagLabel', '标签筛选')} {/* 导出预览 */} {t('evalDatasets.export.previewLabel', '将导出数据:')} {previewLoading ? ( ) : ( {previewTotal} {t('evalDatasets.export.records', '条记录')} )} {previewTotal > 1000 && ( {t('evalDatasets.export.largeDataHint', '数据量较大,将采用流式导出,请耐心等待')} )} ); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/ImportDialog.js ================================================ 'use client'; import { useState, useRef } from 'react'; import { Dialog, DialogContent, DialogActions, Button, Box, Typography, TextField, Alert, LinearProgress, Chip, IconButton, Radio } from '@mui/material'; import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import DownloadIcon from '@mui/icons-material/Download'; import CloseIcon from '@mui/icons-material/Close'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import { useTranslation } from 'react-i18next'; import * as XLSX from 'xlsx'; import { QUESTION_TYPES, FORMAT_PREVIEW, getJsonTemplateData, getExcelTemplateData, getColumnWidths } from '../constants'; import { StyledDialogTitle, UploadBox, PreviewPaper, CodeBlock, ErrorContainer, TypeRadioGroup, TypeFormControlLabel } from './ImportDialog.styles'; export default function ImportDialog({ open, onClose, projectId, onSuccess }) { const { t } = useTranslation(); const fileInputRef = useRef(null); const [questionType, setQuestionType] = useState('open_ended'); const [tags, setTags] = useState(''); const [file, setFile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [errorDetails, setErrorDetails] = useState([]); // 处理文件选择 const handleFileChange = e => { const selectedFile = e.target.files[0]; if (selectedFile) { const ext = selectedFile.name.split('.').pop().toLowerCase(); if (!['json', 'xls', 'xlsx'].includes(ext)) { setError(t('evalDatasets.import.invalidFileType', '不支持的文件格式,请上传 json、xls 或 xlsx 文件')); return; } setFile(selectedFile); setError(null); setErrorDetails([]); } }; // 下载模板 const handleDownloadTemplate = format => { if (!questionType) { setError(t('evalDatasets.import.selectTypeFirst', '请先选择题型')); return; } if (format === 'json') { // JSON 模板动态生成并下载 const templateData = getJsonTemplateData(questionType); const jsonContent = JSON.stringify(templateData, null, 2); const blob = new Blob([jsonContent], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `eval-dataset-template-${questionType}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } else { // Excel 模板动态生成 const templateData = getExcelTemplateData(questionType); const worksheet = XLSX.utils.json_to_sheet(templateData); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, 'Template'); // 设置列宽 const colWidths = getColumnWidths(questionType); worksheet['!cols'] = colWidths; // 下载文件 XLSX.writeFile(workbook, `eval-dataset-template-${questionType}.xlsx`); } }; // 提交导入 const handleSubmit = async () => { if (!questionType) { setError(t('evalDatasets.import.selectTypeFirst', '请先选择题型')); return; } if (!file) { setError(t('evalDatasets.import.selectFile', '请选择要导入的文件')); return; } setLoading(true); setError(null); setErrorDetails([]); try { const formData = new FormData(); formData.append('file', file); formData.append('questionType', questionType); formData.append('tags', tags); const response = await fetch(`/api/projects/${projectId}/eval-datasets/import`, { method: 'POST', body: formData }); const result = await response.json(); if (result.code === 0) { onSuccess?.(result.data); handleClose(); } else { setError(result.error || result.message); if (result.details) { setErrorDetails(result.details); } } } catch (err) { setError(err.message || t('evalDatasets.import.failed', '导入失败')); } finally { setLoading(false); } }; // 关闭对话框 const handleClose = () => { if (loading) return; setQuestionType('open_ended'); setTags(''); setFile(null); setError(null); setErrorDetails([]); onClose(); }; // 获取当前题型的格式预览 const formatPreview = questionType ? FORMAT_PREVIEW[questionType] : null; return ( {t('evalDatasets.import.title', '导入评估数据集')} {loading && } {/* 错误提示 */} {error && ( {error} {errorDetails.length > 0 && ( {errorDetails.map((detail, index) => ( {detail} ))} {errorDetails.length < 10 && ( {t('evalDatasets.import.showingErrors', '显示前 {{count}} 条错误', { count: errorDetails.length })} )} )} )} {/* 题型选择 - 使用封装好的样式组件 */} {t('evalDatasets.import.questionType', '选择题型')} setQuestionType(e.target.value)}> {QUESTION_TYPES.map(type => ( } label={t(type.label, type.labelZh)} /> ))} {/* 数据格式预览 */} {formatPreview && ( {t('evalDatasets.import.formatPreview', '数据格式预览')} {formatPreview.fields.map(field => ( ))} {formatPreview.description}
{JSON.stringify(formatPreview.example, null, 2)}
{/* 下载模板按钮 */}
)} {/* 文件上传 */} fileInputRef.current?.click()}> {file ? ( {file.name} {t('common.clickToReplace', '点击更换文件')} ) : ( {t('evalDatasets.import.dropOrClick', '点击或拖拽文件到此处')} {t('evalDatasets.import.supportedFormats', '支持 JSON、XLS、XLSX 格式')} )} {/* 标签输入 */} setTags(e.target.value)} disabled={loading} helperText={t('evalDatasets.import.tagsHelp', '导入的所有数据将打上这些标签')} InputProps={{ startAdornment: tags ? # : null }} />
); } ================================================ FILE: app/projects/[projectId]/eval-datasets/components/ImportDialog.styles.js ================================================ import { styled, alpha } from '@mui/material/styles'; import { Box, Paper, DialogTitle as MuiDialogTitle, RadioGroup, FormControlLabel } from '@mui/material'; export const StyledDialogTitle = styled(MuiDialogTitle)(({ theme }) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: theme.spacing(2, 3), borderBottom: `1px solid ${theme.palette.divider}`, '& .MuiTypography-root': { fontWeight: 600, fontSize: '1.1rem' } })); export const TypeRadioGroup = styled(RadioGroup)(({ theme }) => ({ display: 'flex', flexDirection: 'row', gap: theme.spacing(2) })); export const TypeFormControlLabel = styled(FormControlLabel, { shouldForwardProp: prop => prop !== 'checked' })(({ theme, checked }) => ({ margin: 0, padding: '4px 12px', borderRadius: '8px', border: '1px solid', borderColor: checked ? theme.palette.primary.main : theme.palette.divider, backgroundColor: checked ? alpha(theme.palette.primary.main, 0.05) : 'transparent', transition: 'all 0.2s', '&:hover': { backgroundColor: checked ? alpha(theme.palette.primary.main, 0.08) : theme.palette.action.hover }, '& .MuiTypography-root': { fontSize: '0.875rem', color: checked ? theme.palette.primary.main : theme.palette.text.primary, fontWeight: checked ? 600 : 400 }, '& .MuiRadio-root': { padding: '4px', color: checked ? theme.palette.primary.main : theme.palette.text.secondary } })); export const UploadBox = styled(Box, { shouldForwardProp: prop => prop !== 'active' && prop !== 'hasFile' })(({ theme, active, hasFile }) => ({ border: '2px dashed', borderColor: active ? theme.palette.primary.main : theme.palette.grey[300], borderRadius: theme.shape.borderRadius * 2, padding: theme.spacing(4), textAlign: 'center', cursor: 'pointer', backgroundColor: active ? alpha(theme.palette.primary.main, 0.05) : hasFile ? alpha(theme.palette.primary.main, 0.05) : 'transparent', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { borderColor: theme.palette.primary.main, backgroundColor: alpha(theme.palette.primary.main, 0.02), transform: 'translateY(-1px)', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }, '& svg': { fontSize: 48, marginBottom: theme.spacing(1), color: active ? theme.palette.primary.main : theme.palette.grey[400], transition: 'color 0.3s ease' } })); export const PreviewPaper = styled(Paper)(({ theme }) => ({ padding: theme.spacing(2.5), marginBottom: theme.spacing(3), backgroundColor: theme.palette.grey[50], border: `1px solid ${theme.palette.divider}`, borderRadius: theme.shape.borderRadius * 1.5, '& .title': { display: 'flex', alignItems: 'center', gap: theme.spacing(1), marginBottom: theme.spacing(1.5), color: theme.palette.text.primary, fontWeight: 600 } })); export const CodeBlock = styled(Box)(({ theme }) => ({ backgroundColor: '#1e1e1e', // Dark theme for code color: '#d4d4d4', padding: theme.spacing(2), borderRadius: theme.shape.borderRadius, fontFamily: '"Fira Code", "Roboto Mono", monospace', fontSize: '0.85rem', overflow: 'auto', maxHeight: 300, '&::-webkit-scrollbar': { height: 8, width: 8 }, '&::-webkit-scrollbar-track': { backgroundColor: '#2d2d2d' }, '&::-webkit-scrollbar-thumb': { backgroundColor: '#555', borderRadius: 4 } })); export const ErrorContainer = styled(Box)(({ theme }) => ({ marginTop: theme.spacing(1), fontSize: '0.85rem', maxHeight: 200, overflowY: 'auto', '& .item': { padding: theme.spacing(0.5, 0), color: theme.palette.error.main, display: 'flex', alignItems: 'flex-start', gap: theme.spacing(1), '&::before': { content: '"•"', fontWeight: 'bold' } } })); export const TagInputWrapper = styled(Box)(({ theme }) => ({ // Custom styles for tag input area if needed })); ================================================ FILE: app/projects/[projectId]/eval-datasets/constants.js ================================================ export const QUESTION_TYPES = [ { value: 'true_false', label: 'eval.questionTypes.true_false', labelZh: '判断题' }, { value: 'single_choice', label: 'eval.questionTypes.single_choice', labelZh: '单选题' }, { value: 'multiple_choice', label: 'eval.questionTypes.multiple_choice', labelZh: '多选题' }, { value: 'short_answer', label: 'eval.questionTypes.short_answer', labelZh: '短答案题' }, { value: 'open_ended', label: 'eval.questionTypes.open_ended', labelZh: '开放式问题' } ]; export const FORMAT_PREVIEW = { true_false: { fields: ['question', 'correctAnswer'], example: { question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅ or ❌' }, description: 'correctAnswer must be "✅" (correct) or "❌" (incorrect)' }, single_choice: { fields: ['question', 'options', 'correctAnswer'], example: { question: 'Which of the following is a core feature of deep learning?', options: '["Option A", "Option B", "Option C", "Option D"]', correctAnswer: 'B' }, description: 'options is an array of options, correctAnswer is the letter of the correct option (A/B/C/D)' }, multiple_choice: { fields: ['question', 'options', 'correctAnswer'], example: { question: 'Which of the following are commonly used deep learning frameworks?', options: '["TensorFlow", "PyTorch", "Excel", "Keras"]', correctAnswer: '["A", "B", "D"]' }, description: 'options is an array of options, correctAnswer is an array of correct option letters' }, short_answer: { fields: ['question', 'correctAnswer'], example: { question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' }, description: 'correctAnswer is a short standard answer' }, open_ended: { fields: ['question', 'correctAnswer'], example: { question: 'Analyze the main reasons for the success of deep learning in computer vision.', correctAnswer: 'Reference answer content...' }, description: 'correctAnswer is a reference answer (can be long)' } }; // 获取 JSON 模板数据 export const getJsonTemplateData = type => { switch (type) { case 'true_false': return [ { question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅' }, { question: 'Deep learning does not require large amounts of data for training', correctAnswer: '❌' } ]; case 'single_choice': return [ { question: 'What is the core feature of deep learning?', options: [ 'Requires manual feature engineering', 'Automatic feature learning', 'Only handles structured data', 'Does not need large amounts of data' ], correctAnswer: 'B' }, { question: 'Which of the following is a commonly used deep learning framework?', options: ['Excel', 'Word', 'TensorFlow', 'PowerPoint'], correctAnswer: 'C' } ]; case 'multiple_choice': return [ { question: 'Which of the following are commonly used deep learning frameworks?', options: ['TensorFlow', 'PyTorch', 'Excel', 'Keras', 'Word'], correctAnswer: ['A', 'B', 'D'] }, { question: 'Which of the following are main types of machine learning?', options: ['Supervised Learning', 'Unsupervised Learning', 'Reinforcement Learning', 'Manual Learning'], correctAnswer: ['A', 'B', 'C'] } ]; case 'short_answer': return [ { question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' }, { question: 'What is the maximum sample size mentioned in the text?', correctAnswer: '1000' } ]; case 'open_ended': return [ { question: 'Analyze the main reasons for the success of deep learning in computer vision.', correctAnswer: 'The success of deep learning in computer vision can be explained from three dimensions: models, data, and computing power...' }, { question: 'Explain the overfitting problem in machine learning and its solutions.', correctAnswer: 'Overfitting refers to the phenomenon where a model performs well on training data but poorly on new data...' } ]; default: return []; } }; // 获取 Excel 模板数据 export const getExcelTemplateData = type => { switch (type) { case 'true_false': return [ { question: 'Artificial Intelligence is a branch of computer science', correctAnswer: '✅' }, { question: 'Deep learning does not require large amounts of data for training', correctAnswer: '❌' } ]; case 'single_choice': return [ { question: 'What is the core feature of deep learning?', options: `["Requires manual feature engineering", "Automatic feature learning", "Only handles structured data", "Does not need large amounts of data"]`, correctAnswer: 'B' }, { question: 'Which of the following is a commonly used deep learning framework?', options: `["Excel", "Word", "TensorFlow", "PowerPoint"]`, correctAnswer: 'C' } ]; case 'multiple_choice': return [ { question: 'Which of the following are commonly used deep learning frameworks?', options: `["TensorFlow", "PyTorch", "Excel", "Keras", "Word"]`, correctAnswer: `["A", "B", "D"]` }, { question: 'Which of the following are main types of machine learning?', options: `["Supervised Learning", "Unsupervised Learning", "Reinforcement Learning", "Manual Learning"]`, correctAnswer: `["A", "B", "C"]` } ]; case 'short_answer': return [ { question: 'What is the typical model structure used in deep learning?', correctAnswer: 'Neural Network' }, { question: 'What is the maximum sample size mentioned in the text?', correctAnswer: '1000' } ]; case 'open_ended': return [ { question: 'Analyze the main reasons for the success of deep learning in computer vision.', correctAnswer: 'The success of deep learning in computer vision can be explained from three dimensions: models, data, and computing power...' }, { question: 'Explain the overfitting problem in machine learning and its solutions.', correctAnswer: 'Overfitting refers to the phenomenon where a model performs well on training data but poorly on new data...' } ]; default: return []; } }; // 获取列宽配置 export const getColumnWidths = type => { if (type === 'single_choice' || type === 'multiple_choice') { return [{ wch: 50 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 25 }, { wch: 15 }]; } return [{ wch: 60 }, { wch: 40 }]; }; export const DATA_SETS = [ { zh: '生物学', en: 'Biology', file: 'mmlu-pro/biology.json', level: 'hard', type: 'single_choice' }, { zh: '商业', en: 'Business', file: 'mmlu-pro/business.json', level: 'hard', type: 'single_choice' }, { zh: '化学', en: 'Chemistry', file: 'mmlu-pro/chemistry.json', level: 'hard', type: 'single_choice' }, { zh: '计算机科学', en: 'Computer Science', file: 'mmlu-pro/computer_science.json', level: 'hard', type: 'single_choice' }, { zh: '经济学', en: 'Economics', file: 'mmlu-pro/economics.json', level: 'hard', type: 'single_choice' }, { zh: '工程学', en: 'Engineering', file: 'mmlu-pro/engineering.json', level: 'hard', type: 'single_choice' }, { zh: '健康科学', en: 'Health', file: 'mmlu-pro/health.json', level: 'hard', type: 'single_choice' }, { zh: '历史', en: 'History', file: 'mmlu-pro/history.json', level: 'hard', type: 'single_choice' }, { zh: '法律', en: 'Law', file: 'mmlu-pro/law.json', level: 'hard', type: 'single_choice' }, { zh: '数学', en: 'Math', file: 'mmlu-pro/math.json', level: 'hard', type: 'single_choice' }, { zh: '其他', en: 'Other', file: 'mmlu-pro/other.json', level: 'hard', type: 'single_choice' }, { zh: '哲学', en: 'Philosophy', file: 'mmlu-pro/philosophy.json', level: 'hard', type: 'single_choice' }, { zh: '物理', en: 'Physics', file: 'mmlu-pro/physics.json', level: 'hard', type: 'single_choice' }, { zh: '心理学', en: 'Psychology', file: 'mmlu-pro/psychology.json', level: 'hard', type: 'single_choice' }, { zh: '抽象代数', en: 'Abstract Algebra', file: 'mmlu/abstract_algebra_test.json', level: 'easy', type: 'single_choice' }, { zh: '解剖学', en: 'Anatomy', file: 'mmlu/anatomy_test.json', level: 'easy', type: 'single_choice' }, { zh: '天文学', en: 'Astronomy', file: 'mmlu/astronomy_test.json', level: 'easy', type: 'single_choice' }, { zh: '商业伦理', en: 'Business Ethics', file: 'mmlu/business_ethics_test.json', level: 'easy', type: 'single_choice' }, { zh: '临床知识', en: 'Clinical Knowledge', file: 'mmlu/clinical_knowledge_test.json', level: 'easy', type: 'single_choice' }, { zh: '大学生物', en: 'College Biology', file: 'mmlu/college_biology_test.json', level: 'easy', type: 'single_choice' }, { zh: '大学化学', en: 'College Chemistry', file: 'mmlu/college_chemistry_test.json', level: 'easy', type: 'single_choice' }, { zh: '大学计算机科学', en: 'College Computer Science', file: 'mmlu/college_computer_science_test.json', level: 'easy', type: 'single_choice' }, { zh: '大学数学', en: 'College Mathematics', file: 'mmlu/college_mathematics_test.json', level: 'easy', type: 'single_choice' }, { zh: '大学医学', en: 'College Medicine', file: 'mmlu/college_medicine_test.json', level: 'easy', type: 'single_choice' }, { zh: '大学物理', en: 'College Physics', file: 'mmlu/college_physics_test.json', level: 'easy', type: 'single_choice' }, { zh: '计算机安全', en: 'Computer Security', file: 'mmlu/computer_security_test.json', level: 'easy', type: 'single_choice' }, { zh: '概念物理', en: 'Conceptual Physics', file: 'mmlu/conceptual_physics_test.json', level: 'easy', type: 'single_choice' }, { zh: '计量经济学', en: 'Econometrics', file: 'mmlu/econometrics_test.json', level: 'easy', type: 'single_choice' }, { zh: '电气工程', en: 'Electrical Engineering', file: 'mmlu/electrical_engineering_test.json', level: 'easy', type: 'single_choice' }, { zh: '初等数学', en: 'Elementary Mathematics', file: 'mmlu/elementary_mathematics_test.json', level: 'easy', type: 'single_choice' }, { zh: '形式逻辑', en: 'Formal Logic', file: 'mmlu/formal_logic_test.json', level: 'easy', type: 'single_choice' }, { zh: '全球事实', en: 'Global Facts', file: 'mmlu/global_facts_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中生物', en: 'High School Biology', file: 'mmlu/high_school_biology_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中化学', en: 'High School Chemistry', file: 'mmlu/high_school_chemistry_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中计算机科学', en: 'High School Computer Science', file: 'mmlu/high_school_computer_science_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中欧洲历史', en: 'High School European History', file: 'mmlu/high_school_european_history_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中地理', en: 'High School Geography', file: 'mmlu/high_school_geography_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中政府与政治', en: 'High School Government And Politics', file: 'mmlu/high_school_government_and_politics_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中宏观经济学', en: 'High School Macroeconomics', file: 'mmlu/high_school_macroeconomics_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中数学', en: 'High School Mathematics', file: 'mmlu/high_school_mathematics_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中微观经济学', en: 'High School Microeconomics', file: 'mmlu/high_school_microeconomics_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中物理', en: 'High School Physics', file: 'mmlu/high_school_physics_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中心理学', en: 'High School Psychology', file: 'mmlu/high_school_psychology_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中统计学', en: 'High School Statistics', file: 'mmlu/high_school_statistics_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中美国历史', en: 'High School Us History', file: 'mmlu/high_school_us_history_test.json', level: 'easy', type: 'single_choice' }, { zh: '高中世界历史', en: 'High School World History', file: 'mmlu/high_school_world_history_test.json', level: 'easy', type: 'single_choice' }, { zh: '人类衰老', en: 'Human Aging', file: 'mmlu/human_aging_test.json', level: 'easy', type: 'single_choice' }, { zh: '人类性学', en: 'Human Sexuality', file: 'mmlu/human_sexuality_test.json', level: 'easy', type: 'single_choice' }, { zh: '国际法', en: 'International Law', file: 'mmlu/international_law_test.json', level: 'easy', type: 'single_choice' }, { zh: '法理学', en: 'Jurisprudence', file: 'mmlu/jurisprudence_test.json', level: 'easy', type: 'single_choice' }, { zh: '逻辑谬误', en: 'Logical Fallacies', file: 'mmlu/logical_fallacies_test.json', level: 'easy', type: 'single_choice' }, { zh: '机器学习', en: 'Machine Learning', file: 'mmlu/machine_learning_test.json', level: 'easy', type: 'single_choice' }, { zh: '管理学', en: 'Management', file: 'mmlu/management_test.json', level: 'easy', type: 'single_choice' }, { zh: '市场营销', en: 'Marketing', file: 'mmlu/marketing_test.json', level: 'easy', type: 'single_choice' }, { zh: '医学遗传学', en: 'Medical Genetics', file: 'mmlu/medical_genetics_test.json', level: 'easy', type: 'single_choice' }, { zh: '杂项/综合', en: 'Miscellaneous', file: 'mmlu/miscellaneous_test.json', level: 'easy', type: 'single_choice' }, { zh: '道德争议', en: 'Moral Disputes', file: 'mmlu/moral_disputes_test.json', level: 'easy', type: 'single_choice' }, { zh: '道德场景', en: 'Moral Scenarios', file: 'mmlu/moral_scenarios_test.json', level: 'easy', type: 'single_choice' }, { zh: '营养学', en: 'Nutrition', file: 'mmlu/nutrition_test.json', level: 'easy', type: 'single_choice' }, { zh: '哲学', en: 'Philosophy', file: 'mmlu/philosophy_test.json', level: 'easy', type: 'single_choice' }, { zh: '史前史', en: 'Prehistory', file: 'mmlu/prehistory_test.json', level: 'easy', type: 'single_choice' }, { zh: '专业会计', en: 'Professional Accounting', file: 'mmlu/professional_accounting_test.json', level: 'easy', type: 'single_choice' }, { zh: '专业法律', en: 'Professional Law', file: 'mmlu/professional_law_test.json', level: 'easy', type: 'single_choice' }, { zh: '专业医学', en: 'Professional Medicine', file: 'mmlu/professional_medicine_test.json', level: 'easy', type: 'single_choice' }, { zh: '专业心理学', en: 'Professional Psychology', file: 'mmlu/professional_psychology_test.json', level: 'easy', type: 'single_choice' }, { zh: '公共关系', en: 'Public Relations', file: 'mmlu/public_relations_test.json', level: 'easy', type: 'single_choice' }, { zh: '安全研究', en: 'Security Studies', file: 'mmlu/security_studies_test.json', level: 'easy', type: 'single_choice' }, { zh: '社会学', en: 'Sociology', file: 'mmlu/sociology_test.json', level: 'easy', type: 'single_choice' }, { zh: '美国外交政策', en: 'Us Foreign Policy', file: 'mmlu/us_foreign_policy_test.json', level: 'easy', type: 'single_choice' }, { zh: '病毒学', en: 'Virology', file: 'mmlu/virology_test.json', level: 'easy', type: 'single_choice' }, { zh: '世界宗教测试', en: 'World Religions', file: 'mmlu/world_religions_test.json', level: 'easy', type: 'single_choice' } ]; ================================================ FILE: app/projects/[projectId]/eval-datasets/hooks/useEvalDatasets.js ================================================ 'use client'; import { useState, useCallback, useEffect, useRef } from 'react'; /** * Eval datasets list hook * @param {string} projectId */ export default function useEvalDatasets(projectId) { const [data, setData] = useState({ items: [], total: 0, stats: null, totalPages: 1 }); const [loading, setLoading] = useState(true); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); const isInitialMount = useRef(true); const abortRef = useRef(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [questionType, setQuestionType] = useState(''); const [keyword, setKeyword] = useState(''); const [debouncedKeyword, setDebouncedKeyword] = useState(''); const [chunkId, setChunkId] = useState(''); const [tags, setTags] = useState([]); const setQuestionTypeWithReset = useCallback(value => { setQuestionType(value); setPage(1); }, []); const setKeywordWithReset = useCallback(value => { setKeyword(value); }, []); const setChunkIdWithReset = useCallback(value => { setChunkId(value); setPage(1); }, []); const setTagsWithReset = useCallback(value => { setTags(value); setPage(1); }, []); const [viewMode, setViewMode] = useState('card'); const [selectedIds, setSelectedIds] = useState([]); useEffect(() => { const timer = setTimeout(() => { setDebouncedKeyword(keyword); if (keyword !== debouncedKeyword) { setPage(1); } }, 500); return () => clearTimeout(timer); }, [keyword]); const fetchDataRef = useRef(null); fetchDataRef.current = async (showLoading = true, options = {}) => { if (!projectId) return; const includeStats = options.forceStats || showLoading; if (abortRef.current) { abortRef.current.abort(); } const controller = new AbortController(); abortRef.current = controller; if (showLoading) { setLoading(true); } else { setSearching(true); } setError(null); try { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), includeStats: includeStats ? 'true' : 'false' }); if (questionType) params.append('questionType', questionType); if (debouncedKeyword) params.append('keyword', debouncedKeyword); if (chunkId) params.append('chunkId', chunkId); if (tags.length > 0) { tags.forEach(tag => params.append('tags', tag)); } const response = await fetch(`/api/projects/${projectId}/eval-datasets?${params}`, { signal: controller.signal }); if (!response.ok) { throw new Error('Failed to fetch eval datasets'); } const result = await response.json(); setData(prev => ({ ...result, stats: result.stats ?? prev.stats })); } catch (err) { if (err?.name === 'AbortError') return; setError(err.message); } finally { if (abortRef.current === controller) { abortRef.current = null; } if (showLoading) { setLoading(false); } else { setSearching(false); } } }; const fetchData = useCallback((showLoading = true, options = {}) => { return fetchDataRef.current?.(showLoading, options); }, []); useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; fetchDataRef.current?.(true, { forceStats: true }); } else { fetchDataRef.current?.(false, { forceStats: false }); } }, [projectId, page, pageSize, questionType, debouncedKeyword, chunkId, tags]); useEffect(() => { return () => { if (abortRef.current) { abortRef.current.abort(); } }; }, []); const deleteItems = useCallback( async ids => { if (!ids || ids.length === 0) return; const response = await fetch(`/api/projects/${projectId}/eval-datasets`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); if (!response.ok) { throw new Error('Failed to delete items'); } await fetchData(true, { forceStats: true }); setSelectedIds([]); return await response.json(); }, [projectId, fetchData] ); const resetFilters = useCallback(() => { setQuestionType(''); setKeyword(''); setChunkId(''); setTags([]); setPage(1); }, []); const toggleSelect = useCallback(id => { setSelectedIds(prev => (prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])); }, []); const toggleSelectAll = useCallback(() => { if (selectedIds.length === data.items.length) { setSelectedIds([]); } else { setSelectedIds(data.items.map(item => item.id)); } }, [selectedIds, data.items]); return { items: data.items, total: data.total, stats: data.stats, totalPages: data.totalPages || 1, loading, searching, error, page, pageSize, setPage, setPageSize, questionType, keyword, chunkId, tags, setQuestionType: setQuestionTypeWithReset, setKeyword: setKeywordWithReset, setChunkId: setChunkIdWithReset, setTags: setTagsWithReset, resetFilters, viewMode, setViewMode, selectedIds, toggleSelect, toggleSelectAll, setSelectedIds, fetchData, deleteItems }; } ================================================ FILE: app/projects/[projectId]/eval-datasets/hooks/useExportEvalDatasets.js ================================================ 'use client'; import { useState, useCallback, useEffect } from 'react'; /** * 评估数据集导出 Hook * 管理导出对话框状态、筛选条件和导出逻辑 */ export default function useExportEvalDatasets(projectId, stats = {}) { // 对话框状态 const [dialogOpen, setDialogOpen] = useState(false); const [exporting, setExporting] = useState(false); const [error, setError] = useState(''); // 导出配置 const [format, setFormat] = useState('json'); const [questionTypes, setQuestionTypes] = useState([]); const [selectedTags, setSelectedTags] = useState([]); const [keyword, setKeyword] = useState(''); // 预览数据 const [previewTotal, setPreviewTotal] = useState(0); const [previewLoading, setPreviewLoading] = useState(false); // 从 stats 中获取可用的标签列表 const availableTags = stats?.byTag ? Object.keys(stats.byTag).sort() : []; // 当筛选条件变化时,获取预览数量 useEffect(() => { if (!dialogOpen || !projectId) return; const controller = new AbortController(); const fetchPreview = async () => { try { setPreviewLoading(true); const params = new URLSearchParams(); if (questionTypes.length > 0) { questionTypes.forEach(t => params.append('questionTypes', t)); } if (selectedTags.length > 0) { selectedTags.forEach(t => params.append('tags', t)); } if (keyword.trim()) { params.append('keyword', keyword.trim()); } const response = await fetch(`/api/projects/${projectId}/eval-datasets/export?${params.toString()}`, { signal: controller.signal }); if (response.ok) { const result = await response.json(); setPreviewTotal(result?.data?.total ?? 0); } } catch (err) { if (err.name !== 'AbortError') { console.error('获取导出预览失败:', err); } } finally { setPreviewLoading(false); } }; fetchPreview(); return () => { controller.abort(); }; }, [dialogOpen, projectId, questionTypes, selectedTags, keyword]); // 打开对话框 const openDialog = useCallback(() => { setDialogOpen(true); setError(''); }, []); // 关闭对话框 const closeDialog = useCallback(() => { if (exporting) return; setDialogOpen(false); // 重置状态 setFormat('json'); setQuestionTypes([]); setSelectedTags([]); setKeyword(''); setError(''); }, [exporting]); // 重置筛选条件 const resetFilters = useCallback(() => { setQuestionTypes([]); setSelectedTags([]); setKeyword(''); }, []); // 执行导出 const handleExport = useCallback(async () => { if (previewTotal === 0) { setError('没有符合条件的数据可导出'); return; } try { setExporting(true); setError(''); const response = await fetch(`/api/projects/${projectId}/eval-datasets/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format, questionTypes, tags: selectedTags, keyword: keyword.trim() }) }); if (!response.ok) { const result = await response.json(); throw new Error(result.error || '导出失败'); } // 获取文件名 const contentDisposition = response.headers.get('Content-Disposition'); let filename = `eval-datasets-${Date.now()}.${format}`; if (contentDisposition) { const match = contentDisposition.match(/filename="?([^"]+)"?/); if (match) { filename = match[1]; } } // 下载文件 const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); // 导出成功,关闭对话框 closeDialog(); return true; } catch (err) { console.error('导出失败:', err); setError(err.message || '导出失败'); return false; } finally { setExporting(false); } }, [projectId, format, questionTypes, selectedTags, keyword, previewTotal, closeDialog]); return { // 对话框状态 dialogOpen, openDialog, closeDialog, // 导出状态 exporting, error, setError, // 导出配置 format, setFormat, questionTypes, setQuestionTypes, selectedTags, setSelectedTags, keyword, setKeyword, // 预览数据 previewTotal, previewLoading, availableTags, // 操作 resetFilters, handleExport }; } ================================================ FILE: app/projects/[projectId]/eval-datasets/page.js ================================================ 'use client'; import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { Box, Container, Typography, Grid, Pagination, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions, Button, Snackbar } from '@mui/material'; import { Masonry } from '@mui/lab'; import { useTranslation } from 'react-i18next'; import useEvalDatasets from './hooks/useEvalDatasets'; import useExportEvalDatasets from './hooks/useExportEvalDatasets'; import EvalToolbar from './components/EvalToolbar'; import EvalDatasetCard from './components/EvalDatasetCard'; import EvalDatasetList from './components/EvalDatasetList'; import ImportDialog from './components/ImportDialog'; import BuiltinDatasetDialog from './components/BuiltinDatasetDialog'; import ExportEvalDialog from './components/ExportEvalDialog'; export default function EvalDatasetsPage() { const { projectId } = useParams(); const router = useRouter(); const { t } = useTranslation(); const { items, total, stats, totalPages, loading, searching, error, page, setPage, questionType, setQuestionType, tags, setTags, keyword, setKeyword, viewMode, setViewMode, selectedIds, toggleSelect, toggleSelectAll, fetchData, deleteItems } = useEvalDatasets(projectId); // 导出 Hook const { dialogOpen: exportDialogOpen, openDialog: openExportDialog, closeDialog: closeExportDialog, exporting, error: exportError, format: exportFormat, setFormat: setExportFormat, questionTypes: exportQuestionTypes, setQuestionTypes: setExportQuestionTypes, selectedTags: exportSelectedTags, setSelectedTags: setExportSelectedTags, keyword: exportKeyword, setKeyword: setExportKeyword, previewTotal, previewLoading, availableTags: exportAvailableTags, resetFilters: resetExportFilters, handleExport } = useExportEvalDatasets(projectId, stats); // 删除确认对话框 const [deleteDialog, setDeleteDialog] = useState({ open: false, ids: [] }); // 导入对话框 const [importDialogOpen, setImportDialogOpen] = useState(false); const [builtinImportOpen, setBuiltinImportOpen] = useState(false); // Toast 提示 const [toast, setToast] = useState({ open: false, message: '', severity: 'success' }); // 处理导入成功 const handleImportSuccess = result => { setToast({ open: true, message: t('evalDatasets.import.successMessage', { count: result.total }), severity: 'success' }); fetchData(); // 刷新数据 }; // 处理删除 const handleDelete = async ids => { setDeleteDialog({ open: true, ids: Array.isArray(ids) ? ids : [ids] }); }; const confirmDelete = async () => { try { await deleteItems(deleteDialog.ids); setDeleteDialog({ open: false, ids: [] }); } catch (err) { console.error('Delete failed:', err); } }; // 处理编辑 const handleEdit = item => { router.push(`/projects/${projectId}/eval-datasets/${item.id}`); }; // 处理查看 const handleView = item => { router.push(`/projects/${projectId}/eval-datasets/${item.id}`); }; return ( {/* 错误提示 */} {error && ( {error} )} {/* 工具栏(包含统计筛选) */} handleDelete(selectedIds)} stats={stats} questionType={questionType} onTypeChange={setQuestionType} tags={tags} onTagsChange={setTags} onRefresh={fetchData} loading={loading} onImport={() => setImportDialogOpen(true)} onBuiltinImport={() => setBuiltinImportOpen(true)} onExport={openExportDialog} /> {/* 加载状态 */} {loading && ( )} {/* 内容区域 */} {!loading && ( {/* 搜索加载遮罩 */} {searching && ( )} {viewMode === 'card' ? ( {items.map(item => ( ))} ) : ( )} {/* 空状态 */} {items.length === 0 && ( {t('eval.noData')} {t('eval.noDataHint')} )} {/* 分页 */} {totalPages > 1 && ( setPage(value)} color="primary" showFirstButton showLastButton /> )} )} {/* 删除确认对话框 */} setDeleteDialog({ open: false, ids: [] })}> {t('eval.deleteConfirmTitle')} {t('eval.deleteConfirmMessage', { count: deleteDialog.ids.length })} {/* 导入对话框 */} setImportDialogOpen(false)} projectId={projectId} onSuccess={handleImportSuccess} /> {/* 内置数据集导入对话框 */} setBuiltinImportOpen(false)} projectId={projectId} onSuccess={handleImportSuccess} /> {/* 导出对话框 */} {/* Toast 提示 */} setToast({ ...toast, open: false })} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} > setToast({ ...toast, open: false })}> {toast.message} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/[taskId]/components/EvalHeader.js ================================================ 'use client'; import { Box, Paper, Typography, Chip, Grid, Divider } from '@mui/material'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import { detailStyles } from '../detailStyles'; import { useTranslation } from 'react-i18next'; import { getModelIcon } from '@/lib/util/modelIcon'; export default function EvalHeader({ task, stats, filterCorrect, onFilterCorrectSelect }) { const { t } = useTranslation(); if (!task) return null; const { modelInfo, createAt, status, detail } = task; const score = detail?.finalScore || 0; const isPass = score >= 60; const totalTime = task.endTime ? Math.floor((new Date(task.endTime) - new Date(task.createAt)) / 1000) : 0; const incorrectCount = (stats?.totalQuestions || 0) - (stats?.correctCount || 0); // 获取教师模型信息 const judgeModelId = detail?.judgeModelId; const judgeProviderId = detail?.judgeProviderId; const hasJudgeModel = judgeModelId && judgeProviderId; return ( {/* 左侧:模型信息 */} {modelInfo?.modelId {modelInfo?.providerName || modelInfo?.providerId} / {modelInfo?.modelName || modelInfo?.modelId} {hasJudgeModel && ( )} {new Date(createAt).toLocaleString()} {totalTime > 0 && ` ${t('evalTasks.durationFormat', { time: totalTime })}`} {/* 中间:统计概览 (增加点击筛选) */} onFilterCorrectSelect(null)} sx={{ ...detailStyles.statBox, cursor: 'pointer', bgcolor: filterCorrect === null ? 'rgba(25, 118, 210, 0.08)' : 'background.default', border: filterCorrect === null ? '1px solid' : '1px solid transparent', borderColor: 'primary.main', transition: 'all 0.2s' }} > {stats?.totalQuestions || 0} {t('evalTasks.totalQuestionsLabel')} onFilterCorrectSelect(true)} sx={{ ...detailStyles.statBox, cursor: 'pointer', bgcolor: filterCorrect === true ? 'rgba(46, 125, 50, 0.08)' : 'background.default', border: filterCorrect === true ? '1px solid' : '1px solid transparent', borderColor: 'success.main', transition: 'all 0.2s' }} > {stats?.correctCount || 0} {t('evalTasks.correctLabel')} onFilterCorrectSelect(false)} sx={{ ...detailStyles.statBox, cursor: 'pointer', bgcolor: filterCorrect === false ? 'rgba(211, 47, 47, 0.08)' : 'background.default', border: filterCorrect === false ? '1px solid' : '1px solid transparent', borderColor: 'error.main', transition: 'all 0.2s' }} > {incorrectCount} {t('evalTasks.incorrectLabel')} {/* 右侧:分数印章 */} {score.toFixed(1)} SCORE ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/[taskId]/components/EvalStats.js ================================================ 'use client'; import { Box, Grid, Typography, LinearProgress } from '@mui/material'; import { detailStyles } from '../detailStyles'; import { useTranslation } from 'react-i18next'; const QUESTION_TYPE_LABELS = { true_false: 'eval.questionTypes.true_false', single_choice: 'eval.questionTypes.single_choice', multiple_choice: 'eval.questionTypes.multiple_choice', short_answer: 'eval.questionTypes.short_answer', open_ended: 'eval.questionTypes.open_ended' }; export default function EvalStats({ stats, currentFilter, onFilterSelect }) { const { t } = useTranslation(); if (!stats?.byType || Object.keys(stats.byType).length === 0) return null; return ( {Object.entries(stats.byType).map(([type, typeStats]) => { const accuracy = typeStats.total > 0 ? (typeStats.correct / typeStats.total) * 100 : 0; const isSelected = currentFilter === type; return ( onFilterSelect(isSelected ? null : type)} sx={{ ...detailStyles.typeStatsItem, cursor: 'pointer', transition: 'all 0.2s', bgcolor: isSelected ? 'primary.light' : '#fff', borderColor: isSelected ? 'primary.main' : '#eee', '&:hover': { transform: 'translateY(-2px)', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', borderColor: 'primary.main' }, '& *': { color: isSelected ? 'primary.contrastText' : undefined } }} > {t(QUESTION_TYPE_LABELS[type] || type)} {typeStats.correct} / {typeStats.total} = 60 ? 'success' : 'error'} /> {accuracy.toFixed(0)}% ); })} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/[taskId]/components/QuestionCard.js ================================================ 'use client'; import { useState, useRef, useEffect } from 'react'; import { Box, Typography, Chip, Paper, Button } from '@mui/material'; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import ReactMarkdown from 'react-markdown'; import { detailStyles } from '../detailStyles'; import { useTranslation } from 'react-i18next'; import 'github-markdown-css/github-markdown-light.css'; // 答题状态常量 const EVAL_STATUS = { SUCCESS: 0, FORMAT_ERROR: 1, API_ERROR: 2 }; // 状态标签配置 const STATUS_CONFIG = { [EVAL_STATUS.SUCCESS]: { label: 'evalTasks.statusSuccess', color: 'success' }, [EVAL_STATUS.FORMAT_ERROR]: { label: 'evalTasks.statusFormatError', color: 'warning' }, [EVAL_STATUS.API_ERROR]: { label: 'evalTasks.statusApiError', color: 'error' } }; export default function QuestionCard({ result, index, task }) { const { t } = useTranslation(); const { evalDataset, modelAnswer, isCorrect, score, judgeResponse, duration = 0, status = 0, errorMessage = '' } = result; const { question, questionType, options, correctAnswer } = evalDataset; const [isExpanded, setIsExpanded] = useState(false); const [shouldShowExpand, setShouldShowExpand] = useState(false); const contentRef = useRef(null); const [isCorrectExpanded, setIsCorrectExpanded] = useState(false); const [shouldShowCorrectExpand, setShouldShowCorrectExpand] = useState(false); const correctContentRef = useRef(null); // 检查内容是否超过高度限制 useEffect(() => { if (contentRef.current) { const hasOverflow = contentRef.current.scrollHeight > 200; setShouldShowExpand(hasOverflow); } }, [modelAnswer]); useEffect(() => { if (correctContentRef.current) { const hasOverflow = correctContentRef.current.scrollHeight > 200; setShouldShowCorrectExpand(hasOverflow); } }, [correctAnswer]); // 解析选项 let parsedOptions = []; if (questionType === 'single_choice' || questionType === 'multiple_choice') { try { parsedOptions = JSON.parse(options); } catch (e) { parsedOptions = options ? [options] : []; } } else if (questionType === 'true_false') { parsedOptions = ['True', 'False']; } // 格式化答案显示 const formatAnswer = ans => { if (!ans) return '-'; return String(ans); }; // 判断选项状态 const getOptionStatus = (optionText, idx) => { const letter = String.fromCharCode(65 + idx); const normModelAns = String(modelAnswer).trim(); const normCorrectAns = String(correctAnswer).trim(); let isSelected = false; let isCorrectOption = false; if (questionType === 'true_false') { // 判断题:A 对应 ✅/True,B 对应 ❌/False const isTrueOption = idx === 0; const isFalseOption = idx === 1; isSelected = (isTrueOption && (normModelAns === '✅' || normModelAns.toUpperCase() === 'TRUE')) || (isFalseOption && (normModelAns === '❌' || normModelAns.toUpperCase() === 'FALSE')); isCorrectOption = (isTrueOption && (normCorrectAns === '✅' || normCorrectAns.toUpperCase() === 'TRUE')) || (isFalseOption && (normCorrectAns === '❌' || normCorrectAns.toUpperCase() === 'FALSE')); } else { // 选择题逻辑 const normModelAnsUpper = normModelAns.toUpperCase(); const normCorrectAnsUpper = normCorrectAns.toUpperCase(); const normOptionText = String(optionText).toUpperCase(); isSelected = normModelAnsUpper.includes(letter) || normModelAnsUpper.includes(normOptionText); isCorrectOption = normCorrectAnsUpper.includes(letter) || normCorrectAnsUpper.includes(normOptionText); } return { isSelected, isCorrectOption }; }; // 解析 AI 点评内容 const getJudgeDisplayContent = content => { if (!content) return ''; try { // 尝试从 markdown 代码块中提取 JSON const jsonMatch = content.match(/\{[\s\S]*?\}/); if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]); if (parsed.reason) return parsed.reason; } // 尝试直接解析 const parsed = JSON.parse(content); if (parsed.reason) return parsed.reason; } catch (e) { // 解析失败,返回原内容 } return content; }; return ( {/* 判卷标记 (红勾/红叉) - 绝对定位 */} {isCorrect ? : } {/* 题号与类型标签 */} {index + 1} {/* 答题耗时 */} {duration > 0 && ( } label={duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`} size="small" variant="outlined" sx={{ height: 24, '& .MuiChip-label': { px: 0.75, fontSize: '0.75rem' } }} /> )} {/* 答题状态 */} {status !== EVAL_STATUS.SUCCESS && ( } label={t( STATUS_CONFIG[status]?.label || 'evalTasks.statusUnknown', status === EVAL_STATUS.FORMAT_ERROR ? t('evalTasks.statusFormatError') : t('evalTasks.statusApiError') )} size="small" color={STATUS_CONFIG[status]?.color || 'default'} variant="outlined" sx={{ height: 24, '& .MuiChip-label': { px: 0.75, fontSize: '0.75rem' } }} /> )} {/* 题目内容 */} {question} {/* 选项区域 (仅选择题/判断题) */} {parsedOptions.length > 0 && ( {parsedOptions.map((opt, idx) => { const letter = String.fromCharCode(65 + idx); const { isSelected, isCorrectOption } = getOptionStatus(opt, idx); return ( {letter}. {opt} ); })} )} {/* 答案对比区域 */} {t('evalTasks.modelAnswer')} {questionType === 'open_ended' || questionType === 'short_answer' ? (
{modelAnswer || ''}
) : ( {formatAnswer(modelAnswer)} )} {/* 展开/收起 遮罩和按钮 */} {shouldShowExpand && !isExpanded && ( )}
{isExpanded && shouldShowExpand && ( )} {t('evalTasks.correctAnswer')} {questionType === 'open_ended' || questionType === 'short_answer' ? (
{correctAnswer || ''}
) : ( {formatAnswer(correctAnswer)} )} {/* 展开/收起 遮罩和按钮 */} {shouldShowCorrectExpand && !isCorrectExpanded && ( )}
{isCorrectExpanded && shouldShowCorrectExpand && ( )}
{/* 错误信息显示 */} {errorMessage && ( {errorMessage} )} {/* 教师点评 (气泡样式) */} {judgeResponse && ( {t('evalTasks.judgeComment')} {getJudgeDisplayContent(judgeResponse)} {/* 得分显示(如果是主观题) */} {(questionType === 'short_answer' || questionType === 'open_ended') && ( {(score * 100).toFixed(0)} {t('evalTasks.scoreUnit')} )} )}
); } ================================================ FILE: app/projects/[projectId]/eval-tasks/[taskId]/detailStyles.js ================================================ export const detailStyles = { // 页面背景 pageContainer: { py: 4, minHeight: '100vh', bgcolor: '#f5f7fa' }, // 头部概览卡片 headerCard: { mb: 3, borderRadius: 3, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.05)', border: 'none' }, headerContent: { p: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }, // 分数印章效果 scoreStamp: (score, isPass) => ({ width: 110, height: 110, borderRadius: '50%', border: `4px double ${isPass ? '#2e7d32' : '#d32f2f'}`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: isPass ? '#2e7d32' : '#d32f2f', transform: 'rotate(-15deg)', maskImage: 'url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZmlsdGVyIGlkPSJub2lzZSI+PGZlVHVyYnVsZW5jZSB0eXBlPSJmcmFjdGFsTm9pc2UiIGJhc2VGcmVxdWVuY3k9IjAuNSIgbnVtT2N0YXZlcz0iMyIgc3RpdGNoVGlsZXM9InN0aXRjaCIvPjwvZmlsdGVyPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbHRlcj0idXJsKCNub2lzZSkiIG9wYWNpdHk9IjAuNSIvPjwvc3ZnPg==")', // 简单的噪点遮罩模拟印章纹理(可选) opacity: 0.9, boxShadow: 'inset 0 0 10px rgba(0,0,0,0.1)', flexShrink: 0 }), scoreValue: { fontSize: '2.2rem', fontWeight: 900, lineHeight: 1.1, fontFamily: '"Comic Sans MS", "Chalkboard SE", sans-serif', mb: 0.2 }, scoreLabel: { fontSize: '0.75rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1 }, // 统计卡片 statBox: { textAlign: 'center', p: 2, borderRadius: 2, bgcolor: 'background.default', minWidth: 100 }, // 试卷主体 paperContainer: { width: '100%', mx: 'auto', bgcolor: '#fff', boxShadow: '0 8px 30px rgba(0,0,0,0.08)', borderRadius: 2, overflow: 'hidden', position: 'relative', border: '1px solid #e0e0e0' }, paperHeader: { p: 4, borderBottom: '2px solid #000', textAlign: 'center', position: 'relative', bgcolor: '#fff' }, paperTitle: { fontSize: '1.75rem', fontWeight: 700, mb: 1, fontFamily: '"Songti SC", "SimSun", serif' // 宋体增强试卷感 }, paperSubTitle: { color: 'text.secondary', fontSize: '0.9rem' }, // 题目部分 questionSection: { p: 0 }, questionCard: isCorrect => ({ p: 3, height: '100%', // 确保在Grid中高度撑满 borderBottom: '1px solid #f0f0f0', // 减淡边框颜色 position: 'relative', transition: 'all 0.2s ease', '&:hover': { bgcolor: '#fafafa' } }), questionIndex: { position: 'absolute', left: 20, top: 24, width: 32, height: 32, borderRadius: '50%', // 圆形题号 border: '1px solid #ddd', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', color: 'text.secondary', bgcolor: '#fff', zIndex: 1, fontSize: '0.875rem' }, // 判卷标记(红勾/红叉) markIcon: isCorrect => ({ position: 'absolute', right: 20, top: 20, fontSize: '3rem', color: isCorrect ? '#2e7d32' : '#d32f2f', opacity: 0.8, transform: 'rotate(10deg)', fontFamily: '"Comic Sans MS", "Chalkboard SE", sans-serif' }), // 题目内容 questionContent: { fontSize: '1.1rem', fontWeight: 500, lineHeight: 1.6, mb: 2, color: '#333' }, // 选项区域 optionsContainer: { pl: 2, mb: 2 }, optionItem: (isSelected, isCorrectOption) => ({ p: 1, mb: 0.5, borderRadius: 1, bgcolor: isCorrectOption ? 'rgba(46, 125, 50, 0.1)' // 正确选项显示绿色背景 : isSelected ? 'rgba(211, 47, 47, 0.1)' : 'transparent', // 错误选中显示红色背景 color: isCorrectOption ? 'success.main' : isSelected ? 'error.main' : 'text.primary', display: 'flex', alignItems: 'flex-start', gap: 1 }), // 答案区域 answerSection: { mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2, borderLeft: '4px solid #ddd', position: 'relative' }, // Markdown 展示区域 markdownContainer: isExpanded => ({ maxHeight: isExpanded ? 'none' : '200px', overflow: 'hidden', position: 'relative', '& .markdown-body': { fontSize: '0.9rem', lineHeight: 1.6, bgcolor: 'transparent', color: 'inherit', padding: 0 } }), // 展开收起遮罩层(渐变效果) expandMask: { position: 'absolute', bottom: 0, left: 0, right: 0, height: '60px', background: 'linear-gradient(transparent, #f8f9fa)', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', pb: 1, zIndex: 1 }, expandButton: { fontSize: '0.75rem', textTransform: 'none', color: 'primary.main', bgcolor: 'rgba(255,255,255,0.8)', '&:hover': { bgcolor: 'rgba(255,255,255,1)' }, boxShadow: '0 2px 8px rgba(0,0,0,0.05)', borderRadius: '16px', px: 2 }, // 教师点评样式 judgeComment: { mt: 2, position: 'relative', fontFamily: '"KaiTi", "KaiTi_GB2312", serif', // 楷体模拟手写点评 color: '#d32f2f', padding: '10px 20px', border: '1px solid #d32f2f', borderRadius: '20px 20px 20px 4px', // 气泡形状 maxWidth: 'fit-content', bgcolor: '#fff5f5' }, judgeLabel: { fontSize: '0.8rem', opacity: 0.7, fontStyle: 'italic', mb: 0.5 }, // 按题型统计样式 typeStatsItem: { textAlign: 'center', p: 2, bgcolor: '#fff', borderRadius: 2, border: '1px solid #eee', boxShadow: '0 2px 8px rgba(0,0,0,0.03)', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center' }, typeStatsLabel: { fontSize: '0.85rem', color: 'text.secondary', mb: 1 }, typeStatsScore: { fontWeight: 700, fontSize: '1.25rem', color: 'text.primary' }, typeStatsPercent: { fontSize: '0.75rem', color: 'text.secondary', fontWeight: 500 } }; ================================================ FILE: app/projects/[projectId]/eval-tasks/[taskId]/page.js ================================================ 'use client'; import { useParams, useRouter } from 'next/navigation'; import { Container, Box, Button, CircularProgress, Alert, Typography, LinearProgress, Paper, Grid, Pagination } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import RefreshIcon from '@mui/icons-material/Refresh'; import { useTranslation } from 'react-i18next'; import useEvalTaskDetail from '../hooks/useEvalTaskDetail'; import { detailStyles } from './detailStyles'; import EvalHeader from './components/EvalHeader'; import EvalStats from './components/EvalStats'; import QuestionCard from './components/QuestionCard'; export default function EvalTaskDetailPage() { const { projectId, taskId } = useParams(); const router = useRouter(); const { t } = useTranslation(); const { task, results, stats, total, page, setPage, pageSize, filterType, setFilterType, filterCorrect, setFilterCorrect, loading, error, setError, loadData } = useEvalTaskDetail(projectId, taskId); const handleFilterSelect = type => { setFilterType(type); setPage(1); // 切换筛选时重置到第一页 }; const handleFilterCorrectSelect = isCorrect => { setFilterCorrect(isCorrect); setPage(1); // 切换筛选时重置到第一页 }; const handlePageChange = (event, value) => { setPage(value); // 滚动到试卷顶部 document.getElementById('paper-top')?.scrollIntoView({ behavior: 'smooth' }); }; if (loading && !task) { return ( ); } return ( {/* 顶部导航栏 */} {/* 错误提示 */} {error && ( setError('')}> {error} )} {/* 任务进度(仅进行中时显示) */} {task?.status === 0 && ( {t('evalTasks.statusProcessing')}... {task.completedCount}/{task.totalCount} 0 ? (task.completedCount / task.totalCount) * 100 : 0} sx={{ height: 10, borderRadius: 5 }} /> )} {/* 核心内容区 */} {task && ( <> {/* 头部概览 */} {/* 统计图表 & 筛选 */} {/* 试卷主体 */} {/* 试卷抬头 */} {t('evalTasks.reportTitle', '模型能力评估报告')} {t('evalTasks.taskIdLabel', '任务 ID')}: {taskId} {t('evalTasks.pageInfo', '第 {{page}} / {{totalPages}} 页', { page, totalPages: Math.ceil(total / pageSize) })} {/* 题目列表 (双列布局) */} {loading ? ( ) : ( {results?.map((result, index) => ( ))} )} {!loading && results?.length === 0 && ( {t('evalTasks.noMatchingResults', '暂无符合条件的评估结果')} )} {/* 分页控制 */} {/* 试卷底部 */} {t('evalTasks.reportFooter', 'Easy Dataset Evaluation System · Generated by AI')} )} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/components/CreateEvalTaskDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, FormControl, InputLabel, Select, MenuItem, Alert, Divider, CircularProgress, FormHelperText } from '@mui/material'; import { useTranslation } from 'react-i18next'; import ModelSelector from './ModelSelector'; import QuestionFilter from './QuestionFilter'; import ScoreAnchorsForm from './ScoreAnchorsForm'; import { useEvalTaskForm } from '../hooks/useEvalTaskForm'; import { useEffect } from 'react'; export default function CreateEvalTaskDialog({ open, onClose, projectId, onSuccess }) { const { t, i18n } = useTranslation(); const [submitting, setSubmitting] = useState(false); const { models, selectedModels, setSelectedModels, judgeModel, setJudgeModel, evalDatasets, availableTags, questionTypes, setQuestionTypes, selectedTags, setSelectedTags, searchKeyword, setSearchKeyword, questionCount, setQuestionCount, filteredTotal, sampledIds, hasSubjectiveQuestions, hasShortAnswer, hasOpenEnded, shortAnswerScoreAnchors, setShortAnswerScoreAnchors, openEndedScoreAnchors, setOpenEndedScoreAnchors, initScoreAnchors, loading, error, setError, setSampledIds, resetFilters, resetForm } = useEvalTaskForm(projectId, open); // 当有主观题时,初始化评分规则 useEffect(() => { if (hasSubjectiveQuestions && open) { initScoreAnchors(i18n.language === 'zh-CN' ? 'zh-CN' : 'en'); } }, [hasSubjectiveQuestions, open, i18n.language]); // 统计各题型数量 const typeStats = {}; evalDatasets.forEach(d => { typeStats[d.questionType] = (typeStats[d.questionType] || 0) + 1; }); const getModelKey = model => `${model.providerId}::${model.modelId}`; const handleModelSelectionChange = newSelection => { setSelectedModels(newSelection); setError(''); }; const handleSubmit = async () => { // 先清除之前的错误 setError(''); // 验证 if (selectedModels.length === 0) { setError(t('evalTasks.errorNoModels')); return; } if (filteredTotal === 0) { setError(t('evalTasks.errorNoQuestions')); return; } if (hasSubjectiveQuestions && !judgeModel) { setError(t('evalTasks.errorNoJudgeModel')); return; } // 验证教师模型不在测试模型中 if (judgeModel && selectedModels.includes(judgeModel)) { setError(t('evalTasks.errorJudgeSameAsTest')); return; } try { setSubmitting(true); setError(''); // 解析选中的模型 const models = selectedModels.map(m => { const [providerId, modelId] = m.split('::'); return { modelId, providerId }; // 注意顺序:modelId 在前 }); // 解析教师模型 let judgeModelId = null; let judgeProviderId = null; if (judgeModel) { const [pId, mId] = judgeModel.split('::'); judgeProviderId = pId; judgeModelId = mId; } // 调用后端采样接口获取题目 ID const sampleBody = { questionTypes: questionTypes, tags: selectedTags, keyword: searchKeyword.trim() || '', limit: questionCount > 0 ? questionCount : undefined }; const sampleResponse = await fetch(`/api/projects/${projectId}/eval-datasets/sample`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sampleBody) }); const sampleResult = await sampleResponse.json(); if (!sampleResponse.ok || sampleResult.code !== 0) { setError(sampleResult.error || t('evalTasks.errorCreateFailed')); return; } const ids = sampleResult?.data?.ids || []; if (ids.length === 0) { setError(t('evalTasks.errorNoQuestions')); return; } setSampledIds(ids); // 构建自定义评分规则对象 const customScoreAnchors = {}; if (hasShortAnswer && shortAnswerScoreAnchors.length > 0) { customScoreAnchors.short_answer = shortAnswerScoreAnchors; } if (hasOpenEnded && openEndedScoreAnchors.length > 0) { customScoreAnchors.open_ended = openEndedScoreAnchors; } // 创建任务 const response = await fetch(`/api/projects/${projectId}/eval-tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ models, // 后端期望的字段名 judgeModelId, // 分开传递 judgeProviderId, // 分开传递 evalDatasetIds: ids, language: i18n.language === 'zh-CN' ? 'zh-CN' : 'en', customScoreAnchors: Object.keys(customScoreAnchors).length > 0 ? customScoreAnchors : undefined }) }); const result = await response.json(); if (result.code === 0) { onSuccess && onSuccess(result.data); handleClose(); } else { setError(result.error || t('evalTasks.errorCreateFailed')); } } catch (err) { console.error('创建评估任务失败:', err); setError(t('evalTasks.errorCreateFailed')); } finally { setSubmitting(false); } }; const handleClose = () => { resetForm(); onClose(); }; const handleJudgeModelChange = event => { setJudgeModel(event.target.value); setError(''); }; return ( {t('evalTasks.createTitle')} {error && ( setError('')}> {error} )} {/* 选择测试模型 */} {/* 题目筛选 */} {/* 最终题目统计 */} {t('evalTasks.finalSelection')} {sampledIds.length || (questionCount > 0 ? questionCount : filteredTotal)}{' '} {t('evalTasks.questionsSuffix')} {hasSubjectiveQuestions && ( {t('evalTasks.hasSubjectiveHint')} )} {/* 选择教师模型(仅当有主观题时显示) */} {hasSubjectiveQuestions && ( <> {t('evalTasks.selectJudgeModel')} * {t('evalTasks.selectJudgeModelHint')} {/* 简答题评分规则 */} {hasShortAnswer && ( )} {/* 开放题评分规则 */} {hasOpenEnded && ( )} )} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/components/EvalTaskCard.js ================================================ 'use client'; import { useState } from 'react'; import { Card, CardContent, Box, Typography, Chip, IconButton, LinearProgress, Menu, MenuItem, ListItemIcon, Avatar, useTheme } from '@mui/material'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DeleteIcon from '@mui/icons-material/Delete'; import StopIcon from '@mui/icons-material/Stop'; import VisibilityIcon from '@mui/icons-material/Visibility'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorIcon from '@mui/icons-material/Error'; import PauseCircleIcon from '@mui/icons-material/PauseCircle'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import QuizIcon from '@mui/icons-material/Quiz'; import { useTranslation } from 'react-i18next'; import { getModelIcon } from '@/lib/util/modelIcon'; import styles from '../styles'; const STATUS_CONFIG = { 0: { label: 'evalTasks.statusProcessing', color: 'info', icon: HourglassEmptyIcon }, 1: { label: 'evalTasks.statusCompleted', color: 'success', icon: CheckCircleIcon }, 2: { label: 'evalTasks.statusFailed', color: 'error', icon: ErrorIcon }, 3: { label: 'evalTasks.statusInterrupted', color: 'warning', icon: PauseCircleIcon } }; export default function EvalTaskCard({ task, onView, onDelete, onInterrupt }) { const { t } = useTranslation(); const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const { modelInfo, detail, status, completedCount, totalCount, createAt } = task; const statusConfig = STATUS_CONFIG[status] || STATUS_CONFIG[0]; const StatusIcon = statusConfig.icon; const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0; const finalScore = detail?.finalScore; const handleMenuClick = e => { e.stopPropagation(); setAnchorEl(e.currentTarget); }; const handleMenuClose = () => setAnchorEl(null); const handleAction = action => () => { handleMenuClose(); action?.(task); }; const getScoreColor = score => { if (score >= 80) return 'success'; if (score >= 60) return 'info'; if (score >= 40) return 'warning'; return 'error'; }; return ( {/* 头部 */} {modelInfo?.modelId {modelInfo?.modelName || modelInfo?.modelId} {modelInfo?.providerName || modelInfo?.providerId} {/* 状态和得分 */} } label={t(statusConfig.label)} color={statusConfig.color} size="small" variant="outlined" sx={{ height: 24, '& .MuiChip-label': { px: 1, fontSize: '0.7rem' } }} /> {finalScore !== undefined && status === 1 && ( )} {/* 进度条 */} {status === 0 && ( {t('evalTasks.progress')} {completedCount}/{totalCount} )} {/* 统计信息 */} } label={`${totalCount} ${t('evalTasks.questions')}`} size="small" variant="outlined" sx={{ height: 22, '& .MuiChip-label': { px: 0.75, fontSize: '0.7rem' } }} /> {detail?.hasSubjectiveQuestions && ( )} {/* 时间 */} {new Date(createAt).toLocaleString()} {/* 菜单 */} e.stopPropagation()}> {t('datasets.viewDetails')} {status === 0 && ( {t('evalTasks.interrupt')} )} {t('common.delete')} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/components/ModelSelector.js ================================================ 'use client'; import { Box, Typography, Checkbox, FormHelperText, FormControl, InputLabel, Select, MenuItem, ListItemText, OutlinedInput, Chip } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function ModelSelector({ models, selectedModels, onSelectionChange, error }) { const { t } = useTranslation(); const getModelKey = model => `${model.providerId}::${model.modelId}`; const handleChange = event => { const { target: { value } } = event; // On autofill we get a stringified value. onSelectionChange(typeof value === 'string' ? value.split(',') : value); }; const getModelLabel = modelKey => { const model = models.find(m => getModelKey(m) === modelKey); if (!model) return modelKey; return `${model.providerName || model.providerId} / ${model.modelName || model.modelId}`; }; return ( {t('evalTasks.selectModels')} * {error || t('evalTasks.selectModelsHint')} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/components/QuestionFilter.js ================================================ 'use client'; import { Box, Typography, TextField, FormControl, InputLabel, Select, MenuItem, Chip, OutlinedInput, Checkbox, ListItemText, Slider, Button } from '@mui/material'; import FilterAltIcon from '@mui/icons-material/FilterAlt'; import ClearIcon from '@mui/icons-material/Clear'; import { useTranslation } from 'react-i18next'; const QUESTION_TYPES = [ { value: 'true_false', labelKey: 'eval.questionTypes.true_false' }, { value: 'single_choice', labelKey: 'eval.questionTypes.single_choice' }, { value: 'multiple_choice', labelKey: 'eval.questionTypes.multiple_choice' }, { value: 'short_answer', labelKey: 'eval.questionTypes.short_answer' }, { value: 'open_ended', labelKey: 'eval.questionTypes.open_ended' } ]; export default function QuestionFilter({ questionTypes, selectedTags, searchKeyword, questionCount, availableTags, filteredCount, onQuestionTypesChange, onTagsChange, onSearchChange, onQuestionCountChange, onReset }) { const { t } = useTranslation(); const hasFilters = questionTypes.length > 0 || selectedTags.length > 0 || searchKeyword || questionCount > 0; return ( {t('evalTasks.filterTitle')} {hasFilters && ( )} {/* 关键字搜索 */} onSearchChange(e.target.value)} /> {/* 题型和标签筛选 - 并排显示 */} {/* 题型筛选 */} {t('evalTasks.filterByTypeLabel')} {/* 标签筛选 */} {availableTags.length > 0 && ( {t('evalTasks.filterByTagLabel')} )} {/* 题目数量选择 - 紧凑布局 */} {t('evalTasks.questionCountLabel')} {questionCount === 0 ? t('common.all') : questionCount} / {filteredCount} onQuestionCountChange(parseInt(e.target.value) || 0)} inputProps={{ min: 0, max: filteredCount }} sx={{ width: 100 }} /> onQuestionCountChange(value)} min={0} max={filteredCount} step={1} valueLabelDisplay="auto" /> {questionCount === 0 ? t('evalTasks.useAllQuestions') : t('evalTasks.randomSampleHint', { filteredCount, questionCount })} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/components/ScoreAnchorsForm.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, TextField, Accordion, AccordionSummary, AccordionDetails, Chip, IconButton, Tooltip } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import RestoreIcon from '@mui/icons-material/Restore'; import { useTranslation } from 'react-i18next'; import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge'; /** * 评分规则表单组件 * 用于自定义简答题和开放题的评分规则 */ export default function ScoreAnchorsForm({ questionType, // 'short_answer' 或 'open_ended' scoreAnchors, onChange, language = 'zh-CN' }) { const { t, i18n } = useTranslation(); const [expanded, setExpanded] = useState(false); // 获取当前语言 const currentLanguage = i18n.language === 'zh-CN' ? 'zh-CN' : 'en'; // 初始化评分规则(如果为空) useEffect(() => { if (!scoreAnchors || scoreAnchors.length === 0) { onChange(getDefaultScoreAnchors(questionType, currentLanguage)); } }, [questionType, currentLanguage]); // 处理单个规则的描述更改 const handleDescriptionChange = (index, newDescription) => { const newAnchors = [...scoreAnchors]; newAnchors[index] = { ...newAnchors[index], description: newDescription }; onChange(newAnchors); }; // 恢复默认值 const handleRestore = () => { onChange(getDefaultScoreAnchors(questionType, currentLanguage)); }; // 获取题型显示名称 const getQuestionTypeName = () => { if (questionType === 'short_answer') { return t('evalTasks.shortAnswer', '简答题'); } return t('evalTasks.openEnded', '开放题'); }; // 获取分数区间的颜色 const getScoreColor = range => { if (range === '1.0') return 'success'; if (range.includes('0.8') || range.includes('0.9')) return 'info'; if (range.includes('0.6') || range.includes('0.7')) return 'warning'; return 'error'; }; if (!scoreAnchors || scoreAnchors.length === 0) { return null; } return ( setExpanded(isExpanded)} sx={{ mb: 2, '&:before': { display: 'none' }, boxShadow: 1 }} > } sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }} > {t('evalTasks.scoreAnchorsTitle', '{{type}}评分规则', { type: getQuestionTypeName() })} {t('evalTasks.scoreAnchorsHint', '自定义评分标准,用于指导LLM评估模型的回答质量')} {scoreAnchors.map((anchor, index) => ( {t('evalTasks.scoreRange', '分数区间')} handleDescriptionChange(index, e.target.value)} placeholder={t('evalTasks.scoreDescriptionPlaceholder', '请输入该分数区间的评分标准描述...')} sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} /> ))} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/hooks/useEvalTaskDetail.js ================================================ 'use client'; import { useState, useEffect, useCallback } from 'react'; /** * 评估任务详情 Hook */ export default function useEvalTaskDetail(projectId, taskId) { const [task, setTask] = useState(null); const [results, setResults] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); // 分页和筛选状态 const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [filterType, setFilterType] = useState(null); const [filterCorrect, setFilterCorrect] = useState(null); // null: all, true: correct, false: incorrect const [total, setTotal] = useState(0); // 加载任务详情 const loadData = useCallback(async () => { if (!projectId || !taskId) return; try { setLoading(true); setError(''); // 构建查询参数 const params = new URLSearchParams({ page: page.toString(), pageSize: pageSize.toString() }); if (filterType) { params.append('type', filterType); } if (filterCorrect !== null) { params.append('isCorrect', filterCorrect.toString()); } const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}?${params.toString()}`); const result = await response.json(); if (result.code === 0) { setTask(result.data.task); setResults(result.data.results || []); setTotal(result.data.total || 0); setStats(result.data.stats); } else { setError(result.error || '加载失败'); } } catch (err) { console.error('加载任务详情失败:', err); setError('加载失败'); } finally { setLoading(false); } }, [projectId, taskId, page, pageSize, filterType, filterCorrect]); // 初始加载 useEffect(() => { loadData(); }, [loadData]); // 自动刷新进行中的任务 (仅在第一页且无筛选时刷新,避免干扰用户查看历史记录) useEffect(() => { if (task?.status !== 0 || page !== 1 || filterType || filterCorrect !== null) return; const interval = setInterval(loadData, 3000); return () => clearInterval(interval); }, [task?.status, page, filterType, filterCorrect, loadData]); return { task, results, stats, total, page, setPage, pageSize, setPageSize, filterType, setFilterType, filterCorrect, setFilterCorrect, loading, error, setError, loadData }; } ================================================ FILE: app/projects/[projectId]/eval-tasks/hooks/useEvalTaskForm.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { getDefaultScoreAnchors } from '@/lib/llm/prompts/llmJudge'; export function useEvalTaskForm(projectId, open) { const [models, setModels] = useState([]); const [selectedModels, setSelectedModels] = useState([]); const [judgeModel, setJudgeModel] = useState(''); const [evalDatasets, setEvalDatasets] = useState([]); const [availableTags, setAvailableTags] = useState([]); // 筛选条件 const [questionTypes, setQuestionTypes] = useState([]); const [selectedTags, setSelectedTags] = useState([]); const [searchKeyword, setSearchKeyword] = useState(''); const [questionCount, setQuestionCount] = useState(0); // 后端统计 & 采样结果 const [filteredTotal, setFilteredTotal] = useState(0); const [sampledIds, setSampledIds] = useState([]); const [hasSubjectiveQuestions, setHasSubjectiveQuestions] = useState(false); // 主观题类型统计(用于确定显示哪个评分规则表单) const [hasShortAnswer, setHasShortAnswer] = useState(false); const [hasOpenEnded, setHasOpenEnded] = useState(false); // 自定义评分规则 const [shortAnswerScoreAnchors, setShortAnswerScoreAnchors] = useState([]); const [openEndedScoreAnchors, setOpenEndedScoreAnchors] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); // 加载数据 useEffect(() => { if (open && projectId) { loadModels(); loadEvalDatasets(); } }, [open, projectId]); // 当筛选条件变化时,调用后端统计数量 useEffect(() => { if (!open || !projectId) return; const controller = new AbortController(); const fetchCount = async () => { try { const params = new URLSearchParams(); if (questionTypes.length > 0) { questionTypes.forEach(t => params.append('questionTypes', t)); } if (searchKeyword.trim()) { params.append('keyword', searchKeyword.trim()); } if (selectedTags.length > 0) { selectedTags.forEach(tag => params.append('tags', tag)); } const response = await fetch(`/api/projects/${projectId}/eval-datasets/count?${params.toString()}`, { signal: controller.signal }); if (response.ok) { const result = await response.json(); const total = result?.data?.total ?? 0; const hasSubjective = result?.data?.hasSubjective ?? false; const hasShort = result?.data?.hasShortAnswer ?? false; const hasOpen = result?.data?.hasOpenEnded ?? false; setFilteredTotal(total); setHasSubjectiveQuestions(hasSubjective); setHasShortAnswer(hasShort); setHasOpenEnded(hasOpen); } } catch (err) { if (err.name !== 'AbortError') { console.error('加载评估题目数量失败:', err); } } }; fetchCount(); return () => { controller.abort(); }; }, [open, projectId, questionTypes, selectedTags, searchKeyword]); const loadModels = async () => { try { const response = await fetch(`/api/projects/${projectId}/model-config`); if (response.ok) { const result = await response.json(); const modelList = result?.data || []; const availableModels = modelList.filter(m => m.apiKey && m.apiKey.trim() !== '' && m.status === 1); setModels(availableModels); } } catch (err) { console.error('加载模型列表失败:', err); setModels([]); } }; const loadEvalDatasets = async () => { try { setLoading(true); // 这里只需要拿到全部可用标签和题型分布,可以复用已有列表接口或标签接口 const response = await fetch(`/api/projects/${projectId}/eval-datasets?includeStats=true&page=1&pageSize=20`); if (response.ok) { const data = await response.json(); const stats = data.stats || {}; const byTag = stats.byTag || {}; const tags = Object.keys(byTag); setAvailableTags(tags.sort()); // 用部分数据来判断是否存在主观题(类型统计更准确) const byType = stats.byType || {}; const mockDatasets = Object.entries(byType).map(([type]) => ({ questionType: type })); setEvalDatasets(mockDatasets); } } catch (err) { console.error('加载评估题目失败:', err); } finally { setLoading(false); } }; const resetFilters = () => { setQuestionTypes([]); setSelectedTags([]); setSearchKeyword(''); setQuestionCount(0); setFilteredTotal(0); setSampledIds([]); setHasShortAnswer(false); setHasOpenEnded(false); }; // 初始化评分规则(根据语言环境) const initScoreAnchors = (language = 'zh-CN') => { setShortAnswerScoreAnchors(getDefaultScoreAnchors('short_answer', language)); setOpenEndedScoreAnchors(getDefaultScoreAnchors('open_ended', language)); }; const resetForm = () => { setSelectedModels([]); setJudgeModel(''); resetFilters(); setError(''); setShortAnswerScoreAnchors([]); setOpenEndedScoreAnchors([]); }; return { models, selectedModels, setSelectedModels, judgeModel, setJudgeModel, evalDatasets, availableTags, questionTypes, setQuestionTypes, selectedTags, setSelectedTags, searchKeyword, setSearchKeyword, questionCount, setQuestionCount, filteredTotal, sampledIds, hasSubjectiveQuestions, hasShortAnswer, hasOpenEnded, shortAnswerScoreAnchors, setShortAnswerScoreAnchors, openEndedScoreAnchors, setOpenEndedScoreAnchors, initScoreAnchors, loading, error, setError, setSampledIds, resetFilters, resetForm }; } ================================================ FILE: app/projects/[projectId]/eval-tasks/hooks/useEvalTasks.js ================================================ 'use client'; import { useState, useEffect, useCallback } from 'react'; /** * 评估任务列表 Hook */ export default function useEvalTasks(projectId) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(12); const [total, setTotal] = useState(0); // 加载任务列表 const loadTasks = useCallback( async (isRefresh = false) => { if (!projectId) return; try { if (!isRefresh) setLoading(true); setError(''); const response = await fetch(`/api/projects/${projectId}/eval-tasks?page=${page}&pageSize=${pageSize}`); const result = await response.json(); if (result.code === 0) { setTasks(result.data.items || []); setTotal(result.data.total || 0); } else { setError(result.error || '加载失败'); } } catch (err) { console.error('加载评估任务失败:', err); setError('加载失败'); } finally { if (!isRefresh) setLoading(false); } }, [projectId, page, pageSize] ); // 初始加载和分页变化加载 useEffect(() => { loadTasks(); }, [loadTasks]); // 自动刷新进行中的任务 useEffect(() => { const hasProcessingTasks = tasks.some(t => t.status === 0); if (!hasProcessingTasks) return; const interval = setInterval(() => loadTasks(true), 5000); return () => clearInterval(interval); }, [tasks, loadTasks]); // 删除任务 const deleteTask = useCallback( async taskId => { try { const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}`, { method: 'DELETE' }); const result = await response.json(); if (result.code === 0) { loadTasks(); return true; } else { setError(result.error || '删除失败'); return false; } } catch (err) { console.error('删除任务失败:', err); setError('删除失败'); return false; } }, [projectId] ); // 中断任务 const interruptTask = useCallback( async taskId => { try { const response = await fetch(`/api/projects/${projectId}/eval-tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'interrupt' }) }); const result = await response.json(); if (result.code === 0) { loadTasks(); return true; } else { setError(result.error || '中断失败'); return false; } } catch (err) { console.error('中断任务失败:', err); setError('中断失败'); return false; } }, [projectId, loadTasks] ); // 创建任务 const createTasks = useCallback( async data => { try { const response = await fetch(`/api/projects/${projectId}/eval-tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (result.code === 0) { loadTasks(); return { success: true, data: result.data }; } else { return { success: false, error: result.error }; } } catch (err) { console.error('创建任务失败:', err); return { success: false, error: '创建失败' }; } }, [projectId, loadTasks] ); return { tasks, loading, error, setError, loadTasks, deleteTask, interruptTask, createTasks, page, setPage, pageSize, setPageSize, total }; } ================================================ FILE: app/projects/[projectId]/eval-tasks/page.js ================================================ 'use client'; import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { Container, Typography, Box, Paper, Button, Grid, CircularProgress, Alert, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, TablePagination } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import RefreshIcon from '@mui/icons-material/Refresh'; import AssessmentIcon from '@mui/icons-material/Assessment'; import { useTranslation } from 'react-i18next'; import useEvalTasks from './hooks/useEvalTasks'; import CreateEvalTaskDialog from './components/CreateEvalTaskDialog'; import EvalTaskCard from './components/EvalTaskCard'; import styles from './styles'; export default function EvalTasksPage() { const { projectId } = useParams(); const router = useRouter(); const { t } = useTranslation(); const { tasks, loading, error, setError, loadTasks, deleteTask, interruptTask, page, setPage, pageSize, setPageSize, total } = useEvalTasks(projectId); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null }); const [interruptDialog, setInterruptDialog] = useState({ open: false, task: null }); const handleView = task => router.push(`/projects/${projectId}/eval-tasks/${task.id}`); const handleDelete = task => setDeleteDialog({ open: true, task }); const handleInterrupt = task => setInterruptDialog({ open: true, task }); const handlePageChange = (event, newPage) => { setPage(newPage + 1); }; const handlePageSizeChange = event => { setPageSize(parseInt(event.target.value, 10)); setPage(1); }; const confirmDelete = async () => { if (deleteDialog.task) { await deleteTask(deleteDialog.task.id); } setDeleteDialog({ open: false, task: null }); }; const confirmInterrupt = async () => { if (interruptDialog.task) { await interruptTask(interruptDialog.task.id); } setInterruptDialog({ open: false, task: null }); }; return ( {/* 标题栏 */} {t('evalTasks.title')} {/* 错误提示 */} {error && ( setError('')}> {error} )} {/* 加载状态 */} {loading && tasks.length === 0 && ( )} {/* 空状态 */} {!loading && tasks.length === 0 && ( {t('evalTasks.noTasks')} {t('evalTasks.noTasksHint')} )} {/* 任务列表 */} {tasks.length > 0 && ( <> {tasks.map(task => ( ))} )} {/* 创建任务对话框 */} setCreateDialogOpen(false)} projectId={projectId} onSuccess={loadTasks} /> {/* 删除确认对话框 */} setDeleteDialog({ open: false, task: null })}> {t('evalTasks.deleteConfirmTitle')} {t('evalTasks.deleteConfirmMessage')} {/* 中断确认对话框 */} setInterruptDialog({ open: false, task: null })}> {t('evalTasks.interruptConfirmTitle')} {t('evalTasks.interruptConfirmMessage')} ); } ================================================ FILE: app/projects/[projectId]/eval-tasks/styles.js ================================================ /** * 评估任务页面样式 */ export const evalTasksStyles = { // 页面容器 pageContainer: { py: 3, minHeight: '100vh' }, // 页头 header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }, headerTitle: { fontWeight: 600 }, headerActions: { display: 'flex', gap: 1 }, // 空状态 emptyState: { p: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: 400, borderRadius: 3, bgcolor: 'background.paper' }, emptyIcon: { fontSize: 80, color: 'text.disabled', mb: 2 }, emptyTitle: { mb: 1, fontWeight: 500 }, emptyHint: { mb: 4, textAlign: 'center', maxWidth: 400 }, // 任务卡片 taskCard: theme => ({ height: '100%', cursor: 'pointer', transition: 'all 0.2s ease', borderRadius: 2, overflow: 'hidden', border: `1px solid ${theme.palette.divider}`, '&:hover': { boxShadow: theme.shadows[6], transform: 'translateY(-4px)', borderColor: theme.palette.primary.main } }), taskCardContent: { p: 2.5 }, taskCardHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }, taskCardModel: { flex: 1, overflow: 'hidden' }, taskCardModelName: { fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.3 }, taskCardTime: { mt: 0.5, fontSize: '0.75rem' }, taskCardStatus: { display: 'flex', alignItems: 'center', gap: 1, mb: 2 }, taskCardProgress: { mb: 2 }, progressBar: { height: 6, borderRadius: 3 }, taskCardStats: { display: 'flex', gap: 1, flexWrap: 'wrap' }, // 统计卡片 statsCard: theme => ({ height: '100%', borderRadius: 2, border: `1px solid ${theme.palette.divider}`, transition: 'all 0.2s ease', '&:hover': { boxShadow: theme.shadows[2] } }), statsCardContent: { p: 2.5 }, statsLabel: { fontSize: '0.75rem', color: 'text.secondary', mb: 1, textTransform: 'uppercase', letterSpacing: 0.5 }, statsValue: { fontWeight: 700, fontSize: '1.75rem', lineHeight: 1.2 }, // 按题型统计 typeStatsContainer: { p: 2.5, mb: 3, borderRadius: 2 }, typeStatsTitle: { fontWeight: 600, mb: 2 }, typeStatsItem: theme => ({ textAlign: 'center', p: 1.5, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)', borderRadius: 1.5, border: `1px solid ${theme.palette.divider}` }), typeStatsLabel: { fontSize: '0.7rem', color: 'text.secondary', mb: 0.5 }, typeStatsScore: { fontWeight: 700, fontSize: '1.1rem' }, typeStatsPercent: { fontSize: '0.7rem', color: 'text.secondary' }, // 结果表格 resultsTable: { overflow: 'hidden', borderRadius: 2 }, resultsTableHeader: { fontWeight: 600, p: 2, borderBottom: 1, borderColor: 'divider' }, resultsTableContainer: { maxHeight: 600 }, resultRow: { cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }, resultQuestion: { maxWidth: 400 }, resultScore: correct => ({ fontWeight: 'bold', color: correct ? 'success.main' : 'error.main' }), resultExpandedContent: { py: 2.5, px: 1.5 }, resultAnswerBox: isCorrect => theme => ({ p: 2, mt: 1, borderRadius: 1.5, bgcolor: isCorrect ? theme.palette.mode === 'dark' ? 'rgba(46, 125, 50, 0.15)' : 'rgba(46, 125, 50, 0.08)' : theme.palette.mode === 'dark' ? 'rgba(211, 47, 47, 0.15)' : 'rgba(211, 47, 47, 0.08)', border: `1px solid ${isCorrect ? theme.palette.success.main : theme.palette.error.main}` }), resultReferenceBox: { p: 2, mt: 1, borderRadius: 1.5, bgcolor: 'action.hover' }, resultJudgeBox: { p: 2, mt: 1, borderRadius: 1.5, bgcolor: 'action.hover' }, // 对话框 dialogContent: { mt: 1 }, dialogSection: { mb: 3 }, dialogDivider: { my: 2 }, dialogInfoBox: theme => ({ p: 2, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)', borderRadius: 1.5, border: `1px solid ${theme.palette.divider}` }), dialogWarning: { mt: 1, color: 'warning.main', fontWeight: 500 } }; export default evalTasksStyles; ================================================ FILE: app/projects/[projectId]/image-datasets/[datasetId]/page.js ================================================ 'use client'; import { Container, Box, CircularProgress, Alert } from '@mui/material'; import { useParams } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import useImageDatasetDetails from '../hooks/useImageDatasetDetails'; import ImageDatasetHeader from '../components/ImageDatasetHeader'; import DatasetContent from '../components/DatasetContent'; import DatasetSidebar from '../components/DatasetSidebar'; export default function ImageDatasetDetailPage() { const { projectId, datasetId } = useParams(); const { t } = useTranslation(); const { currentDataset, loading, confirming, unconfirming, datasetsAllCount, datasetsConfirmCount, updateDataset, handleNavigate, handleConfirm, handleUnconfirm, handleDelete } = useImageDatasetDetails(projectId, datasetId); // 加载状态 if (loading) { return ( ); } // 无数据状态 if (!currentDataset) { return ( {t('imageDatasets.notFound', '数据集不存在')} ); } return ( {/* 顶部导航栏 */} {/* 主要布局:左右分栏 */} {/* 左侧主要内容区域 */} { // 直接传递答案字符串,DatasetContent 已经处理了格式转换 await updateDataset({ answer: newAnswer }); }} /> {/* 右侧固定侧边栏 */} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/DatasetContent.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Paper, Typography, Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Image from 'next/image'; import SaveIcon from '@mui/icons-material/Save'; import AnswerInput from '../../images/components/annotation/AnswerInput'; function handleAnswer(dataset) { const { answer, answerType } = dataset; if (answerType === 'label' || answerType === 'custom_format') { try { return JSON.parse(answer); } catch (e) { return answer; } } return answer; } /** * 数据集主要内容组件 */ export default function DatasetContent({ dataset, projectId, onAnswerChange }) { const { t } = useTranslation(); const [currentAnswer, setCurrentAnswer] = useState(() => handleAnswer(dataset)); const [hasChanges, setHasChanges] = useState(false); const [saving, setSaving] = useState(false); // 当 dataset 变化时,重置状态 useEffect(() => { setCurrentAnswer(handleAnswer(dataset)); setHasChanges(false); }, [dataset.id, dataset.answer]); // 处理答案变化 const handleAnswerChange = newAnswer => { setCurrentAnswer(newAnswer); // 检测是否有变化 const originalAnswer = handleAnswer(dataset); const hasChanged = JSON.stringify(newAnswer) !== JSON.stringify(originalAnswer); setHasChanges(hasChanged); }; // 保存答案 const handleSave = async () => { setSaving(true); try { let answerToSave = currentAnswer; if (typeof answerToSave !== 'string') { answerToSave = JSON.stringify(answerToSave, null, 2); } await onAnswerChange(answerToSave); setHasChanges(false); } catch (error) { console.error('保存失败:', error); } finally { setSaving(false); } }; return ( {/* 问题和保存按钮 */} {dataset.question} {/* 保存按钮 - 只在有变化时显示 */} {hasChanges && ( )} {/* 答案编辑器 */} {/* 图片 */} {dataset.base64 ? ( {dataset.imageName} ) : ( {dataset.imageName} )} {dataset.imageName} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/DatasetSidebar.js ================================================ 'use client'; import { Box } from '@mui/material'; import MetadataInfo from './MetadataInfo'; import MetadataEditor from './MetadataEditor'; /** * 数据集右侧边栏组件 */ export default function DatasetSidebar({ dataset, projectId, onUpdate }) { return ( {/* 元数据信息 - Chip 形式 */} {/* 操作卡片 */} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/EmptyState.js ================================================ 'use client'; import { Box, Typography } from '@mui/material'; import ImageSearchIcon from '@mui/icons-material/ImageSearch'; import { useTranslation } from 'react-i18next'; import { imageDatasetStyles } from '../styles/imageDatasetStyles'; export default function EmptyState() { const { t } = useTranslation(); return ( {t('imageDatasets.noData', { defaultValue: '暂无图片数据集' })} {t('imageDatasets.noDataTip', { defaultValue: '请先在图片管理中生成问答数据集' })} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/ExportImageDatasetDialog.js ================================================ 'use client'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, Checkbox, TextField, Box, Typography, Alert } from '@mui/material'; const ExportImageDatasetDialog = ({ open, onClose, onExport }) => { const { t } = useTranslation(); const [formatType, setFormatType] = useState('raw'); const [exportImages, setExportImages] = useState(false); const [includeImagePath, setIncludeImagePath] = useState(true); const [systemPrompt, setSystemPrompt] = useState(''); const [confirmedOnly, setConfirmedOnly] = useState(false); const handleExport = () => { onExport({ formatType, exportImages, includeImagePath, systemPrompt, confirmedOnly }); }; const handleClose = () => { onClose(); }; return ( {t('imageDatasets.exportTitle', '导出图片数据集')} {/* 导出格式选择 */} {t('imageDatasets.exportFormat', '导出格式')} setFormatType(e.target.value)}> } label={t('imageDatasets.rawFormat', '原始格式')} /> } label="ShareGPT (OpenAI)" /> } label="Alpaca" /> {/* 图片导出选项 */} setExportImages(e.target.checked)} />} label={t('imageDatasets.exportImagesOption', '导出图片文件')} /> {t('imageDatasets.exportImagesDesc', '将所有图片打包成 ZIP 压缩包一起下载')} {/* 图片路径选项 */} setIncludeImagePath(e.target.checked)} />} label={t('imageDatasets.includeImagePath', '在数据集中包含图片路径')} /> {t('imageDatasets.includeImagePathDesc', '在问题或答案中添加图片路径(格式:/images/图片名称)')} {/* 系统提示词 */} setSystemPrompt(e.target.value)} placeholder={t('imageDatasets.systemPromptPlaceholder', '输入系统提示词...')} fullWidth /> {/* 仅导出已确认 */} setConfirmedOnly(e.target.checked)} />} label={t('imageDatasets.confirmedOnly', '仅导出已确认的数据集')} /> {/* 提示信息 */} {t('imageDatasets.exportTip', '标签格式的答案将自动解析为文本(逗号分隔)')} ); }; export default ExportImageDatasetDialog; ================================================ FILE: app/projects/[projectId]/image-datasets/components/ImageDatasetCard.js ================================================ 'use client'; import { Card, CardMedia, Box, Chip, Typography, Tooltip, IconButton } from '@mui/material'; import VisibilityIcon from '@mui/icons-material/Visibility'; import DeleteIcon from '@mui/icons-material/Delete'; import AssessmentIcon from '@mui/icons-material/Assessment'; import { useTranslation } from 'react-i18next'; import { imageDatasetStyles } from '../styles/imageDatasetStyles'; export default function ImageDatasetCard({ dataset, onClick, onView = () => {}, onDelete = () => {}, onEvaluate = () => {} }) { const { t } = useTranslation(); const getAnswerText = () => { if (!dataset.answer) return t('imageDatasets.noAnswer', '暂无答案'); if (dataset.answerType === 'label') { try { const labels = JSON.parse(dataset.answer); return `${t('imageDatasets.labels', '标签')}: ${labels.join(', ')}`; } catch { return dataset.answer; } } return dataset.answer; }; const getAnswerTypeLabel = type => { switch (type) { case 'label': return t('imageDatasets.typeLabel', '标签'); case 'custom_format': return t('imageDatasets.typeCustom', '自定义'); default: return t('imageDatasets.typeText', '文本'); } }; const getAnswerTypeColor = type => { switch (type) { case 'label': return 'secondary'; case 'custom_format': return 'info'; default: return 'primary'; } }; const getScoreLabel = () => { if (!dataset.score || dataset.score === 0) { return t('imageDatasets.unscored', '未评分'); } return dataset.score; }; return ( {/* 图片区域 */} {/* 悬停遮罩 */} {/* 问题内容 - 底部,毛玻璃背景 */} {dataset.question} {/* 内容区域 - 标签和操作按钮 */} {/* 左侧:所有标签 */} ⭐} label={getScoreLabel()} size="small" color={dataset.score && dataset.score > 0 ? 'warning' : 'default'} sx={{ fontSize: '0.7rem', height: 20 }} /> {/* 右侧:操作按钮 - 不同颜色 */} { e.stopPropagation(); onView(dataset.id); }} sx={{ p: 0.5, borderRadius: 1, color: '#1976d2', '&:hover': { backgroundColor: 'rgba(25, 118, 210, 0.1)' } }} > { e.stopPropagation(); onEvaluate(dataset.id); }} sx={{ p: 0.5, borderRadius: 1, color: '#f57c00', '&:hover': { backgroundColor: 'rgba(245, 124, 0, 0.1)' } }} > { e.stopPropagation(); onDelete(dataset.id); }} sx={{ p: 0.5, borderRadius: 1, color: '#d32f2f', '&:hover': { backgroundColor: 'rgba(211, 47, 47, 0.1)' } }} > ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/ImageDatasetFilterDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Box, Typography, Select, MenuItem, Slider, TextField, Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function ImageDatasetFilterDialog({ open, onClose, statusFilter, scoreFilter, onStatusChange, onScoreChange, onResetFilters, onApplyFilters }) { const { t } = useTranslation(); return ( {t('datasets.filtersTitle', '筛选条件')} {/* 确认状态筛选 */} {t('imageDatasets.status', { defaultValue: '确认状态' })} {/* 评分范围筛选 */} {t('imageDatasets.scoreRange', { defaultValue: '评分范围' })} {scoreFilter[0]} - {scoreFilter[1]} 分 onScoreChange(newValue)} valueLabelDisplay="auto" min={0} max={5} step={1} marks sx={{ mt: 1 }} /> {/* 对话框操作按钮 */} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/ImageDatasetFilters.js ================================================ 'use client'; import { Box, Paper, IconButton, InputBase, Button, Badge } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import FilterListIcon from '@mui/icons-material/FilterList'; import { useTranslation } from 'react-i18next'; export default function ImageDatasetFilters({ searchQuery, onSearchChange, onMoreFiltersClick, activeFilterCount = 0 }) { const { t } = useTranslation(); return ( {/* 搜索框 - 完全参考数据集管理的设计 */} onSearchChange(e.target.value)} /> {/* 更多筛选按钮 - 带 Badge 显示活跃筛选条件数 */} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/ImageDatasetHeader.js ================================================ 'use client'; import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper } from '@mui/material'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import DeleteIcon from '@mui/icons-material/Delete'; import UndoIcon from '@mui/icons-material/Undo'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; /** * 图片数据集详情页面的头部导航组件 */ export default function ImageDatasetHeader({ projectId, datasetsAllCount, datasetsConfirmCount, confirming, unconfirming, currentDataset, onNavigate, onConfirm, onUnconfirm, onDelete }) { const router = useRouter(); const { t } = useTranslation(); return ( {/* 左侧:返回按钮和统计信息 */} 共 {datasetsAllCount} 个数据集,已确认 {datasetsConfirmCount} 个 ( {datasetsAllCount > 0 ? ((datasetsConfirmCount / datasetsAllCount) * 100).toFixed(2) : 0}%) {/* 右侧:翻页、确认/取消确认、删除按钮 */} onNavigate('prev')}> onNavigate('next')}> {/* 确认/取消确认按钮 */} {currentDataset?.confirmed ? ( ) : ( )} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/MetadataEditor.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, Divider, Paper } from '@mui/material'; import { toast } from 'sonner'; import StarRating from '@/components/datasets/StarRating'; import TagSelector from '@/components/datasets/TagSelector'; import NoteInput from '@/components/datasets/NoteInput'; import { useTranslation } from 'react-i18next'; export default function MetadataEditor({ dataset, projectId, onUpdate }) { const { t } = useTranslation(); const [availableTags, setAvailableTags] = useState([]); const [loading, setLoading] = useState(false); // 解析数据集中的标签 const parseDatasetTags = tagsString => { try { return JSON.parse(tagsString || '[]'); } catch (e) { return []; } }; // 本地状态管理,从 props 初始化 const [localScore, setLocalScore] = useState(dataset?.score || 0); const [localTags, setLocalTags] = useState(() => { const tags = parseDatasetTags(dataset?.tags); // 确保 localTags 始终是数组 return Array.isArray(tags) ? tags : []; }); const [localNote, setLocalNote] = useState(dataset?.note || ''); // 获取项目中已使用的标签 useEffect(() => { const fetchAvailableTags = async () => { try { const response = await fetch(`/api/projects/${projectId}/image-datasets/tags`); if (response.ok) { const data = await response.json(); setAvailableTags(data.tags || []); } } catch (error) { console.error('获取可用标签失败:', error); } }; if (projectId) { fetchAvailableTags(); } }, [projectId]); // 同步props中的dataset到本地状态 useEffect(() => { if (dataset) { setLocalScore(dataset.score || 0); const tags = parseDatasetTags(dataset.tags); setLocalTags(Array.isArray(tags) ? tags : []); setLocalNote(dataset.note || ''); } }, [dataset]); // 更新数据集元数据 const updateMetadata = async updates => { if (loading) return; // 立即更新本地状态,提升响应速度 if (updates.score !== undefined) { setLocalScore(updates.score); } // 注意:tags 已经在 handleTagsChange 中更新过了,这里不需要再更新 if (updates.note !== undefined) { setLocalNote(updates.note); } setLoading(true); try { // 调用父组件的更新方法 if (onUpdate) { await onUpdate(updates); } toast.success(t('imageDatasets.updateSuccess', '更新成功')); } catch (error) { console.error('更新数据集元数据失败:', error); toast.error(t('imageDatasets.updateFailed', '更新失败')); // 出错时恢复本地状态 if (updates.score !== undefined) { setLocalScore(dataset?.score || 0); } if (updates.tags !== undefined) { // 恢复为原始的标签数组 const tags = parseDatasetTags(dataset?.tags); setLocalTags(Array.isArray(tags) ? tags : []); } if (updates.note !== undefined) { setLocalNote(dataset?.note || ''); } } finally { setLoading(false); } }; // 处理评分变更 const handleScoreChange = newScore => { updateMetadata({ score: newScore }); }; // 处理标签变更 const handleTagsChange = newTags => { // 立即更新本地状态(保持为数组) setLocalTags(newTags); // 发送给父组件时转换为 JSON 字符串 updateMetadata({ tags: JSON.stringify(newTags) }); }; // 处理备注变更 const handleNoteChange = newNote => { updateMetadata({ note: newNote }); }; return ( {/* 评分区域 */} {t('datasets.rating', '评分')} {/* 标签区域 */} {t('datasets.customTags', '自定义标签')} {/* 备注区域 */} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/components/MetadataInfo.js ================================================ 'use client'; import { Box, Typography, Chip, alpha, Divider } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; /** * 元数据信息展示组件 - Chip 形式(参考 DatasetMetadata) */ export default function MetadataInfo({ dataset }) { const { t } = useTranslation(); const theme = useTheme(); // 解析标签 const parsedTags = (() => { try { if (typeof dataset.tags === 'string' && dataset.tags) { return JSON.parse(dataset.tags); } return Array.isArray(dataset.tags) ? dataset.tags : []; } catch { return []; } })(); // 格式化文件大小 const formatFileSize = bytes => { if (!bytes) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; }; return ( {/* 数据集信息 */} {t('common.detailInfo', '详细信息')} {/* 使用模型 */} {dataset.model && ( )} {/* 标签数量 */} {parsedTags.length > 0 && ( )} {/* 创建时间 */} {/* 文本块信息 */} {dataset.questionTemplate?.description && ( )} {/* 确认状态 */} {dataset.confirmed && ( )} {/* 图片信息 */} {dataset.image && ( <> {t('images.imageInfo', '图片信息')} {/* 图片尺寸 */} {dataset.image.width && dataset.image.height && ( )} {/* 文件大小 */} {dataset.image.size && ( )} {/* 图片创建时间 */} {dataset.image.createAt && ( )} {/* 图片名称 */} {dataset.image.imageName && ( )} )} ); } ================================================ FILE: app/projects/[projectId]/image-datasets/hooks/useImageDatasetDetail.js ================================================ import { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; export function useImageDatasetDetail(projectId, datasetId) { const { t } = useTranslation(); const [dataset, setDataset] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); // 获取详情 const fetchDetail = useCallback(async () => { try { setLoading(true); const response = await axios.get(`/api/projects/${projectId}/image-datasets/${datasetId}`); setDataset(response.data); } catch (error) { console.error('Failed to fetch dataset detail:', error); toast.error(t('imageDatasets.fetchDetailFailed', { defaultValue: '获取详情失败' })); } finally { setLoading(false); } }, [projectId, datasetId, t]); // 更新数据集 const updateDataset = useCallback( async updates => { try { setSaving(true); const response = await axios.put(`/api/projects/${projectId}/image-datasets/${datasetId}`, updates); setDataset(response.data); toast.success(t('imageDatasets.updateSuccess', { defaultValue: '更新成功' })); return response.data; } catch (error) { console.error('Failed to update dataset:', error); toast.error(t('imageDatasets.updateFailed', { defaultValue: '更新失败' })); throw error; } finally { setSaving(false); } }, [projectId, datasetId, t] ); // AI 重新识别 const regenerateAnswer = useCallback(async () => { try { setSaving(true); const response = await axios.post(`/api/projects/${projectId}/image-datasets/${datasetId}/regenerate`); setDataset(response.data); toast.success(t('imageDatasets.regenerateSuccess', { defaultValue: 'AI 识别成功' })); return response.data; } catch (error) { console.error('Failed to regenerate answer:', error); toast.error(t('imageDatasets.regenerateFailed', { defaultValue: 'AI 识别失败' })); throw error; } finally { setSaving(false); } }, [projectId, datasetId, t]); useEffect(() => { if (projectId && datasetId) { fetchDetail(); } }, [projectId, datasetId, fetchDetail]); return { dataset, loading, saving, updateDataset, regenerateAnswer, fetchDetail }; } ================================================ FILE: app/projects/[projectId]/image-datasets/hooks/useImageDatasetDetails.js ================================================ 'use client'; import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import axios from 'axios'; export default function useImageDatasetDetails(projectId, datasetId) { const router = useRouter(); const { t } = useTranslation(); const [currentDataset, setCurrentDataset] = useState(null); const [loading, setLoading] = useState(true); const [confirming, setConfirming] = useState(false); const [unconfirming, setUnconfirming] = useState(false); const [saving, setSaving] = useState(false); const [datasetsAllCount, setDatasetsAllCount] = useState(0); const [datasetsConfirmCount, setDatasetsConfirmCount] = useState(0); // 获取数据集列表信息 const fetchDatasetsList = useCallback(async () => { try { // 获取所有数据集以正确统计已确认数量 const response = await axios.get(`/api/projects/${projectId}/image-datasets?page=1&pageSize=10000`); const data = response.data; setDatasetsAllCount(data.total || 0); setDatasetsConfirmCount(data.data?.filter(d => d.confirmed).length || 0); } catch (error) { console.error('Failed to fetch datasets list:', error); } }, [projectId]); // 获取当前数据集详情 const fetchDatasetDetail = useCallback(async () => { try { setLoading(true); const response = await axios.get(`/api/projects/${projectId}/image-datasets/${datasetId}`); setCurrentDataset(response.data); } catch (error) { console.error('Failed to fetch dataset detail:', error); toast.error(t('imageDatasets.fetchDetailFailed', '获取详情失败')); } finally { setLoading(false); } }, [projectId, datasetId, t]); useEffect(() => { if (projectId && datasetId) { fetchDatasetDetail(); fetchDatasetsList(); } }, [projectId, datasetId, fetchDatasetDetail, fetchDatasetsList]); // 更新数据集 const updateDataset = useCallback( async updates => { try { setSaving(true); await axios.put(`/api/projects/${projectId}/image-datasets/${datasetId}`, updates); toast.success(t('imageDatasets.updateSuccess', '更新成功')); // 刷新数据 await fetchDatasetDetail(); await fetchDatasetsList(); } catch (error) { console.error('Failed to update dataset:', error); toast.error(t('imageDatasets.updateFailed', '更新失败')); } finally { setSaving(false); } }, [projectId, datasetId, t, fetchDatasetDetail, fetchDatasetsList] ); // 翻页导航 const handleNavigate = useCallback( async (direction, skipCurrentId = null) => { try { // 获取所有数据集(不分页),使用一个足够大的 pageSize const response = await axios.get(`/api/projects/${projectId}/image-datasets?page=1&pageSize=10000`); const datasets = response.data.data || []; if (datasets.length === 0) { router.push(`/projects/${projectId}/image-datasets`); return; } // 确定当前索引 let currentIndex = -1; const searchId = skipCurrentId || datasetId; const currentDatasetId = String(searchId); // 查找当前数据集的索引 currentIndex = datasets.findIndex(d => String(d.id) === currentDatasetId); // 如果找不到(删除场景或其他原因),从第一个开始 if (currentIndex === -1) { currentIndex = 0; } // 计算下一个索引 let nextIndex; if (direction === 'prev') { nextIndex = currentIndex > 0 ? currentIndex - 1 : datasets.length - 1; } else { nextIndex = currentIndex < datasets.length - 1 ? currentIndex + 1 : 0; } const nextDataset = datasets[nextIndex]; if (nextDataset) { router.push(`/projects/${projectId}/image-datasets/${nextDataset.id}`); } } catch (error) { console.error('Failed to navigate:', error); toast.error(t('common.navigationFailed', '导航失败')); } }, [projectId, datasetId, router, t] ); // 确认保留 const handleConfirm = useCallback(async () => { setConfirming(true); try { await updateDataset({ confirmed: true }); // 确认后导航到下一条 await handleNavigate('next'); } finally { setConfirming(false); } }, [updateDataset, handleNavigate]); // 取消确认 const handleUnconfirm = useCallback(async () => { setUnconfirming(true); try { await updateDataset({ confirmed: false }); } finally { setUnconfirming(false); } }, [updateDataset]); // 删除数据集 const handleDelete = useCallback(async () => { if (confirm(t('imageDatasets.deleteConfirm', '确定要删除这个数据集吗?'))) { try { await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`); toast.success(t('imageDatasets.deleteSuccess', '删除成功')); // 导航到下一条,传递 datasetId 以便 handleNavigate 知道是删除场景 await handleNavigate('next', datasetId); } catch (error) { console.error('Failed to delete dataset:', error); toast.error(t('imageDatasets.deleteFailed', '删除失败')); } } }, [projectId, datasetId, handleNavigate, t]); return { currentDataset, loading, saving, confirming, unconfirming, datasetsAllCount, datasetsConfirmCount, updateDataset, handleNavigate, handleConfirm, handleUnconfirm, handleDelete }; } ================================================ FILE: app/projects/[projectId]/image-datasets/hooks/useImageDatasetExport.js ================================================ 'use client'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import axios from 'axios'; const useImageDatasetExport = projectId => { const { t } = useTranslation(); /** * 解析标签格式的答案 * 如果答案是 JSON 数组格式,解析并用逗号连接 */ const parseAnswerLabels = item => { const { answer, answerType } = item; if (answerType !== 'label' || !answer) { return answer; } try { // 尝试解析 JSON const parsed = JSON.parse(answer); if (Array.isArray(parsed)) { // 如果是数组,用逗号连接 return parsed.join(', '); } return answer; } catch (e) { // 不是 JSON 格式,直接返回原答案 return answer; } }; /** * 导出图片数据集 */ const exportImageDatasets = async exportOptions => { try { // 1. 获取数据集数据 const apiUrl = `/api/projects/${projectId}/image-datasets/export`; const response = await axios.post(apiUrl, { confirmedOnly: exportOptions.confirmedOnly }); let datasets = response.data; if (!datasets || datasets.length === 0) { toast.warning(t('imageDatasets.noDataToExport', '没有可导出的数据')); return false; } // 2. 处理答案中的标签格式 datasets = datasets.map(item => ({ ...item, answer: parseAnswerLabels(item) })); // 3. 根据格式类型转换数据 let formattedData; if (exportOptions.formatType === 'raw') { // 原始格式:直接导出数据集 formattedData = datasets.map(item => { const result = { ...item }; // 如果需要包含图片路径 if (exportOptions.includeImagePath && item.imageName) { result.image_path = `/images/${item.imageName}`; } if (item.answerType === 'custom_format') { try { result.answerObj = JSON.parse(item.answer); } catch {} } return result; }); } else if (exportOptions.formatType === 'alpaca') { formattedData = datasets.map(({ question, answer, imageName }) => { const item = { instruction: question, input: '', output: answer }; // 如果需要包含图片路径 if (exportOptions.includeImagePath && imageName) { item.images = [`/images/${imageName}`]; } return item; }); } else if (exportOptions.formatType === 'sharegpt') { formattedData = datasets.map(({ question, answer, imageName }) => { const messages = []; // 添加系统提示词(如果有) if (exportOptions.systemPrompt) { messages.push({ role: 'system', content: exportOptions.systemPrompt }); } // 添加用户问题 const userContent = []; // 如果需要包含图片路径 if (exportOptions.includeImagePath && imageName) { userContent.push({ type: 'image_url', image_url: { url: `/images/${imageName}` } }); } userContent.push({ type: 'text', text: question }); messages.push({ role: 'user', content: userContent }); // 添加助手回答 messages.push({ role: 'assistant', content: answer }); return { messages }; }); } // 4. 生成 JSON 文件 const jsonContent = JSON.stringify(formattedData, null, 2); const blob = new Blob([jsonContent], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const formatSuffix = exportOptions.formatType; const dateStr = new Date().toISOString().slice(0, 10); a.download = `image-datasets-${projectId}-${formatSuffix}-${dateStr}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(t('imageDatasets.exportSuccess', '数据集导出成功')); // 5. 如果需要导出图片,调用压缩包接口 if (exportOptions.exportImages) { try { const params = new URLSearchParams({ confirmedOnly: exportOptions.confirmedOnly.toString() }); const zipUrl = `/api/projects/${projectId}/image-datasets/export-zip?${params.toString()}`; // 创建一个隐藏的 a 标签来触发下载 const a = document.createElement('a'); a.href = zipUrl; a.style.display = 'none'; a.target = '_blank'; document.body.appendChild(a); a.click(); document.body.removeChild(a); toast.success(t('imageDatasets.exportImagesSuccess', '图片压缩包导出成功')); } catch (error) { console.error('Failed to export images:', error); toast.error(t('imageDatasets.exportImagesFailed', '图片导出失败')); } } return true; } catch (error) { console.error('Export failed:', error); toast.error(error.message || t('imageDatasets.exportFailed', '导出失败')); return false; } }; return { exportImageDatasets }; }; export default useImageDatasetExport; ================================================ FILE: app/projects/[projectId]/image-datasets/hooks/useImageDatasetFilters.js ================================================ import { useState, useEffect, useCallback } from 'react'; const STORAGE_KEY = 'imageDatasetFilters'; export function useImageDatasetFilters(projectId) { const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [scoreFilter, setScoreFilter] = useState([0, 5]); const [isInitialized, setIsInitialized] = useState(false); // 从 localStorage 恢复筛选条件 useEffect(() => { try { const stored = localStorage.getItem(`${STORAGE_KEY}_${projectId}`); if (stored) { const filters = JSON.parse(stored); setSearchQuery(filters.searchQuery || ''); setStatusFilter(filters.statusFilter || 'all'); setScoreFilter(filters.scoreFilter || [0, 5]); } } catch (error) { console.error('Failed to restore filters:', error); } setIsInitialized(true); }, [projectId]); // 保存筛选条件到 localStorage useEffect(() => { if (isInitialized) { try { localStorage.setItem( `${STORAGE_KEY}_${projectId}`, JSON.stringify({ searchQuery, statusFilter, scoreFilter }) ); } catch (error) { console.error('Failed to save filters:', error); } } }, [projectId, searchQuery, statusFilter, scoreFilter, isInitialized]); // 计算活跃筛选条件数 const getActiveFilterCount = useCallback(() => { let count = 0; if (statusFilter !== 'all') count++; if (scoreFilter[0] > 0 || scoreFilter[1] < 5) count++; return count; }, [statusFilter, scoreFilter]); // 重置筛选条件 const resetFilters = useCallback(() => { setSearchQuery(''); setStatusFilter('all'); setScoreFilter([0, 5]); }, []); return { searchQuery, setSearchQuery, statusFilter, setStatusFilter, scoreFilter, setScoreFilter, isInitialized, getActiveFilterCount, resetFilters }; } ================================================ FILE: app/projects/[projectId]/image-datasets/hooks/useImageDatasets.js ================================================ import { useState, useEffect, useCallback, useMemo } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; export function useImageDatasets(projectId, filters = {}) { const { t } = useTranslation(); const [datasets, setDatasets] = useState({ data: [], total: 0 }); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const pageSize = 20; // 使用 useMemo 稳定 filters 对象引用 const stableFilters = useMemo( () => ({ search: filters.search || '', confirmed: filters.confirmed, minScore: filters.minScore, maxScore: filters.maxScore }), [filters.search, filters.confirmed, filters.minScore, filters.maxScore] ); // 获取数据集列表 const fetchDatasets = useCallback(async () => { try { setLoading(true); let url = `/api/projects/${projectId}/image-datasets?page=${page}&pageSize=${pageSize}`; // 搜索条件 if (stableFilters.search) { url += `&search=${encodeURIComponent(stableFilters.search)}`; } // 确认状态筛选 if (stableFilters.confirmed !== undefined) { url += `&confirmed=${stableFilters.confirmed}`; } // 评分筛选 if (stableFilters.minScore !== undefined || stableFilters.maxScore !== undefined) { if (stableFilters.minScore !== undefined) { url += `&minScore=${stableFilters.minScore}`; } if (stableFilters.maxScore !== undefined) { url += `&maxScore=${stableFilters.maxScore}`; } } const response = await axios.get(url); setDatasets(response.data); } catch (error) { console.error('Failed to fetch datasets:', error); toast.error(t('imageDatasets.fetchFailed', { defaultValue: '获取数据集失败' })); } finally { setLoading(false); } }, [projectId, page, pageSize, stableFilters, t]); // 删除数据集 const deleteDataset = useCallback( async datasetId => { try { await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`); toast.success(t('imageDatasets.deleteSuccess', { defaultValue: '删除成功' })); fetchDatasets(); } catch (error) { console.error('Failed to delete dataset:', error); toast.error(t('imageDatasets.deleteFailed', { defaultValue: '删除失败' })); } }, [projectId, fetchDatasets, t] ); useEffect(() => { if (projectId) { fetchDatasets(); } }, [projectId, page, stableFilters, fetchDatasets]); return { datasets, loading, page, setPage, pageSize, fetchDatasets, deleteDataset }; } ================================================ FILE: app/projects/[projectId]/image-datasets/page.js ================================================ 'use client'; import { useState } from 'react'; import { Container, Box, Typography, Grid, Pagination, CircularProgress, Card, Button } from '@mui/material'; import { useParams, useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { toast } from 'sonner'; import { imageDatasetStyles } from './styles/imageDatasetStyles'; import { useImageDatasets } from './hooks/useImageDatasets'; import { useImageDatasetFilters } from './hooks/useImageDatasetFilters'; import ImageDatasetFilters from './components/ImageDatasetFilters'; import ImageDatasetFilterDialog from './components/ImageDatasetFilterDialog'; import ImageDatasetCard from './components/ImageDatasetCard'; import EmptyState from './components/EmptyState'; import ExportImageDatasetDialog from './components/ExportImageDatasetDialog'; import useImageDatasetExport from './hooks/useImageDatasetExport'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { alpha } from '@mui/material/styles'; export default function ImageDatasetsPage() { const { projectId } = useParams(); const router = useRouter(); const { t } = useTranslation(); const [filterDialogOpen, setFilterDialogOpen] = useState(false); const [exportDialogOpen, setExportDialogOpen] = useState(false); // 使用筛选 Hook const { searchQuery, setSearchQuery, statusFilter, setStatusFilter, scoreFilter, setScoreFilter, getActiveFilterCount, resetFilters } = useImageDatasetFilters(projectId); // 使用数据 Hook const { datasets, loading, page, setPage, pageSize, fetchDatasets } = useImageDatasets(projectId, { search: searchQuery, confirmed: statusFilter === 'all' ? undefined : statusFilter === 'confirmed', minScore: scoreFilter[0], maxScore: scoreFilter[1] }); // 使用导出 Hook const { exportImageDatasets } = useImageDatasetExport(projectId); const handlePageChange = (event, value) => { setPage(value); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleCardClick = datasetId => { router.push(`/projects/${projectId}/image-datasets/${datasetId}`); }; const handleViewDetails = datasetId => { router.push(`/projects/${projectId}/image-datasets/${datasetId}`); }; const handleDeleteDataset = async datasetId => { if (confirm(t('imageDatasets.deleteConfirm', '确定要删除这个数据集吗?'))) { try { await axios.delete(`/api/projects/${projectId}/image-datasets/${datasetId}`); toast.success(t('imageDatasets.deleteSuccess', '删除成功')); // 重新查询数据 fetchDatasets(); } catch (error) { console.error('Failed to delete dataset:', error); toast.error(t('imageDatasets.deleteFailed', '删除失败')); } } }; const handleEvaluateDataset = datasetId => { toast.info(t('common.comingSoon', '功能开发中...')); }; const handleResetFilters = () => { resetFilters(); setFilterDialogOpen(false); }; const handleApplyFilters = () => { setFilterDialogOpen(false); setPage(1); }; const handleExport = async exportOptions => { setExportDialogOpen(false); await exportImageDatasets(exportOptions); }; const totalPages = Math.ceil(datasets.total / pageSize); return ( {/* 筛选区域 - 参考数据集管理的设计 */} alpha(theme.palette.primary.main, 0.06) }} > { setSearchQuery(value); setPage(1); }} onMoreFiltersClick={() => setFilterDialogOpen(true)} activeFilterCount={getActiveFilterCount()} /> {/* 数据集列表 */} {loading ? ( ) : datasets.data.length === 0 ? ( ) : ( <> {datasets.data.map(dataset => ( ))} {/* 分页 */} {totalPages > 1 && ( )} )} {/* 筛选对话框 */} setFilterDialogOpen(false)} statusFilter={statusFilter} scoreFilter={scoreFilter} onStatusChange={setStatusFilter} onScoreChange={setScoreFilter} onResetFilters={handleResetFilters} onApplyFilters={handleApplyFilters} /> {/* 导出对话框 */} setExportDialogOpen(false)} onExport={handleExport} /> ); } ================================================ FILE: app/projects/[projectId]/image-datasets/styles/imageDatasetStyles.js ================================================ /** * 图片数据集模块样式配置 * 参考图片管理模块的精美设计 */ export const imageDatasetStyles = { // 页面容器 pageContainer: { py: 4 }, // 页面头部 header: { mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 3 }, headerTitle: { display: 'flex', flexDirection: 'column', gap: 0.5 }, title: { fontWeight: 700 }, subtitle: { color: 'text.secondary', fontSize: '0.875rem' }, headerActions: { display: 'flex', gap: 2, flexWrap: 'wrap' }, // 筛选区域 filterCard: { mb: 3, borderRadius: 2, boxShadow: 1, border: '1px solid', borderColor: 'divider', overflow: 'visible' }, filterContent: { display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }, // 数据集卡片 - 参考图片管理的设计 datasetCard: { height: '100%', display: 'flex', flexDirection: 'column', borderRadius: 3, overflow: 'hidden', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', border: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', cursor: 'pointer', '&:hover': { transform: 'translateY(-8px)', boxShadow: theme => `0 12px 24px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.15)'}`, borderColor: 'primary.main', '& .image-overlay': { opacity: 1 }, '& .image-media': { transform: 'scale(1.05)' } } }, // 图片包装器 imageWrapper: { position: 'relative', overflow: 'hidden', bgcolor: 'grey.100' }, // 图片媒体 imageMedia: { className: 'image-media', height: 220, objectFit: 'cover', transition: 'transform 0.3s ease' }, // 悬停遮罩 imageOverlay: { className: 'image-overlay', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.6) 100%)', opacity: 0, transition: 'opacity 0.3s ease', pointerEvents: 'none' }, // 状态标签容器 - 右上角 statusChipsContainer: { position: 'absolute', top: 12, right: 12, display: 'flex', gap: 0.5, flexDirection: 'column', alignItems: 'flex-end', zIndex: 2 }, // 状态标签 statusChip: { backdropFilter: 'blur(10px)', fontWeight: 600, fontSize: '0.75rem', height: 24, boxShadow: 2 }, // 图片名称容器 - 底部 imageNameContainer: { position: 'absolute', bottom: 12, left: 12, right: 12, display: 'flex', justifyContent: 'center', zIndex: 2 }, // 图片名称标签 imageNameChip: { backdropFilter: 'blur(10px)', bgcolor: 'rgba(255, 255, 255, 0.95)', fontWeight: 600, maxWidth: '90%', boxShadow: 2, '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, // 卡片内容 cardContent: { flexGrow: 1, p: 2.5 }, // 问题文本 questionText: { fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.5, mb: 1.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis' }, // 答案预览 answerPreview: { color: 'text.secondary', fontSize: '0.875rem', lineHeight: 1.6, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis', mb: 2 }, // 元数据信息 metaInfo: { display: 'flex', gap: 1.5, flexWrap: 'wrap', mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }, metaItem: { display: 'flex', alignItems: 'center', gap: 0.5, fontSize: '0.75rem', color: 'text.secondary' }, // 分页样式 pagination: { display: 'flex', justifyContent: 'center', mt: 4 }, // 操作按钮容器 actionButtonsContainer: { display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }, // 操作按钮样式 actionButton: { p: 0.5, borderRadius: 1, color: 'text.secondary', '&:hover': { backgroundColor: 'action.hover', color: 'primary.main' } }, // 空状态 emptyState: { textAlign: 'center', py: 12, px: 3 }, emptyIcon: { width: 120, height: 120, borderRadius: '50%', bgcolor: 'primary.lighter', display: 'flex', alignItems: 'center', justifyContent: 'center', mx: 'auto', mb: 3 }, emptyTitle: { fontWeight: 600, mb: 1 }, emptyDescription: { color: 'text.secondary', mb: 4 } }; ================================================ FILE: app/projects/[projectId]/images/components/DatasetDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, CircularProgress, Alert, Box, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; import { toast } from 'sonner'; import axios from 'axios'; export default function DatasetDialog({ open, projectId, image, onClose, onSuccess }) { const { t, i18n } = useTranslation(); const selectedModel = useAtomValue(selectedModelInfoAtom); const [question, setQuestion] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); useEffect(() => { if (open) { setQuestion(''); setError(''); } }, [open]); const handleGenerate = async () => { if (!selectedModel) { setError(t('images.selectModelFirst')); return; } if (selectedModel.type !== 'vision') { setError(t('images.visionModelRequired')); return; } if (!question.trim()) { setError(t('images.questionRequired')); return; } try { setLoading(true); setError(''); await axios.post(`/api/projects/${projectId}/images/datasets`, { imageName: image.imageName, question: { question: question.trim() }, model: selectedModel, language: i18n.language }); toast.success(t('images.datasetGenerated')); onSuccess?.(); onClose(); } catch (err) { console.error('Failed to generate dataset:', err); setError(err.response?.data?.error || t('images.generateFailed')); } finally { setLoading(false); } }; const handleClose = () => { if (!loading) { onClose(); } }; return ( {t('images.generateDataset')} {error && ( {error} )} {image && ( {t('images.imageName')} {image.imageName} )} setQuestion(e.target.value)} placeholder={t('images.questionPlaceholder')} disabled={loading} /> {selectedModel && ( {t('images.currentModel')}: {selectedModel.modelName} )} ); } ================================================ FILE: app/projects/[projectId]/images/components/ImageFilters.js ================================================ 'use client'; import { Box, TextField, Select, MenuItem, FormControl, InputLabel, InputAdornment, Card, CardContent, ToggleButtonGroup, ToggleButton } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import GridViewIcon from '@mui/icons-material/GridView'; import ViewListIcon from '@mui/icons-material/ViewList'; import { useTranslation } from 'react-i18next'; import { useDebounce } from '@/hooks/useDebounce'; import { useEffect, useState } from 'react'; import { imageStyles } from '../styles/imageStyles'; export default function ImageFilters({ imageName, onImageNameChange, hasQuestions, onHasQuestionsChange, hasDatasets, onHasDatasetsChange, viewMode = 'grid', onViewModeChange }) { const { t } = useTranslation(); const [localImageName, setLocalImageName] = useState(imageName); const debouncedImageName = useDebounce(localImageName, 500); useEffect(() => { onImageNameChange(debouncedImageName); }, [debouncedImageName]); return ( {/* 搜索框 */} setLocalImageName(e.target.value)} size="small" sx={imageStyles.searchField} InputProps={{ startAdornment: ( ) }} /> {/* 问题状态筛选 */} {t('images.hasQuestions', { defaultValue: '问题状态' })} {/* 数据集状态筛选 */} {t('images.hasDatasets', { defaultValue: '数据集状态' })} {/* 视图切换 */} {onViewModeChange && ( newMode && onViewModeChange(newMode)} size="small" sx={imageStyles.viewToggle} > )} ); } ================================================ FILE: app/projects/[projectId]/images/components/ImageGrid.js ================================================ 'use client'; import { useState } from 'react'; import { Grid, Card, CardMedia, CardContent, CardActions, Typography, Chip, Box, Pagination, Tooltip, Dialog, DialogContent, IconButton, Button } from '@mui/material'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import DatasetIcon from '@mui/icons-material/Dataset'; import DeleteIcon from '@mui/icons-material/Delete'; import EditNoteIcon from '@mui/icons-material/EditNote'; import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary'; import { useTranslation } from 'react-i18next'; import { imageStyles } from '../styles/imageStyles'; export default function ImageGrid({ images, total, page, pageSize, onPageChange, onGenerateQuestions, onGenerateDataset, onDelete, onAnnotate }) { const { t } = useTranslation(); const [previewImage, setPreviewImage] = useState(null); if (!images || images.length === 0) { return ( {t('images.noImages', { defaultValue: '还没有图片' })} {t('images.noImagesDescription', { defaultValue: '开始导入图片,创建您的第一个图片数据集' })} ); } return ( <> {images.map(image => ( {/* 图片区域 */} setPreviewImage(image)} /> {/* 悬停遮罩 */} {/* 状态标签 - 悬浮在图片右上角 */} 0 ? 'primary' : 'default'} sx={imageStyles.statusChip} /> 0 ? 'success' : 'default'} sx={imageStyles.statusChip} /> {/* 文件名标签 - 悬浮在图片底部 */} {/* 操作按钮区域 */} onGenerateQuestions(image)} sx={imageStyles.actionIconButton}> onGenerateDataset(image)} sx={imageStyles.actionIconButton}> onDelete(image.id)} sx={imageStyles.actionIconButton} > ))} {total > pageSize && ( onPageChange(newPage)} color="primary" size="large" showFirstButton showLastButton /> )} {/* 图片预览对话框 */} setPreviewImage(null)} maxWidth="lg" fullWidth PaperProps={{ sx: { bgcolor: 'transparent', boxShadow: 'none', overflow: 'hidden' } }} > {previewImage && ( {previewImage.imageName} {previewImage.imageName} )} ); } ================================================ FILE: app/projects/[projectId]/images/components/ImageList.js ================================================ 'use client'; import { useState } from 'react'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, Box, Pagination, Tooltip, IconButton, Avatar, Dialog, DialogContent, Typography, Button, Checkbox } from '@mui/material'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import DatasetIcon from '@mui/icons-material/Dataset'; import DeleteIcon from '@mui/icons-material/Delete'; import EditNoteIcon from '@mui/icons-material/EditNote'; import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary'; import VisibilityIcon from '@mui/icons-material/Visibility'; import { useTranslation } from 'react-i18next'; import { imageStyles } from '../styles/imageStyles'; export default function ImageList({ images, total, page, pageSize, onPageChange, onGenerateQuestions, onGenerateDataset, onDelete, onAnnotate, selectedIds = [], onSelectionChange }) { const { t } = useTranslation(); const [previewImage, setPreviewImage] = useState(null); // 处理全选/取消全选 const handleSelectAll = event => { if (event.target.checked) { const allIds = images.map(img => img.id); onSelectionChange?.(allIds); } else { onSelectionChange?.([]); } }; // 处理单个选择 const handleSelectOne = (imageId, checked) => { if (checked) { onSelectionChange?.([...selectedIds, imageId]); } else { onSelectionChange?.(selectedIds.filter(id => id !== imageId)); } }; // 判断是否全选 const isAllSelected = images.length > 0 && selectedIds.length === images.length; const isSomeSelected = selectedIds.length > 0 && selectedIds.length < images.length; // 格式化日期 const formatDate = dateString => { if (!dateString) return '-'; const date = new Date(dateString); return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }; // 格式化文件大小 const formatSize = bytes => { if (!bytes) return '-'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }; if (!images || images.length === 0) { return ( {t('images.noImages', { defaultValue: '还没有图片' })} {t('images.noImagesDescription', { defaultValue: '开始导入图片,创建您的第一个图片数据集' })} ); } return ( <> {t('images.preview', { defaultValue: '预览' })} {t('images.fileName', { defaultValue: '文件名' })} {t('images.size', { defaultValue: '大小' })} {t('images.dimensions', { defaultValue: '尺寸' })} {t('images.questionCount', { defaultValue: '问题数' })} {t('images.datasetCount', { defaultValue: '数据集数' })} {t('images.uploadTime', { defaultValue: '上传时间' })} {t('common.actions', { defaultValue: '操作' })} {images.map(image => ( {/* 复选框 */} handleSelectOne(image.id, e.target.checked)} /> {/* 预览缩略图 */} setPreviewImage(image)} /> {/* 文件名 */} {image.imageName} {/* 文件大小 */} {formatSize(image.size)} {/* 尺寸 */} {image.width && image.height ? ( {image.width} × {image.height} ) : ( - )} {/* 问题数 */} 0 ? 'primary' : 'default'} variant="outlined" /> {/* 数据集数 */} 0 ? 'success' : 'default'} variant="outlined" /> {/* 上传时间 */} {formatDate(image.createAt)} {/* 操作按钮 */} setPreviewImage(image)}> onAnnotate(image)}> onGenerateQuestions(image)}> onGenerateDataset(image)}> onDelete(image.id)}> ))}
{/* 分页 */} {total > pageSize && ( onPageChange(newPage)} color="primary" size="large" showFirstButton showLastButton /> )} {/* 图片预览对话框 */} setPreviewImage(null)} maxWidth="lg" fullWidth PaperProps={{ sx: { bgcolor: 'transparent', boxShadow: 'none', overflow: 'hidden' } }} > {previewImage && ( {previewImage.imageName} {previewImage.imageName} )} ); } ================================================ FILE: app/projects/[projectId]/images/components/ImportDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, List, ListItem, ListItemText, IconButton, CircularProgress, Alert, TextField, Tabs, Tab, Paper, Chip, Card } from '@mui/material'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import FolderZipIcon from '@mui/icons-material/FolderZip'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import axios from 'axios'; export default function ImportDialog({ open, projectId, onClose, onSuccess }) { const { t } = useTranslation(); const [mode, setMode] = useState(0); // 0: 目录导入, 1: PDF 导入, 2: 压缩包导入 const [directories, setDirectories] = useState([]); const [loading, setLoading] = useState(false); const [inputPath, setInputPath] = useState(''); const [selectedPdf, setSelectedPdf] = useState(null); const [selectedZip, setSelectedZip] = useState(null); const handleAddDirectory = () => { if (inputPath.trim() && !directories.includes(inputPath.trim())) { setDirectories([...directories, inputPath.trim()]); setInputPath(''); } }; const handleRemoveDirectory = index => { setDirectories(directories.filter((_, i) => i !== index)); }; const handleImport = async () => { if (directories.length === 0) { toast.error(t('images.selectAtLeastOne')); return; } try { setLoading(true); const response = await axios.post(`/api/projects/${projectId}/images`, { directories }); toast.success(t('images.importSuccess', { count: response.data.count })); setDirectories([]); onSuccess?.(); } catch (error) { console.error('Failed to import images:', error); toast.error(error.response?.data?.error || t('images.importFailed')); } finally { setLoading(false); } }; const handlePdfSelect = event => { const file = event.target.files?.[0]; if (file && file.type === 'application/pdf') { setSelectedPdf(file); } else { toast.error(t('images.invalidPdfFile', { defaultValue: '请选择有效的 PDF 文件' })); } }; const handlePdfImport = async () => { if (!selectedPdf) { toast.error(t('images.selectPdfFile', { defaultValue: '请选择 PDF 文件' })); return; } try { setLoading(true); const formData = new FormData(); formData.append('file', selectedPdf); // 调用 PDF 转换 API const response = await axios.post(`/api/projects/${projectId}/images/pdf-convert`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); toast.success( t('images.pdfImportSuccess', { defaultValue: `成功从 PDF "${response.data.pdfName}" 导入 ${response.data.count} 张图片`, count: response.data.count, name: response.data.pdfName }) ); setSelectedPdf(null); onSuccess?.(); } catch (error) { console.error('Failed to import PDF:', error); toast.error(error.response?.data?.error || t('images.pdfImportFailed', { defaultValue: 'PDF 导入失败' })); } finally { setLoading(false); } }; const handleZipSelect = event => { const file = event.target.files?.[0]; if (file && file.name.toLowerCase().endsWith('.zip')) { setSelectedZip(file); } else { toast.error(t('images.invalidZipFile', { defaultValue: '请选择有效的 ZIP 文件' })); } }; const handleZipImport = async () => { if (!selectedZip) { toast.error(t('images.selectZipFile', { defaultValue: '请选择 ZIP 文件' })); return; } try { setLoading(true); const formData = new FormData(); formData.append('file', selectedZip); // 调用压缩包导入 API const response = await axios.post(`/api/projects/${projectId}/images/zip-import`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }); toast.success( t('images.zipImportSuccess', { defaultValue: `成功从压缩包 "${response.data.zipName}" 导入 ${response.data.count} 张图片`, count: response.data.count, name: response.data.zipName }) ); setSelectedZip(null); onSuccess?.(); } catch (error) { console.error('Failed to import ZIP:', error); toast.error(error.response?.data?.error || t('images.zipImportFailed', { defaultValue: '压缩包导入失败' })); } finally { setLoading(false); } }; const handleClose = () => { if (!loading) { setDirectories([]); setSelectedPdf(null); setSelectedZip(null); setMode(0); onClose(); } }; return ( {t('images.importImages')} setMode(newValue)} sx={{ mb: 3, borderBottom: 1, borderColor: 'divider' }} > } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" /> {mode === 0 ? ( <> {t('images.importTip')} setInputPath(e.target.value)} onKeyPress={e => { if (e.key === 'Enter') { handleAddDirectory(); } }} disabled={loading} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }} /> {directories.length > 0 && ( {t('images.selectedDirectories')} ({directories.length}) {directories.map((dir, index) => ( handleRemoveDirectory(index)} disabled={loading} icon={} sx={{ borderRadius: 1.5, fontWeight: 500, maxWidth: '100%', '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }} /> ))} )} ) : mode === 1 ? ( <> {t('images.pdfImportTip', { defaultValue: '选择 PDF 文件,系统会自动将其转换为图片并导入' })} document.getElementById('pdf-file-input').click()} > {selectedPdf ? selectedPdf.name : t('images.clickToSelectPdf', { defaultValue: '点击选择 PDF 文件' })} {t('images.supportedFormat', { defaultValue: '支持格式:PDF' })} {selectedPdf && ( {t('images.fileSize', { defaultValue: '文件大小' })}: {(selectedPdf.size / 1024 / 1024).toFixed(2)} MB )} ) : ( <> {t('images.zipImportTip', { defaultValue: '选择 ZIP 压缩包文件,系统会自动解压并导入其中的图片' })} document.getElementById('zip-file-input').click()} > {selectedZip ? selectedZip.name : t('images.clickToSelectZip', { defaultValue: '点击选择 ZIP 文件' })} {t('images.supportedZipFormat', { defaultValue: '支持格式:ZIP' })} {selectedZip && ( {t('images.fileSize', { defaultValue: '文件大小' })}: {(selectedZip.size / 1024 / 1024).toFixed(2)} MB )} )} {mode === 0 ? ( ) : mode === 1 ? ( ) : ( )} ); } ================================================ FILE: app/projects/[projectId]/images/components/QuestionDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, CircularProgress, Alert, Box, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; import { toast } from 'sonner'; import axios from 'axios'; export default function QuestionDialog({ open, projectId, image, onClose, onSuccess }) { const { t, i18n } = useTranslation(); const selectedModel = useAtomValue(selectedModelInfoAtom); const [count, setCount] = useState(3); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); useEffect(() => { if (open) { setCount(3); setError(''); } }, [open]); const handleGenerate = async () => { if (!selectedModel) { setError(t('images.selectModelFirst')); return; } if (selectedModel.type !== 'vision') { setError(t('images.visionModelRequired')); return; } if (count < 1 || count > 10) { setError(t('images.countRange')); return; } try { setLoading(true); setError(''); const response = await axios.post(`/api/projects/${projectId}/images/questions`, { imageName: image.imageName, count, model: selectedModel, language: i18n.language }); toast.success(t('images.questionsGenerated', { count: response.data.questions.length })); onSuccess?.(); onClose(); } catch (err) { console.error('Failed to generate questions:', err); setError(err.response?.data?.error || t('images.generateFailed')); } finally { setLoading(false); } }; const handleClose = () => { if (!loading) { onClose(); } }; return ( {t('images.generateQuestions')} {error && ( {error} )} {image && ( {t('images.imageName')} {image.imageName} )} setCount(parseInt(e.target.value) || 1)} inputProps={{ min: 1, max: 10 }} helperText={t('images.questionCountHelp')} disabled={loading} /> {selectedModel && ( {t('images.currentModel')}: {selectedModel.modelName} )} ); } ================================================ FILE: app/projects/[projectId]/images/components/annotation/AIGenerateButton.js ================================================ 'use client'; import { Button, CircularProgress } from '@mui/material'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; /** * AI 生成答案按钮组件 * @param {string} projectId - 项目ID * @param {string} imageName - 图片名称 * @param {string} question - 问题内容 * @param {function} onSuccess - 生成成功的回调,接收生成的答案 * @param {boolean} previewOnly - 是否只预览(不保存数据集),默认 true * @param {object} sx - 自定义样式 */ export default function AIGenerateButton({ projectId, imageName, question, onSuccess, previewOnly = true, sx = {}, answerType }) { const { t, i18n } = useTranslation(); const [loading, setLoading] = useState(false); const model = useAtomValue(selectedModelInfoAtom); const handleGenerate = async () => { if (!projectId || !imageName || !question) { toast.error(t('images.missingParameters', { defaultValue: '缺少必要参数' })); return; } if (model.type !== 'vision') { toast.error(t('images.visionModelRequired', { defaultValue: '请选择支持视觉的模型' })); return; } setLoading(true); try { const response = await axios.post(`/api/projects/${projectId}/images/datasets`, { imageName, question, model, language: i18n.language, previewOnly }); if (response.data.success && response.data.answer) { let data = response.data.answer; if (answerType === 'label') { try { data = JSON.parse(response.data.answer); } catch {} } onSuccess(data); toast.success(t('images.aiGenerateSuccess', { defaultValue: 'AI 生成成功' })); } } catch (error) { console.error('AI 生成失败:', error); const errorMsg = error.response?.data?.error || t('images.aiGenerateFailed', { defaultValue: 'AI 生成失败' }); toast.error(errorMsg); } finally { setLoading(false); } }; return ( ); } ================================================ FILE: app/projects/[projectId]/images/components/annotation/AnnotationDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, Chip, CircularProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Image from 'next/image'; import QuestionSelector from './QuestionSelector'; import AnswerInput from './AnswerInput'; export default function AnnotationDialog({ open, onClose, image, templates, selectedTemplate, onTemplateChange, answer, onAnswerChange, onSave, onSaveAndContinue, saving, loading, onOpenCreateQuestion, onOpenCreateTemplate }) { const { t } = useTranslation(); if (!image) return null; return ( {t('images.annotateImage', { defaultValue: '标注图片' })} {image && ( )} {/* 图片预览区域 */} {/* 图片预览 */} {image && ( <> {image.base64 ? ( {image.imageName} ) : ( {t('images.imageLoadError', { defaultValue: '图片加载失败' })} )} {/* 图片信息卡片 */} {image.imageName} {image.width && image.height && ( )} {image.size && ( )} {image.format && } {t('images.annotatedCount', { defaultValue: '已标注' })}: {image.datasetCount || 0}{' '} {t('images.questions', { defaultValue: '个问题' })} )} {/* 标注区域 */} {/* 问题选择器 */} {loading ? ( ) : ( )} {/* 答案输入区域 */} {selectedTemplate && ( )} {/* 左侧:创建按钮 */} {/* 右侧:操作按钮 */} ); } ================================================ FILE: app/projects/[projectId]/images/components/annotation/AnswerInput.js ================================================ 'use client'; import { Box, Typography, TextField, Chip, Button, Paper } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import AIGenerateButton from './AIGenerateButton'; export default function AnswerInput({ answerType, answer, onAnswerChange, labels, customFormat, projectId, imageName, question }) { const { t, i18n } = useTranslation(); const [newLabel, setNewLabel] = useState(''); const [jsonError, setJsonError] = useState(''); // 文字类型输入 if (answerType === 'text') { return ( {t('images.answer', { defaultValue: '文本答案' })} * onAnswerChange(e.target.value)} placeholder={t('images.answerPlaceholder', { defaultValue: '请输入答案...' })} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 3, backgroundColor: 'background.paper', '& fieldset': { borderWidth: 2, borderColor: 'divider' }, '&:hover fieldset': { borderColor: 'primary.main' }, '&.Mui-focused fieldset': { borderColor: 'primary.main', borderWidth: 2 } }, '& textarea': { fontSize: '14px', lineHeight: 1.6 } }} /> ); } // 标签类型输入 - 提前解析 labels,避免条件中的 hooks 问题 if (answerType === 'label') { const selectedLabels = Array.isArray(answer) ? answer : []; // 解析 labels(可能是 JSON 字符串或数组) let labelOptions = []; if (typeof labels === 'string' && labels) { try { labelOptions = JSON.parse(labels); } catch (e) { labelOptions = []; } } else if (Array.isArray(labels)) { labelOptions = labels; } if (!labelOptions.includes('其他') && !labelOptions.includes('other')) { labelOptions.push(i18n.language === 'en' ? 'other' : '其他'); } const handleToggleLabel = label => { if (selectedLabels.includes(label)) { onAnswerChange(selectedLabels.filter(l => l !== label)); } else { let newLabels = [...selectedLabels, label]; onAnswerChange(newLabels); } }; const handleAddNewLabel = () => { if (newLabel.trim() && !labelOptions.includes(newLabel.trim())) { handleToggleLabel(newLabel.trim()); setNewLabel(''); } }; return ( {t('images.selectLabels', { defaultValue: '标签选择' })} * {/* 可选标签 */} {t('images.availableLabels', { defaultValue: '可选标签' })} {labelOptions && labelOptions.length > 0 ? ( labelOptions.map(label => ( handleToggleLabel(label)} color={selectedLabels.includes(label) ? 'primary' : 'default'} variant={selectedLabels.includes(label) ? 'filled' : 'outlined'} sx={{ borderRadius: 2, fontWeight: 500, fontSize: '0.875rem', height: 36, cursor: 'pointer', transition: 'all 0.2s ease', '&:hover': { transform: 'translateY(-1px)', boxShadow: 2 } }} /> )) ) : ( {t('images.noLabelsAvailable', { defaultValue: '暂无可选标签' })} )} {/* 添加新标签 */} {/* setNewLabel(e.target.value)} placeholder={t('images.addNewLabel', { defaultValue: '添加新标签...' })} onKeyPress={e => { if (e.key === 'Enter') { handleAddNewLabel(); } }} sx={{ flex: 1, '& .MuiOutlinedInput-root': { borderRadius: 2, backgroundColor: 'background.paper', '& fieldset': { borderWidth: 2 }, '&:hover fieldset': { borderColor: 'primary.main' } } }} /> */} {/* 已选择标签 */} {/* {selectedLabels.length > 0 && ( {t('images.selectedLabels', { defaultValue: '已选择' })} ({selectedLabels.length}) {selectedLabels.map(label => ( handleToggleLabel(label)} color="primary" sx={{ borderRadius: 2, fontWeight: 500, fontSize: '0.875rem', height: 36, '& .MuiChip-deleteIcon': { fontSize: '18px', '&:hover': { color: 'error.main' } } }} /> ))} )} */} ); } // 自定义格式输入 if (answerType === 'custom_format') { const handleJsonChange = value => { onAnswerChange(value); // 验证 JSON 格式 if (value.trim()) { try { JSON.parse(value); setJsonError(''); } catch (e) { setJsonError(t('images.invalidJsonFormat', { defaultValue: 'JSON 格式不正确' })); } } else { setJsonError(''); } }; const handleUseTemplate = () => { if (customFormat) { try { let templateJson; if (typeof customFormat === 'string') { templateJson = JSON.parse(customFormat); } else { templateJson = customFormat; } const formatted = JSON.stringify(templateJson, null, 2); onAnswerChange(formatted); setJsonError(''); } catch (e) { onAnswerChange('{}'); } } }; if (answer && typeof answer === 'object') { answer = JSON.stringify(answer, null, 2); } return ( {t('images.customFormatAnswer', { defaultValue: '自定义格式答案' })} * {customFormat && ( )} {/* */} {/* 显示格式要求 */} {customFormat && ( {t('images.formatRequirement', { defaultValue: '格式要求' })}
                {typeof customFormat === 'string' ? customFormat : JSON.stringify(customFormat, null, 2)}
              
)} {/* JSON 输入框 */} handleJsonChange(e.target.value)} placeholder={t('images.customFormatPlaceholder', { defaultValue: '请输入符合格式的 JSON...' })} error={!!jsonError} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 3, backgroundColor: 'background.paper', '& fieldset': { borderWidth: 2 }, '&:hover fieldset': { borderColor: 'primary.main' }, '&.Mui-focused fieldset': { borderColor: 'primary.main', borderWidth: 2 }, '&.Mui-error fieldset': { borderColor: 'error.main', borderWidth: 2 } }, '& textarea': { fontFamily: 'Monaco, Consolas, "Courier New", monospace', fontSize: '13px', lineHeight: 1.5 }, '& .MuiFormHelperText-root': { fontSize: '0.875rem', fontWeight: 500 } }} />
); } return null; } ================================================ FILE: app/projects/[projectId]/images/components/annotation/QuestionSelector.js ================================================ 'use client'; import { Autocomplete, TextField, Box, Typography, Chip, Button, Dialog } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; export default function QuestionSelector({ templates, selectedTemplate, onTemplateChange, answeredQuestions = [], unansweredQuestions = [], onOpenCreateQuestion, onOpenCreateTemplate }) { const { t } = useTranslation(); const [showNoQuestionsMessage, setShowNoQuestionsMessage] = useState(false); // 构建未完成标注的问题选项(用于下拉框) const dropdownOptions = unansweredQuestions.map(q => ({ ...q, isUnanswered: true })); const getAnswerTypeLabel = answerType => { switch (answerType) { case 'text': return t('images.answerTypeText', { defaultValue: '文字' }); case 'label': return t('images.answerTypeLabel', { defaultValue: '标签' }); case 'custom_format': return t('images.answerTypeCustomFormat', { defaultValue: '自定义格式' }); default: return answerType; } }; // 判断是否有待标注问题 const hasUnansweredQuestions = unansweredQuestions.length > 0; const hasAnsweredQuestions = answeredQuestions.length > 0; const hasAnyQuestions = hasUnansweredQuestions || hasAnsweredQuestions; return ( {/* 已标注问题区域 - 优化显示为一行,添加最大高度 */} {answeredQuestions.length > 0 && ( {t('images.answeredQuestions', { defaultValue: '已标注问题' })} ({answeredQuestions.length}) {answeredQuestions.map(question => ( ))} )} {/* 问题选择下拉框 */} {t('images.selectNewQuestion', { defaultValue: '选择新问题' })} {!hasUnansweredQuestions ? ( // 没有待标注问题的提示 {hasAnsweredQuestions ? ( {t('images.allQuestionsAnnotated', { defaultValue: '当前图片所有问题已标注完成' })} ) : ( {t('images.noQuestionsAssociated', { defaultValue: '当前图片未关联任何问题' })} )} ) : ( // 有待标注问题时显示下拉框 { if (newValue) { onTemplateChange(newValue); } }} getOptionLabel={option => option.question || ''} renderOption={(props, option) => ( {option.question} )} renderInput={params => ( )} isOptionEqualToValue={(option, value) => option.id === value.id} /> )} {selectedTemplate && selectedTemplate.description && ( {selectedTemplate.description} )} ); } ================================================ FILE: app/projects/[projectId]/images/hooks/useAnnotation.js ================================================ import { useState } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; // 深度遍历 JSON,将所有值设为空字符串 function clearJsonValues(obj) { if (Array.isArray(obj)) { return obj.map(item => clearJsonValues(item)); } else if (obj !== null && typeof obj === 'object') { const cleared = {}; for (const key in obj) { cleared[key] = clearJsonValues(obj[key]); } return cleared; } else { return ''; // 所有基础类型值都变为空字符串 } } export function useAnnotation(projectId, onSuccess, onFindNextImage) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(false); const [currentImage, setCurrentImage] = useState(null); const [selectedTemplate, setSelectedTemplate] = useState(null); const [answer, setAnswer] = useState(''); // 打开标注对话框 const openAnnotation = async (image, template = null) => { setLoading(true); try { // 获取图片详情,包括已标注和未标注的问题 const response = await axios.get(`/api/projects/${projectId}/images/${image.id}`); if (response.data.success) { const imageDetail = response.data.data; setCurrentImage(imageDetail); // 如果没有指定模板,尝试选择第一个未标注的问题 if (!template) { if (imageDetail.unansweredQuestions?.length > 0) { template = imageDetail.unansweredQuestions[0]; } } setSelectedTemplate(template); // 根据问题类型初始化答案 let initialAnswer = ''; if (template?.answerType === 'label') { initialAnswer = []; } else if (template?.answerType === 'custom_format' && template?.customFormat) { // 为自定义格式提供默认值(所有字段值清空) try { let templateJson; if (typeof template.customFormat === 'string') { // 如果customFormat是字符串,尝试解析为JSON templateJson = JSON.parse(template.customFormat); } else { // 如果customFormat已经是对象,直接使用 templateJson = template.customFormat; } // 深度遍历,将所有字段值清空 const clearedJson = clearJsonValues(templateJson); initialAnswer = JSON.stringify(clearedJson, null, 2); } catch (error) { // 如枟解析失败,提供一个空的JSON对象 initialAnswer = '{}'; } } setAnswer(initialAnswer); setOpen(true); } else { toast.error(t('images.loadImageDetailFailed', { defaultValue: '加载图片详情失败' })); } } catch (error) { console.error('获取图片详情失败:', error); toast.error(t('images.loadImageDetailFailed', { defaultValue: '加载图片详情失败' })); } finally { setLoading(false); } }; // 关闭对话框 const closeAnnotation = () => { setOpen(false); setCurrentImage(null); setSelectedTemplate(null); setAnswer(''); }; // 刷新当前图片的问题列表(创建问题后调用) const refreshCurrentImage = async () => { if (!currentImage) return; try { const response = await axios.get(`/api/projects/${projectId}/images/${currentImage.id}`); if (response.data.success) { const imageDetail = response.data.data; // 更新当前图片数据 setCurrentImage(imageDetail); return imageDetail; } } catch (error) { console.error('刷新图片详情失败:', error); } }; // 查找下一个未标注的问题 const findNextUnansweredQuestion = async () => { // 重新获取图片详情,获取最新的问题列表 try { const response = await axios.get(`/api/projects/${projectId}/images/${currentImage.id}`); if (response.data.success) { const imageDetail = response.data.data; // 更新当前图片数据 setCurrentImage(imageDetail); // 返回第一个未标注的问题 if (imageDetail.unansweredQuestions?.length > 0) { return imageDetail.unansweredQuestions[0]; } return null; } } catch (error) { console.error('获取下一个问题失败:', error); return null; } }; // 保存标注 const saveAnnotation = async (continueNext = false) => { if (!currentImage) { toast.error(t('images.noImageSelected', { defaultValue: '未选择图片' })); return; } if (!selectedTemplate) { toast.error(t('images.noTemplateSelected', { defaultValue: '请选择问题' })); return; } // 验证答案 if (!answer || (Array.isArray(answer) && answer.length === 0)) { toast.error(t('images.answerRequired', { defaultValue: '请输入答案' })); return; } // 如果是自定义格式,验证 JSON 格式 if (selectedTemplate.answerType === 'custom_format') { try { JSON.parse(answer); } catch (e) { toast.error(t('images.invalidJsonFormat', { defaultValue: 'JSON 格式不正确' })); return; } } console.log(999, answer); setSaving(true); try { const response = await axios.post(`/api/projects/${projectId}/images/annotations`, { imageId: currentImage.id, imageName: currentImage.imageName, questionId: selectedTemplate.id, question: selectedTemplate.question, templateId: selectedTemplate.id, answerType: selectedTemplate.answerType, answer }); if (response.data.success) { toast.success(t('images.annotationSuccess', { defaultValue: '标注保存成功' })); // 触发刷新回调 if (onSuccess) { onSuccess(); } if (continueNext) { // 查找下一个未标注的问题 const nextQuestion = await findNextUnansweredQuestion(); if (nextQuestion) { // 切换到下一个问题 setSelectedTemplate(nextQuestion); // 根据问题类型初始化答案 let initialAnswer = ''; if (nextQuestion.answerType === 'label') { initialAnswer = []; } else if (nextQuestion.answerType === 'custom_format' && nextQuestion.customFormat) { try { let templateJson; if (typeof nextQuestion.customFormat === 'string') { templateJson = JSON.parse(nextQuestion.customFormat); } else { templateJson = nextQuestion.customFormat; } const clearedJson = clearJsonValues(templateJson); initialAnswer = JSON.stringify(clearedJson, null, 2); } catch (error) { initialAnswer = '{}'; } } setAnswer(initialAnswer); } else { // 没有更多未标注的问题了,尝试查找下一个有未标注问题的图片 if (onFindNextImage) { const nextImage = await onFindNextImage(); if (nextImage) { // 打开下一个图片的标注 await openAnnotation(nextImage); } else { // 没有更多图片了 toast.info(t('images.allImagesAnnotated', { defaultValue: '所有图片的问题都已标注完成' })); closeAnnotation(); } } else { toast.info(t('images.allQuestionsAnnotated', { defaultValue: '当前图片所有问题已标注完成' })); closeAnnotation(); } } } else { closeAnnotation(); } } } catch (error) { console.error('保存标注失败:', error); const errorMsg = error.response?.data?.error || t('images.annotationFailed', { defaultValue: '保存标注失败' }); toast.error(errorMsg); } finally { setSaving(false); } }; // 处理模板变更 const handleTemplateChange = template => { setSelectedTemplate(template); // 根据新模板类型初始化答案 let initialAnswer = ''; if (template?.answerType === 'label') { initialAnswer = []; } else if (template?.answerType === 'custom_format' && template?.customFormat) { // 为自定义格式提供默认值(所有字段值清空) try { let templateJson; if (typeof template.customFormat === 'string') { // 如果customFormat是字符串,尝试解析为JSON templateJson = JSON.parse(template.customFormat); } else { // 如果customFormat已经是对象,直接使用 templateJson = template.customFormat; } // 深度遍历,将所有字段值清空 const clearedJson = clearJsonValues(templateJson); initialAnswer = JSON.stringify(clearedJson, null, 2); } catch (error) { // 如枟解析失败,提供一个空的JSON对象 initialAnswer = '{}'; } } setAnswer(initialAnswer); }; return { open, saving, loading, currentImage, selectedTemplate, answer, setSelectedTemplate, setAnswer, handleTemplateChange, openAnnotation, closeAnnotation, saveAnnotation, refreshCurrentImage }; } ================================================ FILE: app/projects/[projectId]/images/page.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { Container, Box, Typography, Button, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material'; import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import DeleteIcon from '@mui/icons-material/Delete'; import { imageStyles } from './styles/imageStyles'; import { toast } from 'sonner'; import axios from 'axios'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; import ImageFilters from './components/ImageFilters'; import ImageGrid from './components/ImageGrid'; import ImageList from './components/ImageList'; import ImportDialog from './components/ImportDialog'; import QuestionDialog from './components/QuestionDialog'; import DatasetDialog from './components/DatasetDialog'; import AnnotationDialog from './components/annotation/AnnotationDialog'; import { useQuestionTemplates } from '../questions/hooks/useQuestionTemplates'; import { useAnnotation } from './hooks/useAnnotation'; import { useQuestionEdit } from '../questions/hooks/useQuestionEdit'; import QuestionEditDialog from '../questions/components/QuestionEditDialog'; import TemplateFormDialog from '../questions/components/template/TemplateFormDialog'; export default function ImagesPage() { const { projectId } = useParams(); const router = useRouter(); const { t, i18n } = useTranslation(); const selectedModel = useAtomValue(selectedModelInfoAtom); const [loading, setLoading] = useState(false); const [images, setImages] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize] = useState(8); // 筛选条件 const [imageName, setImageName] = useState(''); const [hasQuestions, setHasQuestions] = useState('all'); const [hasDatasets, setHasDatasets] = useState('all'); // 视图模式 const [viewMode, setViewMode] = useState('grid'); // 选中状态(仅列表视图使用) const [selectedIds, setSelectedIds] = useState([]); // 对话框状态 const [importDialogOpen, setImportDialogOpen] = useState(false); const [questionDialogOpen, setQuestionDialogOpen] = useState(false); const [datasetDialogOpen, setDatasetDialogOpen] = useState(false); const [selectedImage, setSelectedImage] = useState(null); const [autoGenerateDialogOpen, setAutoGenerateDialogOpen] = useState(false); const [questionCount, setQuestionCount] = useState(3); // 问题模板和标注功能 (只获取图像类型的模板) const { templates, createTemplate } = useQuestionTemplates(projectId, 'image'); // 问题编辑 Hook const { editDialogOpen, editMode, editingQuestion, handleOpenCreateDialog, handleCloseDialog, handleSubmitQuestion } = useQuestionEdit(projectId, async () => { fetchImages(); if (annotationOpen && currentImage) { await refreshCurrentImage(); } toast.success(t('questions.operationSuccess')); }); // 模板管理状态 const [templateDialogOpen, setTemplateDialogOpen] = useState(false); // 获取图片列表 const fetchImages = async () => { try { setLoading(true); const params = new URLSearchParams({ page: page.toString(), pageSize: pageSize.toString() }); if (imageName) params.append('imageName', imageName); if (hasQuestions !== 'all') params.append('hasQuestions', hasQuestions); if (hasDatasets !== 'all') params.append('hasDatasets', hasDatasets); const response = await axios.get(`/api/projects/${projectId}/images?${params.toString()}`); setImages(response.data.data); setTotal(response.data.total); } catch (error) { console.error('Failed to fetch images:', error); toast.error(t('common.fetchError')); } finally { setLoading(false); } }; // 查找下一个有未标注问题的图片 const handleFindNextImage = async () => { try { const response = await axios.get(`/api/projects/${projectId}/images/next-unanswered`); return response.data.data || null; } catch (error) { console.error('查找下一个图片失败:', error); return null; } }; const { open: annotationOpen, saving: annotationSaving, loading: annotationLoading, currentImage, selectedTemplate, answer, setAnswer, handleTemplateChange, openAnnotation, closeAnnotation, saveAnnotation, refreshCurrentImage } = useAnnotation(projectId, fetchImages, handleFindNextImage); useEffect(() => { fetchImages(); }, [projectId, page, imageName, hasQuestions, hasDatasets]); useEffect(() => { setSelectedIds([]); }, [viewMode]); // 处理导入成功 const handleImportSuccess = () => { setImportDialogOpen(false); setPage(1); fetchImages(); }; // 处理生成问题 const handleGenerateQuestions = image => { setSelectedImage(image); setQuestionDialogOpen(true); }; // 处理生成数据集 const handleGenerateDataset = image => { setSelectedImage(image); setDatasetDialogOpen(true); }; // 删除图片 const handleDeleteImage = async imageId => { if (!confirm(t('images.deleteConfirm', { defaultValue: '确定要删除这张图片吗?' }))) { return; } try { await axios.delete(`/api/projects/${projectId}/images?imageId=${imageId}`); toast.success(t('images.deleteSuccess', { defaultValue: '删除成功' })); fetchImages(); } catch (error) { console.error('Failed to delete image:', error); toast.error(t('images.deleteFailed', { defaultValue: '删除失败' })); } }; // 批量删除图片 const handleBatchDelete = async () => { if (selectedIds.length === 0) { toast.error(t('images.selectImagesToDelete', { defaultValue: '请选择要删除的图片' })); return; } if ( !confirm( t('images.batchDeleteConfirm', { defaultValue: `确定要删除选中的 ${selectedIds.length} 张图片吗?`, count: selectedIds.length }) ) ) { return; } try { setLoading(true); let successCount = 0; let failCount = 0; // 逐个调用删除接口 for (const imageId of selectedIds) { try { await axios.delete(`/api/projects/${projectId}/images?imageId=${imageId}`); successCount++; } catch (error) { console.error(`Failed to delete image ${imageId}:`, error); failCount++; } } // 显示结果 if (failCount === 0) { toast.success( t('images.batchDeleteSuccess', { defaultValue: `成功删除 ${successCount} 张图片`, count: successCount }) ); } else { toast.warning( t('images.batchDeletePartialSuccess', { defaultValue: `成功删除 ${successCount} 张,失败 ${failCount} 张`, success: successCount, fail: failCount }) ); } // 清空选中状态并刷新列表 setSelectedIds([]); fetchImages(); } catch (error) { console.error('Batch delete failed:', error); toast.error(t('images.batchDeleteFailed', { defaultValue: '批量删除失败' })); } finally { setLoading(false); } }; // 处理自动提取问题 const handleAutoGenerateQuestions = () => { if (!selectedModel) { toast.error(t('images.selectModelFirst')); return; } if (selectedModel.type !== 'vision') { toast.error(t('images.visionModelRequired')); return; } setAutoGenerateDialogOpen(true); }; // 确认创建自动提取任务 const handleConfirmAutoGenerate = async () => { // 验证问题数量 if (questionCount < 1 || questionCount > 10) { toast.error(t('images.countRange')); return; } try { setAutoGenerateDialogOpen(false); const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'image-question-generation', modelInfo: selectedModel, language: i18n.language, note: { questionCount } }); if (response.data.code === 0) { toast.success(t('images.taskCreated')); // 跳转到任务管理页面 router.push(`/projects/${projectId}/tasks`); } else { toast.error(response.data.error || t('images.taskCreateFailed')); } } catch (error) { console.error('Failed to create auto-generate task:', error); toast.error(t('images.taskCreateFailed')); } }; // 模板管理函数 const handleOpenCreateTemplateDialog = () => { setTemplateDialogOpen(true); }; const handleCloseTemplateDialog = () => { setTemplateDialogOpen(false); }; const handleSubmitTemplate = async data => { try { await createTemplate(data); handleCloseTemplateDialog(); fetchImages(); if (annotationOpen && currentImage) { await refreshCurrentImage(); } toast.success(t('questions.operationSuccess')); } catch (error) { console.error('Failed to save template:', error); } }; return ( {/* 页面头部 */} {t('images.title', { defaultValue: '图片管理' })} {viewMode === 'list' && selectedIds.length > 0 && ( )} {/* 筛选区域 */} {/* 图片列表 */} {loading ? ( ) : viewMode === 'grid' ? ( ) : ( )} setImportDialogOpen(false)} onSuccess={handleImportSuccess} /> setQuestionDialogOpen(false)} onSuccess={fetchImages} /> setDatasetDialogOpen(false)} onSuccess={fetchImages} /> setAutoGenerateDialogOpen(false)} maxWidth="sm" fullWidth> {t('images.autoGenerateQuestions')} {t('images.autoGenerateConfirm')} setQuestionCount(parseInt(e.target.value) || 1)} inputProps={{ min: 1, max: 10 }} helperText={t('images.questionCountHelp')} sx={{ mb: 2 }} /> {t('images.currentModel')}: {selectedModel?.modelName || t('common.none')} saveAnnotation(false)} onSaveAndContinue={() => saveAnnotation(true)} saving={annotationSaving} loading={annotationLoading} onOpenCreateQuestion={handleOpenCreateDialog} onOpenCreateTemplate={handleOpenCreateTemplateDialog} /> {/* 问题编辑对话框 */} {/* 问题模板对话框 */} ); } ================================================ FILE: app/projects/[projectId]/images/styles/imageStyles.js ================================================ /** * 图片管理页面样式配置 */ export const imageStyles = { // 页面容器 pageContainer: { py: 4 }, // 页面头部 header: { mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 3 }, headerTitle: { display: 'flex', flexDirection: 'column', gap: 0.5 }, title: { fontWeight: 700 }, subtitle: { color: 'text.secondary', fontSize: '0.875rem' }, headerActions: { display: 'flex', gap: 2, flexWrap: 'wrap' }, actionButton: { borderRadius: 2, textTransform: 'none', px: 3, fontWeight: 600, boxShadow: 2, transition: 'all 0.3s ease', '&:hover': { transform: 'translateY(-2px)', boxShadow: 4 } }, // 筛选区域 filterCard: { mb: 3, borderRadius: 2, boxShadow: 1, border: '1px solid', borderColor: 'divider', overflow: 'visible' }, filterContent: { display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }, searchField: { minWidth: { xs: '100%', sm: 300 }, flex: { xs: '1 1 100%', sm: '1 1 auto' }, '& .MuiOutlinedInput-root': { borderRadius: 2 } }, filterSelect: { minWidth: { xs: '48%', sm: 150 }, '& .MuiOutlinedInput-root': { borderRadius: 2 } }, viewToggle: { ml: 'auto', borderRadius: 2, '& .MuiToggleButton-root': { border: '1px solid', borderColor: 'divider', '&.Mui-selected': { bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' } } } }, // 图片网格 gridContainer: { spacing: 3 }, // 图片卡片 imageCard: { height: '100%', display: 'flex', flexDirection: 'column', borderRadius: 3, overflow: 'hidden', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', border: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', '&:hover': { transform: 'translateY(-8px)', boxShadow: theme => `0 12px 24px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.15)'}`, borderColor: 'primary.main', '& .image-overlay': { opacity: 1 } } }, imageWrapper: { position: 'relative', overflow: 'hidden', bgcolor: 'grey.100' }, imageMedia: { height: 220, objectFit: 'cover', transition: 'transform 0.3s ease', cursor: 'pointer', '&:hover': { transform: 'scale(1.05)' } }, imageOverlay: { className: 'image-overlay', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.6) 100%)', opacity: 0, transition: 'opacity 0.3s ease', pointerEvents: 'none' }, statusChipsContainer: { position: 'absolute', top: 12, right: 12, display: 'flex', gap: 0.5, flexDirection: 'column', alignItems: 'flex-end', zIndex: 2 }, statusChip: { backdropFilter: 'blur(10px)', fontWeight: 600, fontSize: '0.75rem', height: 24, boxShadow: 2 }, imageNameContainer: { position: 'absolute', bottom: 12, left: 12, right: 12, display: 'flex', justifyContent: 'center', zIndex: 2 }, imageNameChip: { backdropFilter: 'blur(10px)', bgcolor: 'rgba(255, 255, 255, 0.95)', fontWeight: 600, maxWidth: '90%', boxShadow: 2, '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, cardContent: { flexGrow: 1, p: 2, pb: 1.5 }, imageName: { fontWeight: 600, fontSize: '0.9rem', lineHeight: 1.4 }, cardActions: { p: 2, pt: 0, gap: 1, mt: 2, display: 'flex', justifyContent: 'space-between' }, actionIconButton: { transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.1)' } }, primaryActionButton: { borderRadius: 2, textTransform: 'none', fontWeight: 600, flex: 1 }, // 分页 pagination: { display: 'flex', justifyContent: 'center', mt: 4 }, // 空状态 emptyState: { textAlign: 'center', py: 12, px: 3 }, emptyIcon: { width: 120, height: 120, borderRadius: '50%', bgcolor: 'primary.lighter', display: 'flex', alignItems: 'center', justifyContent: 'center', mx: 'auto', mb: 3 }, emptyTitle: { fontWeight: 600, mb: 1 }, emptyDescription: { color: 'text.secondary', mb: 4 }, emptyButton: { borderRadius: 2, px: 4, textTransform: 'none', fontWeight: 600 }, // 加载状态 loadingContainer: { display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 } }; ================================================ FILE: app/projects/[projectId]/layout.js ================================================ 'use client'; import Navbar from '@/components/Navbar/index'; import { useState, useEffect } from 'react'; import { Box, CircularProgress, Typography, Button } from '@mui/material'; import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { useSetAtom } from 'jotai'; import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store'; export default function ProjectLayout({ children, params }) { const router = useRouter(); const { projectId } = params; const [projects, setProjects] = useState([]); const [currentProject, setCurrentProject] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [t] = useTranslation(); const setModelConfigList = useSetAtom(modelConfigListAtom); const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom); const fetchData = async () => { try { setLoading(true); const [projectsResponse, projectResponse, modelConfigResponse] = await Promise.all([ fetch('/api/projects'), fetch(`/api/projects/${projectId}`), fetch(`/api/projects/${projectId}/model-config`) ]); if (!projectsResponse.ok) { throw new Error(t('projects.fetchFailed')); } const projectsData = await projectsResponse.json(); setProjects(projectsData); if (!projectResponse.ok) { if (projectResponse.status === 404) { router.push('/'); return; } throw new Error('Failed to load project details'); } const projectData = await projectResponse.json(); setCurrentProject(projectData); if (modelConfigResponse.ok) { const modelConfigData = await modelConfigResponse.json(); const modelList = Array.isArray(modelConfigData?.data) ? modelConfigData.data : []; setModelConfigList(modelList); if (modelConfigData?.defaultModelConfigId) { const defaultModel = modelList.find(item => item.id === modelConfigData.defaultModelConfigId); setSelectedModelInfo(defaultModel || null); } else { setSelectedModelInfo(null); } } else { setModelConfigList([]); setSelectedModelInfo(null); } } catch (error) { console.error('Failed to load project data:', error); setError(error.message); } finally { setLoading(false); } }; useEffect(() => { if (!projectId || projectId === 'undefined') { router.push('/'); return; } fetchData(); }, [projectId, router]); if (loading) { return ( Loading project data... ); } if (error) { return ( {t('projects.fetchFailed')}: {error} ); } return ( <> {children} ); } ================================================ FILE: app/projects/[projectId]/multi-turn/[conversationId]/page.js ================================================ 'use client'; import { Container, Box, Typography, Alert, Dialog, DialogTitle, DialogContent, DialogActions, Button, Paper } from '@mui/material'; import ConversationHeader from '@/components/conversations/ConversationHeader'; import ConversationMetadata from '@/components/conversations/ConversationMetadata'; import ConversationContent from '@/components/conversations/ConversationContent'; import ConversationRatingSection from '@/components/conversations/ConversationRatingSection'; import useConversationDetails from './useConversationDetails'; import { useTranslation } from 'react-i18next'; /** * 多轮对话详情页面 */ export default function ConversationDetailPage({ params }) { const { projectId, conversationId } = params; const { t } = useTranslation(); // 使用自定义Hook管理状态和逻辑 const { conversation, messages, loading, editMode, saving, editData, setEditData, deleteDialogOpen, setDeleteDialogOpen, handleEdit, handleSave, handleCancel, handleDelete, handleNavigate, updateMessageContent } = useConversationDetails(projectId, conversationId); // 加载状态 if (loading) { return ( {t('datasets.loadingDataset')} ); } // 无数据状态 if (!conversation) { return ( {t('datasets.conversationNotFound')} ); } return ( {/* 顶部导航栏 */} setDeleteDialogOpen(true)} onNavigate={handleNavigate} /> {/* 主要布局:左右分栏 */} {/* 左侧主要内容区域 */} {/* 对话内容 */} {/* 右侧固定侧边栏 */} {/* 元数据展示 */} {/* 评分、标签、备注区域 */} { // 更新成功后刷新数据,保持页面状态同步 // 这里可以调用 useConversationDetails 的刷新逻辑 }} /> {/* 删除确认对话框 */} setDeleteDialogOpen(false)}> {t('datasets.confirmDelete')} {t('datasets.confirmDeleteConversation')} ); } ================================================ FILE: app/projects/[projectId]/multi-turn/[conversationId]/useConversationDetails.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; /** * 多轮对话详情页面的状态管理Hook */ export default function useConversationDetails(projectId, conversationId) { const { t } = useTranslation(); const router = useRouter(); // 基础状态 const [conversation, setConversation] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); // 编辑状态 const [editMode, setEditMode] = useState(false); const [saving, setSaving] = useState(false); const [editData, setEditData] = useState({ score: 0, tags: '', note: '', confirmed: false, messages: [] }); // 对话框状态 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); // 获取对话详情 const fetchConversation = async () => { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`); if (!response.ok) { if (response.status === 404) { toast.error(t('datasets.conversationNotFound')); router.push(`/projects/${projectId}/multi-turn`); return; } throw new Error(t('datasets.fetchDataFailed')); } const data = await response.json(); setConversation(data); // 解析对话消息 let parsedMessages = []; try { parsedMessages = JSON.parse(data.rawMessages || '[]'); setMessages(parsedMessages); } catch (error) { console.error('解析对话消息失败:', error); setMessages([]); } // 设置编辑数据 setEditData({ score: data.score || 0, tags: data.tags || '', note: data.note || '', confirmed: data.confirmed || false, messages: parsedMessages }); } catch (error) { console.error('获取对话详情失败:', error); toast.error(error.message || t('datasets.fetchDataFailed')); } finally { setLoading(false); } }; // 保存编辑 const handleSave = async () => { try { setSaving(true); const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ score: editData.score, tags: editData.tags, note: editData.note, confirmed: editData.confirmed, messages: editData.messages }) }); if (!response.ok) { throw new Error(t('datasets.saveFailed')); } // 更新本地状态 setConversation({ ...conversation, ...editData }); setMessages(editData.messages); setEditMode(false); toast.success(t('datasets.saveSuccess')); } catch (error) { console.error('保存失败:', error); toast.error(error.message || t('datasets.saveFailed')); } finally { setSaving(false); } }; // 开始编辑 const handleEdit = () => { setEditMode(true); }; // 取消编辑 const handleCancel = () => { // 恢复到原始数据 setEditData({ score: conversation.score || 0, tags: conversation.tags || '', note: conversation.note || '', confirmed: conversation.confirmed || false, messages: messages }); setEditMode(false); }; // 删除对话 const handleDelete = async () => { try { const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(t('datasets.deleteFailed')); } toast.success(t('datasets.deleteSuccess')); router.push(`/projects/${projectId}/multi-turn`); } catch (error) { console.error('删除失败:', error); toast.error(error.message || t('datasets.deleteFailed')); } }; // 更新消息内容 const updateMessageContent = (index, newContent) => { const updatedMessages = [...editData.messages]; updatedMessages[index] = { ...updatedMessages[index], content: newContent }; setEditData({ ...editData, messages: updatedMessages }); }; // 翻页导航 const handleNavigate = async direction => { try { const response = await fetch( `/api/projects/${projectId}/dataset-conversations/${conversationId}?operateType=${direction}` ); if (!response.ok) { throw new Error('获取导航数据失败'); } const data = await response.json(); if (data) { router.push(`/projects/${projectId}/multi-turn/${data.id}`); } else { toast.warning(`已经是${direction === 'next' ? '最后' : '第'}一条对话了`); } } catch (error) { console.error('导航失败:', error); toast.error(error.message || '导航失败'); } }; // 初始化 useEffect(() => { fetchConversation(); }, [projectId, conversationId]); return { // 数据状态 conversation, messages, loading, // 编辑状态 editMode, saving, editData, setEditData, // 对话框状态 deleteDialogOpen, setDeleteDialogOpen, // 操作方法 handleEdit, handleSave, handleCancel, handleDelete, handleNavigate, updateMessageContent, fetchConversation }; } ================================================ FILE: app/projects/[projectId]/multi-turn/components/ConversationTable.js ================================================ 'use client'; import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, IconButton, Tooltip, Typography, Chip, CircularProgress, Checkbox } from '@mui/material'; import { Delete as DeleteIcon, Visibility as ViewIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import RatingChip from './RatingChip'; const QUESTION_TOOLTIP_THRESHOLD = 80; const SCENARIO_TOOLTIP_THRESHOLD = 120; const ConversationTable = ({ conversations, loading, total, page, rowsPerPage, onPageChange, onRowsPerPageChange, onView, onDelete, selectedIds = [], onSelectionChange, isAllSelected = false, onSelectAll }) => { const { t } = useTranslation(); const [expandedRows, setExpandedRows] = useState({}); const columnWidths = { checkbox: 52, question: 280, scenario: 340, rounds: 90, model: 120, rating: 100, createdAt: 110, actions: 92 }; const shouldShowTooltip = (value, threshold) => (value || '').length > threshold; const handleSelectOne = conversationId => { if (selectedIds.includes(conversationId)) { onSelectionChange(selectedIds.filter(id => id !== conversationId)); } else { onSelectionChange([...selectedIds, conversationId]); } }; const handleSelectAll = () => { if (isAllSelected) { onSelectionChange([]); onSelectAll(false); } else { const currentPageIds = conversations.map(conv => conv.id); onSelectionChange(currentPageIds); onSelectAll(true); } }; const isIndeterminate = selectedIds.length > 0 && !isAllSelected; const toggleRowExpanded = conversationId => { setExpandedRows(prev => ({ ...prev, [conversationId]: !prev[conversationId] })); }; return ( {t('datasets.firstQuestion')} {t('datasets.conversationScenario')} {t('datasets.conversationRounds')} {t('datasets.modelUsed')} {t('datasets.rating')} {t('datasets.createTime')} {t('common.actions')} {loading ? ( ) : conversations.length === 0 ? ( {t('datasets.noConversations')} ) : ( conversations.map(conversation => { const questionText = conversation.question || ''; const scenarioText = conversation.scenario || ''; const isExpanded = Boolean(expandedRows[conversation.id]); const canToggleExpand = questionText.length > QUESTION_TOOLTIP_THRESHOLD || scenarioText.length > SCENARIO_TOOLTIP_THRESHOLD; const questionContent = ( {questionText} ); const scenarioContent = ( {scenarioText || t('datasets.notSet')} ); return ( handleSelectOne(conversation.id)} /> {shouldShowTooltip(questionText, QUESTION_TOOLTIP_THRESHOLD) ? ( {questionContent} ) : ( questionContent )} {conversation.confirmed && ( )} {canToggleExpand && ( toggleRowExpanded(conversation.id)} > {isExpanded ? t('common.collapse') : t('common.expand')} )} {shouldShowTooltip(scenarioText, SCENARIO_TOOLTIP_THRESHOLD) ? ( {scenarioContent} ) : ( scenarioContent )} {conversation.turnCount}/{conversation.maxTurns} {new Date(conversation.createAt).toLocaleDateString()} onView(conversation.id)}> onDelete(conversation.id)}> ); }) )}
onPageChange(newPage)} rowsPerPage={rowsPerPage} rowsPerPageOptions={[20, 50, 100]} onRowsPerPageChange={event => { onRowsPerPageChange(parseInt(event.target.value, 10)); }} labelRowsPerPage={t('datasets.rowsPerPage')} />
); }; export default ConversationTable; ================================================ FILE: app/projects/[projectId]/multi-turn/components/FilterDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import { useTranslation } from 'react-i18next'; /** * 筛选对话框组件 * @param {boolean} open - 对话框开启状态 * @param {function} onClose - 关闭回调 * @param {object} filters - 筛选条件 * @param {function} onFiltersChange - 筛选条件变化回调 * @param {function} onReset - 重置回调 * @param {function} onApply - 应用回调 */ const FilterDialog = ({ open, onClose, filters, onFiltersChange, onReset, onApply }) => { const { t } = useTranslation(); const handleFilterChange = (field, value) => { onFiltersChange({ ...filters, [field]: value }); }; return ( {t('datasets.filtersTitle')} handleFilterChange('roleA', e.target.value)} fullWidth /> handleFilterChange('roleB', e.target.value)} fullWidth /> handleFilterChange('scenario', e.target.value)} fullWidth /> handleFilterChange('scoreMin', e.target.value)} fullWidth /> handleFilterChange('scoreMax', e.target.value)} fullWidth /> {t('datasets.filterConfirmationStatus')} ); }; export default FilterDialog; ================================================ FILE: app/projects/[projectId]/multi-turn/components/RatingChip.js ================================================ 'use client'; import { Chip } from '@mui/material'; import { Star as StarIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import { getRatingConfigI18n, formatScore } from '@/components/datasets/utils/ratingUtils'; /** * 评分展示组件 * @param {number} score - 评分值 */ const RatingChip = ({ score }) => { const { t } = useTranslation(); const config = getRatingConfigI18n(score, t); return ( } label={`${formatScore(score)} ${config.label}`} size="small" sx={{ backgroundColor: config.backgroundColor, color: config.color, fontWeight: 'medium', '& .MuiChip-icon': { color: config.color } }} /> ); }; export default RatingChip; ================================================ FILE: app/projects/[projectId]/multi-turn/components/SearchBar.js ================================================ 'use client'; import { Box, Paper, Button, IconButton, InputBase, CircularProgress } from '@mui/material'; import { Search as SearchIcon, FilterList as FilterIcon, Download as DownloadIcon, Delete as DeleteIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; /** * 搜索栏组件 * @param {string} searchKeyword - 搜索关键词 * @param {function} onSearchChange - 搜索关键词变化回调 * @param {function} onSearch - 搜索回调 * @param {function} onFilterClick - 筛选按钮点击回调 * @param {function} onExportClick - 导出按钮点击回调 * @param {boolean} exportLoading - 导出加载状态 * @param {number} selectedCount - 选中的项目数量 * @param {function} onBatchDelete - 批量删除回调 * @param {boolean} batchDeleteLoading - 批量删除加载状态 */ const SearchBar = ({ searchKeyword, onSearchChange, onSearch, onFilterClick, onExportClick, exportLoading = false, selectedCount = 0, onBatchDelete, batchDeleteLoading = false }) => { const { t } = useTranslation(); return ( onSearchChange(e.target.value)} onKeyPress={e => e.key === 'Enter' && onSearch()} /> {selectedCount > 0 && ( )} ); }; export default SearchBar; ================================================ FILE: app/projects/[projectId]/multi-turn/hooks/useMultiTurnData.js ================================================ 'use client'; import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; /** * Multi-turn dataset data hook * @param {string} projectId */ export const useMultiTurnData = projectId => { const { t } = useTranslation(); const router = useRouter(); const [conversations, setConversations] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(20); const [total, setTotal] = useState(0); const [searchKeyword, setSearchKeyword] = useState(''); const [filterDialogOpen, setFilterDialogOpen] = useState(false); const [exportLoading, setExportLoading] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [isAllSelected, setIsAllSelected] = useState(false); const [batchDeleteLoading, setBatchDeleteLoading] = useState(false); const [filters, setFilters] = useState({ roleA: '', roleB: '', scenario: '', scoreMin: '', scoreMax: '', confirmed: '' }); const abortRef = useRef(null); const buildQuery = ({ pageIndex, keyword, filterValues }) => { const params = new URLSearchParams({ page: String(pageIndex + 1), pageSize: String(rowsPerPage) }); if (keyword) params.append('keyword', keyword); if (filterValues.roleA) params.append('roleA', filterValues.roleA); if (filterValues.roleB) params.append('roleB', filterValues.roleB); if (filterValues.scenario) params.append('scenario', filterValues.scenario); if (filterValues.scoreMin) params.append('scoreMin', filterValues.scoreMin); if (filterValues.scoreMax) params.append('scoreMax', filterValues.scoreMax); if (filterValues.confirmed) params.append('confirmed', filterValues.confirmed); return params; }; const fetchConversations = async (newPage = page, options = {}) => { const keyword = options.keyword ?? searchKeyword; const filterValues = options.filterValues ?? filters; const showLoading = options.showLoading ?? true; try { if (abortRef.current) { abortRef.current.abort(); } const controller = new AbortController(); abortRef.current = controller; if (showLoading) { setLoading(true); } const params = buildQuery({ pageIndex: newPage, keyword, filterValues }); const response = await fetch(`/api/projects/${projectId}/dataset-conversations?${params.toString()}`, { signal: controller.signal }); if (!response.ok) { throw new Error(t('datasets.fetchDataFailed')); } const data = await response.json(); setConversations(data.data || []); setTotal(data.total || 0); } catch (error) { if (error?.name === 'AbortError') return; console.error('Failed to fetch multi-turn dataset list:', error); toast.error(error.message || t('datasets.fetchDataFailed')); } finally { if (showLoading) { setLoading(false); } if (abortRef.current === controller) { abortRef.current = null; } } }; const handleExport = async () => { try { setExportLoading(true); const response = await fetch(`/api/projects/${projectId}/dataset-conversations/export`); if (!response.ok) { throw new Error(t('datasets.exportFailed')); } const data = await response.json(); const dataStr = JSON.stringify(data, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `multi-turn-conversations-${projectId}-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); toast.success(t('datasets.exportSuccess')); } catch (error) { console.error('Export failed:', error); toast.error(error.message || t('datasets.exportFailed')); } finally { setExportLoading(false); } }; const fetchAllConversationIds = async () => { try { const params = new URLSearchParams({ getAllIds: 'true' }); if (searchKeyword) params.append('keyword', searchKeyword); if (filters.roleA) params.append('roleA', filters.roleA); if (filters.roleB) params.append('roleB', filters.roleB); if (filters.scenario) params.append('scenario', filters.scenario); if (filters.scoreMin) params.append('scoreMin', filters.scoreMin); if (filters.scoreMax) params.append('scoreMax', filters.scoreMax); if (filters.confirmed) params.append('confirmed', filters.confirmed); const response = await fetch(`/api/projects/${projectId}/dataset-conversations?${params.toString()}`); if (!response.ok) { throw new Error(t('datasets.fetchDataFailed')); } const data = await response.json(); return data.allConversationIds || []; } catch (error) { console.error('Failed to fetch all conversation IDs:', error); toast.error(error.message || t('datasets.fetchDataFailed')); return []; } }; const handleDelete = async conversationId => { if (!confirm(t('datasets.confirmDeleteConversation'))) { return; } try { const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversationId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(t('datasets.deleteFailed')); } toast.success(t('datasets.deleteSuccess')); fetchConversations(); } catch (error) { console.error('Delete failed:', error); toast.error(error.message || t('datasets.deleteFailed')); } }; const deleteConversationsConcurrently = async (conversationIds, concurrency = 10) => { const results = []; const errors = []; for (let i = 0; i < conversationIds.length; i += concurrency) { const batch = conversationIds.slice(i, i + concurrency); const promises = batch.map(async id => { try { const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${id}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`Delete conversation ${id} failed`); } return { id, success: true }; } catch (error) { errors.push({ id, error: error.message }); return { id, success: false, error: error.message }; } }); const batchResults = await Promise.all(promises); results.push(...batchResults); } return { results, errors }; }; const handleBatchDelete = async () => { let idsToDelete = selectedIds; if (isAllSelected) { idsToDelete = await fetchAllConversationIds(); if (idsToDelete.length === 0) { toast.error(t('datasets.noDataToDelete')); return; } } if (idsToDelete.length === 0) { toast.error(t('datasets.pleaseSelectData')); return; } if (!confirm(t('common.confirmDelete', { count: idsToDelete.length }))) { return; } try { setBatchDeleteLoading(true); const { results, errors } = await deleteConversationsConcurrently(idsToDelete); const successCount = results.filter(r => r.success).length; const failCount = errors.length; if (failCount === 0) { toast.success(t('common.deleteSuccess', { count: successCount })); } else { toast.warning(t('datasets.batchDeletePartialSuccess', { success: successCount, fail: failCount })); } setSelectedIds([]); setIsAllSelected(false); fetchConversations(); } catch (error) { console.error('Batch delete failed:', error); toast.error(error.message || t('datasets.batchDeleteFailed')); } finally { setBatchDeleteLoading(false); } }; const handleSelectionChange = newSelectedIds => { setSelectedIds(newSelectedIds); if (newSelectedIds.length === 0) { setIsAllSelected(false); } }; const handleSelectAll = selectAll => { setIsAllSelected(selectAll); if (!selectAll) { setSelectedIds([]); } }; const handleView = conversationId => { router.push(`/projects/${projectId}/multi-turn/${conversationId}`); }; const applyFilters = () => { setPage(0); setFilterDialogOpen(false); fetchConversations(0, { keyword: searchKeyword, filterValues: filters }); }; const resetFilters = () => { const clearedFilters = { roleA: '', roleB: '', scenario: '', scoreMin: '', scoreMax: '', confirmed: '' }; setFilters(clearedFilters); setSearchKeyword(''); setPage(0); fetchConversations(0, { keyword: '', filterValues: clearedFilters }); }; const handleSearch = () => { setPage(0); fetchConversations(0, { keyword: searchKeyword, filterValues: filters }); }; const handlePageChange = newPage => { setPage(newPage); }; const handleRowsPerPageChange = newRowsPerPage => { setRowsPerPage(newRowsPerPage); setPage(0); }; useEffect(() => { fetchConversations(page, { showLoading: true }); }, [projectId, page, rowsPerPage]); useEffect(() => { return () => { if (abortRef.current) { abortRef.current.abort(); } }; }, []); return { conversations, loading, page, rowsPerPage, total, searchKeyword, filterDialogOpen, exportLoading, filters, selectedIds, isAllSelected, batchDeleteLoading, setSearchKeyword, setFilterDialogOpen, setFilters, fetchConversations, handleExport, handleDelete, handleView, applyFilters, resetFilters, handleSearch, handlePageChange, handleRowsPerPageChange, handleBatchDelete, handleSelectionChange, handleSelectAll }; }; ================================================ FILE: app/projects/[projectId]/multi-turn/page.js ================================================ 'use client'; import { Container, Typography, Box, Card, useTheme, alpha } from '@mui/material'; import { Chat as ChatIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; // 导入拆分后的组件 import SearchBar from './components/SearchBar'; import ConversationTable from './components/ConversationTable'; import FilterDialog from './components/FilterDialog'; import { useMultiTurnData } from './hooks/useMultiTurnData'; export default function MultiTurnDatasetPage({ params }) { const { t } = useTranslation(); const theme = useTheme(); const { projectId } = params; // 使用自定义Hook管理状态和逻辑 const { conversations, loading, page, rowsPerPage, total, searchKeyword, filterDialogOpen, exportLoading, filters, selectedIds, isAllSelected, batchDeleteLoading, setSearchKeyword, setFilterDialogOpen, setFilters, fetchConversations, handleExport, handleDelete, handleView, applyFilters, resetFilters, handleSearch, handlePageChange, handleRowsPerPageChange, handleBatchDelete, handleSelectionChange, handleSelectAll } = useMultiTurnData(projectId); return ( {/* {t('datasets.multiTurnConversations')} */} setFilterDialogOpen(true)} onExportClick={handleExport} exportLoading={exportLoading} selectedCount={isAllSelected ? total : selectedIds.length} onBatchDelete={handleBatchDelete} batchDeleteLoading={batchDeleteLoading} /> setFilterDialogOpen(false)} filters={filters} onFiltersChange={setFilters} onReset={resetFilters} onApply={applyFilters} /> ); } ================================================ FILE: app/projects/[projectId]/page.js ================================================ 'use client'; import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import axios from 'axios'; import { toast } from 'sonner'; import { useSetAtom } from 'jotai/index'; import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store'; export default function ProjectPage({ params }) { const router = useRouter(); const setConfigList = useSetAtom(modelConfigListAtom); const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom); const { projectId } = params; // 默认重定向到文本分割页面 useEffect(() => { getModelConfigList(projectId); router.push(`/projects/${projectId}/text-split`); }, [projectId, router]); const getModelConfigList = projectId => { axios .get(`/api/projects/${projectId}/model-config`) .then(response => { setConfigList(response.data.data); if (response.data.defaultModelConfigId) { setSelectedModelInfo(response.data.data.find(item => item.id === response.data.defaultModelConfigId)); } else { setSelectedModelInfo(null); } }) .catch(error => { toast.error('get model list error'); }); }; return null; } ================================================ FILE: app/projects/[projectId]/playground/page.js ================================================ 'use client'; import React from 'react'; import { Box, Typography, Paper, Alert } from '@mui/material'; import { useParams } from 'next/navigation'; import { useTheme } from '@mui/material/styles'; import ChatArea from '@/components/playground/ChatArea'; import MessageInput from '@/components/playground/MessageInput'; import PlaygroundHeader from '@/components/playground/PlaygroundHeader'; import useModelPlayground from '@/hooks/useModelPlayground'; import { playgroundStyles } from '@/styles/playground'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai/index'; import { modelConfigListAtom } from '@/lib/store'; export default function ModelPlayground({ searchParams }) { const theme = useTheme(); const params = useParams(); const { projectId } = params; const modelId = searchParams?.modelId || null; const styles = playgroundStyles(theme); const { t } = useTranslation(); const { selectedModels, loading, userInput, conversations, error, outputMode, uploadedImage, handleModelSelection, handleInputChange, handleImageUpload, handleRemoveImage, handleSendMessage, handleClearConversations, handleOutputModeChange } = useModelPlayground(projectId, modelId); const availableModels = useAtomValue(modelConfigListAtom); // 获取模型名称 const getModelName = modelId => { const model = availableModels.find(m => m.id === modelId); return model ? `${model.providerName}: ${model.modelName}` : modelId; }; return ( {t('playground.title')} {error && ( {error} )} ); } ================================================ FILE: app/projects/[projectId]/questions/components/ConfirmDialog.js ================================================ 'use client'; import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; /** * 确认对话框组件 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调函数 * @param {Function} props.onConfirm - 确认操作的回调函数 * @param {string} props.title - 对话框标题 * @param {string} props.content - 对话框内容 * @param {string} props.confirmText - 确认按钮文本,默认为 "确认删除" * @param {string} props.cancelText - 取消按钮文本,默认为 "取消" * @param {string} props.confirmColor - 确认按钮颜色,默认为 "error" */ export default function ConfirmDialog({ open, onClose, onConfirm, title, content, confirmText, cancelText, confirmColor = 'error' }) { const { t } = useTranslation(); const handleConfirm = () => { onClose(); if (onConfirm) { onConfirm(); } }; return ( {title} {content} ); } ================================================ FILE: app/projects/[projectId]/questions/components/ExportQuestionsDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, Box, Divider } from '@mui/material'; import { useTranslation } from 'react-i18next'; import DownloadIcon from '@mui/icons-material/Download'; export default function ExportQuestionsDialog({ open, onClose, onExport, selectedCount, totalCount }) { const { t } = useTranslation(); const [format, setFormat] = useState('json'); const [exportScope, setExportScope] = useState('all'); const handleExport = () => { const exportOptions = { format, selectedIds: exportScope === 'selected' ? [] : undefined }; onExport(exportOptions); onClose(); }; return ( {t('questions.exportQuestions')} {/* 导出范围 */} {t('questions.exportScope')} setExportScope(e.target.value)}> } label={t('questions.exportAll', { count: totalCount })} /> {selectedCount > 0 && ( } label={t('questions.exportSelected', { count: selectedCount })} /> )} {/* 导出格式 */} {t('questions.exportFormat')} setFormat(e.target.value)}> } label="JSON" /> } label="JSONL" /> } label={t('questions.txtFormat')} /> } label="CSV" /> ); } ================================================ FILE: app/projects/[projectId]/questions/components/QuestionEditDialog.js ================================================ 'use client'; import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Box, Autocomplete, TextField as MuiTextField, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import axios from 'axios'; export default function QuestionEditDialog({ open, onClose, onSubmit, initialData, projectId, tags, mode = 'create' // 'create' or 'edit' }) { const [chunks, setChunks] = useState([]); const [images, setImages] = useState([]); const { t } = useTranslation(); // 获取文本块的标题 const getChunkTitle = chunkId => { const chunk = chunks.find(c => c.id === chunkId); return chunk?.name || chunkId; // 直接使用文件名 }; const [formData, setFormData] = useState({ id: '', question: '', sourceType: 'text', // 新增:数据源类型 chunkId: '', imageId: '', // 新增:图片ID label: '' // 默认不选中任何标签 }); const getChunks = async projectId => { // 获取文本块列表 const response = await axios.get(`/api/projects/${projectId}/split`); if (response.status !== 200) { throw new Error(t('common.fetchError')); } setChunks(response.data.chunks || []); }; const getImages = async projectId => { // 获取图片列表(只获取ID和名称) try { const response = await axios.get(`/api/projects/${projectId}/images?page=1&pageSize=10000&simple=true`); if (response.status === 200) { setImages(response.data.data || []); } } catch (error) { console.error('Failed to fetch images:', error); } }; useEffect(() => { getChunks(projectId); getImages(projectId); if (initialData) { // 根据 imageId 判断数据源类型 console.log('initialData:', initialData); const sourceType = initialData.imageId ? 'image' : 'text'; setFormData({ id: initialData.id, question: initialData.question || '', sourceType: sourceType, chunkId: initialData.chunkId || '', imageId: initialData.imageId || '', label: initialData.label || 'other' // 改用 label 而不是 label }); } else { setFormData({ id: '', question: '', sourceType: 'text', chunkId: '', imageId: '', label: '' }); } }, [initialData]); const handleSubmit = () => { onSubmit(formData); onClose(); }; const flattenTags = (tags = [], prefix = '') => { let flatTags = []; const traverse = node => { flatTags.push({ id: node.label, // 使用标签名作为 id label: node.label, // 直接使用原始标签名 originalLabel: node.label }); if (node.child && node.child.length > 0) { node.child.forEach(child => traverse(child)); } }; tags.forEach(tag => traverse(tag)); flatTags.push({ id: 'other', label: t('datasets.uncategorized'), originalLabel: 'other' }); return flatTags; }; const flattenedTags = useMemo(() => flattenTags(tags), [tags, t]); return ( {mode === 'create' ? t('questions.createQuestion') : t('questions.editQuestion')} {/* 数据源类型选择 */} {t('questions.sourceType', { defaultValue: '数据源类型' })} {/* 问题内容 */} setFormData({ ...formData, question: e.target.value })} /> {/* 文本块选择(仅当数据源为文本时显示) */} {formData.sourceType === 'text' && ( getChunkTitle(chunk.id)} value={chunks.find(chunk => chunk.id === formData.chunkId) || null} onChange={(e, newValue) => setFormData({ ...formData, chunkId: newValue ? newValue.id : '' })} renderInput={params => ( )} /> )} {/* 图片选择(仅当数据源为图片时显示) */} {formData.sourceType === 'image' && ( image.imageName || ''} value={images.find(image => image.id === formData.imageId) || null} onChange={(e, newValue) => setFormData({ ...formData, imageId: newValue ? newValue.id : '' })} renderInput={params => ( )} /> )} {/* 标签选择 */} {formData.sourceType === 'text' && ( tag.label} value={flattenedTags.find(tag => tag.id === formData.label) || null} onChange={(e, newValue) => setFormData({ ...formData, label: newValue ? newValue.id : '' })} renderInput={params => ( )} /> )} ); } ================================================ FILE: app/projects/[projectId]/questions/components/QuestionsFilter.js ================================================ 'use client'; import { Box, Stack, Checkbox, Typography, TextField, InputAdornment, Select, MenuItem, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; import SearchIcon from '@mui/icons-material/Search'; export default function QuestionsFilter({ // 选择相关 selectedQuestionsCount, totalQuestions, isAllSelected, isIndeterminate, onSelectAll, // 搜索相关 searchTerm, onSearchChange, searchMatchMode, onSearchMatchModeChange, // 过滤相关 answerFilter, onFilterChange, // 文本块名称筛选 chunkNameFilter, onChunkNameFilterChange, // 数据源类型筛选 sourceTypeFilter, onSourceTypeFilterChange, activeTab }) { const { t } = useTranslation(); const theme = useTheme(); if (activeTab === 1) { return <>; } return ( {/* 选择区域 */} {selectedQuestionsCount > 0 ? t('questions.selectedCount', { count: selectedQuestionsCount }) : t('questions.selectAll')} ( {t('questions.totalCount', { count: totalQuestions })} ) {/* 搜索和过滤区域 */} {/* 组合搜索框:下拉选择(匹配/不匹配)+ 输入框 */} ) }} /> ) }} /> ); } ================================================ FILE: app/projects/[projectId]/questions/components/QuestionsPageHeader.js ================================================ 'use client'; import { useState } from 'react'; import { Box, Typography, Button, Tooltip, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material'; import { useTranslation } from 'react-i18next'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import DatasetIcon from '@mui/icons-material/Dataset'; import ChatIcon from '@mui/icons-material/Chat'; import ImageIcon from '@mui/icons-material/Image'; import LibraryAddIcon from '@mui/icons-material/LibraryAdd'; import DownloadIcon from '@mui/icons-material/Download'; export default function QuestionsPageHeader({ questionsTotal, selectedQuestionsCount, onBatchDeleteQuestions, onOpenCreateDialog, onOpenCreateTemplateDialog, onBatchGenerateAnswers, onAutoGenerateDatasets, onAutoGenerateMultiTurnDatasets, onAutoGenerateImageDatasets, onExportQuestions, activeTab }) { const { t } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); const [createAnchorEl, setCreateAnchorEl] = useState(null); const open = Boolean(anchorEl); const createMenuOpen = Boolean(createAnchorEl); const handleMenuClick = event => { setAnchorEl(event.currentTarget); }; const handleMenuClose = () => { setAnchorEl(null); }; const handleCreateMenuClick = event => { setCreateAnchorEl(event.currentTarget); }; const handleCreateMenuClose = () => { setCreateAnchorEl(null); }; const handleCreateQuestion = () => { handleCreateMenuClose(); onOpenCreateDialog(); }; const handleCreateTemplate = () => { handleCreateMenuClose(); onOpenCreateTemplateDialog(); }; const handleSingleTurnGenerate = () => { handleMenuClose(); onAutoGenerateDatasets(); }; const handleMultiTurnGenerate = () => { handleMenuClose(); onAutoGenerateMultiTurnDatasets(); }; const handleImageDatasetGenerate = () => { handleMenuClose(); onAutoGenerateImageDatasets(); }; return ( {t('questions.title')} ({questionsTotal}) {/* */} ); } ================================================ FILE: app/projects/[projectId]/questions/components/TemplateListView.js ================================================ 'use client'; import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Chip, Typography, Alert } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import { useTranslation } from 'react-i18next'; export default function TemplateListView({ templates, onEditTemplate, onDeleteTemplate, loading }) { const { t } = useTranslation(); const getAnswerTypeLabel = type => { const labels = { text: t('questions.template.answerType.text'), label: t('questions.template.answerType.tags'), custom_format: t('questions.template.answerType.customFormat') }; return labels[type] || type; }; const getSourceTypeLabel = type => { const labels = { image: t('questions.template.sourceType.image'), text: t('questions.template.sourceType.text') }; return labels[type] || type; }; if (loading) { return ( {t('common.loading')} ); } if (!templates || templates.length === 0) { return ( {t('questions.template.noTemplates')} ); } return ( {t('questions.template.question')} {t('questions.template.sourceType.label')} {t('questions.template.answerType.label')} {t('questions.template.description')} {t('questions.template.used')} {t('common.actions')} {templates.map(template => ( {template.question} {template.description || '-'} {template.usageCount > 0 ? ( ) : ( 0 )} onEditTemplate(template)} sx={{ mr: 1 }}> onDeleteTemplate(template.id)} disabled={template.usageCount > 0} color="error" > ))}
); } ================================================ FILE: app/projects/[projectId]/questions/components/template/TemplateFormDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, FormControl, InputLabel, Select, MenuItem, Box, Chip, Typography, Alert, FormControlLabel, Checkbox } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function TemplateFormDialog({ open, onClose, onSubmit, template }) { const { t } = useTranslation(); const [formData, setFormData] = useState({ question: '', sourceType: 'text', answerType: 'text', description: '', labels: [], customFormat: '', autoGenerate: true }); const [labelInput, setLabelInput] = useState(''); const [errors, setErrors] = useState({}); const [showConfirmDialog, setShowConfirmDialog] = useState(false); useEffect(() => { if (template) { setFormData({ question: template.question || '', sourceType: template.sourceType || 'text', answerType: template.answerType || 'text', description: template.description || '', labels: template.labels || [], customFormat: template.customFormat ? JSON.stringify(template.customFormat, null, 2) : '', autoGenerate: true // 编辑模式下默认不自动生成 }); } else { setFormData({ question: '', sourceType: 'text', answerType: 'text', description: '', labels: [], customFormat: '', autoGenerate: true }); } setErrors({}); setShowConfirmDialog(false); }, [template, open]); const handleChange = (field, value) => { setFormData(prev => ({ ...prev, [field]: value })); // 清除该字段的错误 if (errors[field]) { setErrors(prev => ({ ...prev, [field]: null })); } }; const handleAddLabel = () => { const trimmed = labelInput.trim(); if (trimmed && !formData.labels.includes(trimmed)) { setFormData(prev => ({ ...prev, labels: [...prev.labels, trimmed] })); setLabelInput(''); } }; const handleDeleteLabel = labelToDelete => { setFormData(prev => ({ ...prev, labels: prev.labels.filter(label => label !== labelToDelete) })); }; const validate = () => { const newErrors = {}; if (!formData.question.trim()) { newErrors.question = t('questions.template.errors.questionRequired'); } if (formData.answerType === 'label' && formData.labels.length === 0) { newErrors.labels = t('questions.template.errors.labelsRequired'); } if (formData.answerType === 'custom_format') { if (!formData.customFormat.trim()) { newErrors.customFormat = t('questions.template.errors.customFormatRequired'); } else { try { JSON.parse(formData.customFormat); } catch (e) { newErrors.customFormat = t('questions.template.errors.invalidJson'); } } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = () => { if (!validate()) { return; } // 如果选择了自动生成,显示确认对话框 if (formData.autoGenerate) { setShowConfirmDialog(true); return; } // 直接提交 submitTemplate(); }; const submitTemplate = () => { const submitData = { question: formData.question.trim(), sourceType: formData.sourceType, answerType: formData.answerType, description: formData.description.trim(), autoGenerate: formData.autoGenerate, templateId: template?.id // 编辑模式时传递模板ID,用于查找未创建问题的数据源 }; if (formData.answerType === 'label') { submitData.labels = formData.labels; } if (formData.answerType === 'custom_format') { try { submitData.customFormat = JSON.parse(formData.customFormat); } catch (e) { // 已在验证中处理 return; } } onSubmit(submitData); setShowConfirmDialog(false); }; const handleConfirmGenerate = () => { submitTemplate(); }; const handleCancelGenerate = () => { setShowConfirmDialog(false); }; return ( {template ? t('questions.template.edit') : t('questions.template.create')} {/* 数据源类型 */} {t('questions.template.sourceTypeInfo')} {/* 问题内容 */} handleChange('question', e.target.value)} error={!!errors.question} helperText={errors.question} required /> {/* 答案类型 */} {t('questions.template.answerType.label')} {/* 描述 */} handleChange('description', e.target.value)} helperText={t('questions.template.descriptionHelp')} multiline rows={2} /> {/* 标签输入 (仅当答案类型为 label 时显示) */} {formData.answerType === 'label' && ( setLabelInput(e.target.value)} onKeyPress={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddLabel(); } }} error={!!errors.labels} helperText={errors.labels} /> {formData.labels.map(label => ( handleDeleteLabel(label)} color="primary" variant="outlined" /> ))} )} {/* 自定义格式输入 (仅当答案类型为 custom_format 时显示) */} {formData.answerType === 'custom_format' && ( handleChange('customFormat', e.target.value)} multiline rows={6} error={!!errors.customFormat} helperText={errors.customFormat || t('questions.template.customFormatHelp')} placeholder='{"field1": "description", "field2": "description"}' /> {t('questions.template.customFormatInfo')} )} {/* 自动生成问题选项 */} handleChange('autoGenerate', e.target.checked)} color="primary" /> } label={t('questions.template.autoGenerate')} /> {formData.sourceType === 'text' ? t('questions.template.autoGenerateHelpText') : t('questions.template.autoGenerateHelpImage')} {/* 自动生成确认对话框 */} {t('questions.template.confirmAutoGenerate')} {template ? formData.sourceType === 'text' ? t('questions.template.confirmAutoGenerateEditTextMessage', { defaultValue: '您选择了自动生成问题。系统将为所有还未创建此模板问题的文本块创建问题。' }) : t('questions.template.confirmAutoGenerateEditImageMessage', { defaultValue: '您选择了自动生成问题。系统将为所有还未创建此模板问题的图片创建问题。' }) : formData.sourceType === 'text' ? t('questions.template.confirmAutoGenerateTextMessage') : t('questions.template.confirmAutoGenerateImageMessage')} {t('questions.template.autoGenerateWarning')} ); } ================================================ FILE: app/projects/[projectId]/questions/components/template/TemplateManagementDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, List, ListItem, ListItemText, ListItemSecondaryAction, IconButton, Chip, Typography, Alert, Tabs, Tab } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { useTranslation } from 'react-i18next'; import TemplateFormDialog from './TemplateFormDialog'; export default function TemplateManagementDialog({ open, onClose, templates, onCreateTemplate, onUpdateTemplate, onDeleteTemplate, loading }) { const { t } = useTranslation(); const [formOpen, setFormOpen] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); const [currentTab, setCurrentTab] = useState(0); // 0: image, 1: text const handleCreate = () => { setEditingTemplate(null); setFormOpen(true); }; const handleEdit = template => { setEditingTemplate(template); setFormOpen(true); }; const handleDelete = async templateId => { const confirmed = window.confirm(t('questions.template.deleteConfirm')); if (confirmed) { await onDeleteTemplate(templateId); } }; const handleFormSubmit = async data => { // 根据当前tab添加sourceType const sourceType = currentTab === 0 ? 'image' : 'text'; const templateData = { ...data, sourceType }; if (editingTemplate) { await onUpdateTemplate(editingTemplate.id, templateData); } else { await onCreateTemplate(templateData); } setFormOpen(false); }; const getAnswerTypeLabel = type => { const labels = { text: t('questions.template.answerType.text'), label: t('questions.template.answerType.tags'), custom_format: t('questions.template.answerType.customFormat') }; return labels[type] || type; }; // 按数据源类型分组模板 const imageTemplates = templates.filter(t => t.sourceType === 'image'); const textTemplates = templates.filter(t => t.sourceType === 'text'); const currentTemplates = currentTab === 0 ? imageTemplates : textTemplates; const renderTemplateList = templateList => { if (templateList.length === 0) { return {t('questions.template.noTemplates')}; } return ( {templateList.map(template => ( {template.question} {template.usageCount > 0 && ( )} } secondary={template.description} /> handleEdit(template)} sx={{ mr: 1 }}> handleDelete(template.id)} disabled={template.usageCount > 0}> ))} ); }; return ( <> {t('questions.template.management')} setCurrentTab(newValue)}> {renderTemplateList(currentTemplates)} setFormOpen(false)} onSubmit={handleFormSubmit} template={editingTemplate} sourceType={currentTab === 0 ? 'image' : 'text'} /> ); } ================================================ FILE: app/projects/[projectId]/questions/hooks/useQuestionDelete.js ================================================ 'use client'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { toast } from 'sonner'; export function useQuestionDelete(projectId, onDeleteSuccess) { const { t } = useTranslation(); // 确认对话框状态 const [confirmDialog, setConfirmDialog] = useState({ open: false, title: '', content: '', confirmAction: null }); // 执行单个问题删除 const executeDeleteQuestion = async (questionId, selectedQuestions, setSelectedQuestions) => { toast.promise(axios.delete(`/api/projects/${projectId}/questions/${questionId}`), { loading: '数据删除中', success: data => { // 更新选中状态 setSelectedQuestions(prev => (prev.includes(questionId) ? prev.filter(id => id !== questionId) : prev)); // 调用成功回调 if (onDeleteSuccess) { onDeleteSuccess(); } return t('common.deleteSuccess'); }, error: error => { return error.response?.data?.message || '删除失败'; } }); }; // 确认删除单个问题 const confirmDeleteQuestion = (questionId, selectedQuestions, setSelectedQuestions) => { setConfirmDialog({ open: true, title: t('common.confirmDelete'), content: t('common.confirmDeleteQuestion'), confirmAction: () => executeDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions) }); }; // 处理删除单个问题的入口函数 const handleDeleteQuestion = (questionId, selectedQuestions, setSelectedQuestions) => { confirmDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions); }; // 执行批量删除问题 const executeBatchDeleteQuestions = async (selectedQuestions, setSelectedQuestions) => { toast.promise( axios.delete(`/api/projects/${projectId}/questions/batch-delete`, { data: { questionIds: selectedQuestions } }), { loading: `正在删除 ${selectedQuestions.length} 个问题...`, success: data => { // 调用成功回调 if (onDeleteSuccess) { onDeleteSuccess(); } // 清空选中状态 setSelectedQuestions([]); return `成功删除 ${selectedQuestions.length} 个问题`; }, error: error => { return error.response?.data?.message || '批量删除问题失败'; } } ); }; // 确认批量删除问题 const confirmBatchDeleteQuestions = (selectedQuestions, setSelectedQuestions) => { if (selectedQuestions.length === 0) { toast.warning('请先选择问题'); return; } setConfirmDialog({ open: true, title: '确认批量删除问题', content: `您确定要删除选中的 ${selectedQuestions.length} 个问题吗?此操作不可恢复。`, confirmAction: () => executeBatchDeleteQuestions(selectedQuestions, setSelectedQuestions) }); }; // 处理批量删除问题的入口函数 const handleBatchDeleteQuestions = (selectedQuestions, setSelectedQuestions) => { confirmBatchDeleteQuestions(selectedQuestions, setSelectedQuestions); }; // 关闭确认对话框 const closeConfirmDialog = () => { setConfirmDialog({ open: false, title: '', content: '', confirmAction: null }); }; // 确认对话框的确认操作 const handleConfirmAction = () => { closeConfirmDialog(); if (confirmDialog.confirmAction) { confirmDialog.confirmAction(); } }; return { // 状态 confirmDialog, // 方法 handleDeleteQuestion, handleBatchDeleteQuestions, closeConfirmDialog, handleConfirmAction }; } ================================================ FILE: app/projects/[projectId]/questions/hooks/useQuestionEdit.js ================================================ 'use client'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import request from '@/lib/util/request'; export function useQuestionEdit(projectId, onSuccess) { const { t } = useTranslation(); const [editDialogOpen, setEditDialogOpen] = useState(false); const [editMode, setEditMode] = useState('create'); const [editingQuestion, setEditingQuestion] = useState(null); const handleOpenCreateDialog = () => { setEditMode('create'); setEditingQuestion(null); setEditDialogOpen(true); }; const handleOpenEditDialog = question => { setEditMode('edit'); setEditingQuestion(question); setEditDialogOpen(true); }; const handleCloseDialog = () => { setEditDialogOpen(false); setEditingQuestion(null); }; const handleSubmitQuestion = async formData => { try { const response = await request(`/api/projects/${projectId}/questions`, { method: editMode === 'create' ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( editMode === 'create' ? { question: formData.question, chunkId: formData.chunkId, label: formData.label, imageId: formData.imageId, imageName: formData.imageName } : { id: formData.id, question: formData.question, chunkId: formData.chunkId, label: formData.label, imageId: formData.imageId, imageName: formData.imageName } ) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('questions.operationFailed')); } // 获取更新后的问题数据 const updatedQuestion = await response.json(); // 直接更新问题列表中的数据,而不是重新获取整个列表 if (onSuccess) { onSuccess(updatedQuestion); } handleCloseDialog(); } catch (error) { console.error('操作失败:', error); } }; return { editDialogOpen, editMode, editingQuestion, handleOpenCreateDialog, handleOpenEditDialog, handleCloseDialog, handleSubmitQuestion }; } ================================================ FILE: app/projects/[projectId]/questions/hooks/useQuestionExport.js ================================================ 'use client'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import axios from 'axios'; const useQuestionExport = projectId => { const { t } = useTranslation(); // 导出问题集 const exportQuestions = async exportOptions => { try { const apiUrl = `/api/projects/${projectId}/questions/export`; const requestBody = { format: exportOptions.format || 'json' }; // 如果有选中的问题 ID,传递 ID 列表 if (exportOptions.selectedIds && exportOptions.selectedIds.length > 0) { requestBody.selectedIds = exportOptions.selectedIds; } // 如果有筛选条件,传递筛选参数 if (exportOptions.filters) { requestBody.filters = exportOptions.filters; } const response = await axios.post(apiUrl, requestBody); const questions = response.data; // 处理和下载数据 await processAndDownloadData(questions, exportOptions); toast.success(t('questions.exportSuccess')); return true; } catch (error) { console.error('Export failed:', error); toast.error(error.message || t('questions.exportFailed')); return false; } }; // 处理和下载数据的通用函数 const processAndDownloadData = async (data, exportOptions) => { const format = exportOptions.format || 'json'; let content; let filename; let mimeType; const timestamp = new Date().toISOString().split('T')[0]; switch (format) { case 'json': content = JSON.stringify(data, null, 2); filename = `questions-${projectId}-${timestamp}.json`; mimeType = 'application/json'; break; case 'jsonl': content = data.map(item => JSON.stringify(item)).join('\n'); filename = `questions-${projectId}-${timestamp}.jsonl`; mimeType = 'application/jsonl'; break; case 'txt': content = data.map(item => item.question).join('\n\n'); filename = `questions-${projectId}-${timestamp}.txt`; mimeType = 'text/plain'; break; case 'csv': // CSV 格式 const headers = Object.keys(data[0] || {}); const csvRows = [headers.join(',')]; data.forEach(item => { const values = headers.map(header => { const value = item[header] || ''; // 处理包含逗号或引号的值 if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) { return `"${value.replace(/"/g, '""')}"`; } return value; }); csvRows.push(values.join(',')); }); content = csvRows.join('\n'); filename = `questions-${projectId}-${timestamp}.csv`; mimeType = 'text/csv'; break; default: content = JSON.stringify(data, null, 2); filename = `questions-${projectId}-${timestamp}.json`; mimeType = 'application/json'; } // 创建下载链接 const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; return { exportQuestions }; }; export default useQuestionExport; ================================================ FILE: app/projects/[projectId]/questions/hooks/useQuestionGeneration.js ================================================ 'use client'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import axios from 'axios'; import i18n from '@/lib/i18n'; import request from '@/lib/util/request'; import { processInParallel } from '@/lib/util/processInParallel'; export function useQuestionGeneration(projectId, model, taskSettings, getQuestionList) { const { t } = useTranslation(); // 处理状态 const [processing, setProcessing] = useState(false); // 进度状态 const [progress, setProgress] = useState({ total: 0, // 总共选择的问题数量 completed: 0, // 已处理完成的数量 percentage: 0, // 进度百分比 datasetCount: 0 // 已生成的数据集数量 }); // 批量生成答案 const handleBatchGenerateAnswers = async selectedQuestions => { if (selectedQuestions.length === 0) { toast.warning(t('questions.noQuestionsSelected')); return; } if (!model) { toast.warning(t('models.configNotFound')); return; } try { setProgress({ total: selectedQuestions.length, completed: 0, percentage: 0, datasetCount: 0 }); // 然后设置处理状态为真,确保进度条显示 setProcessing(true); toast.info(t('questions.batchGenerateStart', { count: selectedQuestions.length })); // 单个问题处理函数 const processQuestion = async questionId => { try { console.log('开始生成数据集:', { questionId }); const language = i18n.language === 'zh-CN' ? '中文' : 'en'; // 调用API生成数据集 const response = await request(`/api/projects/${projectId}/datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ questionId, model, language }) }); if (!response.ok) { const errorData = await response.json(); console.error(t('datasets.generateError'), errorData.error || t('datasets.generateFailed')); // 更新进度状态(即使失败也计入已处理) setProgress(prev => { const completed = prev.completed + 1; const percentage = Math.round((completed / prev.total) * 100); return { ...prev, completed, percentage }; }); return { success: false, questionId, error: errorData.error || t('datasets.generateFailed') }; } const data = await response.json(); // 更新进度状态 setProgress(prev => { const completed = prev.completed + 1; const percentage = Math.round((completed / prev.total) * 100); const datasetCount = prev.datasetCount + 1; return { ...prev, completed, percentage, datasetCount }; }); console.log(`数据集生成成功: ${questionId}`); return { success: true, questionId, data: data.dataset }; } catch (error) { console.error('生成数据集失败:', error); // 更新进度状态(即使失败也计入已处理) setProgress(prev => { const completed = prev.completed + 1; const percentage = Math.round((completed / prev.total) * 100); return { ...prev, completed, percentage }; }); return { success: false, questionId, error: error.message }; } }; // 并行处理所有问题,最多同时处理2个 const results = await processInParallel(selectedQuestions, processQuestion, taskSettings.concurrencyLimit); // 刷新数据 getQuestionList(); // 处理完成后设置结果消息 const successCount = results.filter(r => r.success).length; const failCount = results.filter(r => !r.success).length; if (failCount > 0) { toast.warning( t('datasets.partialSuccess', { successCount, total: selectedQuestions.length, failCount }) ); } else { toast.success(t('common.success', { successCount })); } } catch (error) { console.error('生成数据集出错:', error); toast.error(error.message || '生成数据集失败'); } finally { // 延迟关闭处理状态,确保用户可以看到完成的进度 setTimeout(() => { setProcessing(false); // 再次延迟重置进度状态 setTimeout(() => { setProgress({ total: 0, completed: 0, percentage: 0, datasetCount: 0 }); }, 500); }, 2000); // 延迟关闭处理状态,让用户看到完成的进度 } }; // 自动生成数据集 const handleAutoGenerateDatasets = async () => { try { if (!model) { toast.error(t('questions.selectModelFirst', { defaultValue: '请先选择模型' })); return; } // 调用创建任务接口 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'answer-generation', modelInfo: model, language: i18n.language }); if (response.data?.code === 0) { toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成答案的问题' })); } else { toast.error(t('tasks.createFailed', { defaultValue: '创建后台任务失败' })); } } catch (error) { console.error('创建任务失败:', error); toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message); } }; // 自动生成图像问答数据集 const handleAutoGenerateImageDatasets = async () => { try { if (!model) { toast.error(t('questions.selectModelFirst', { defaultValue: '请先选择模型' })); return; } if (model.type !== 'vision') { toast.error(t('images.visionModelRequired', { defaultValue: '请选择支持视觉的模型' })); return; } // 调用创建任务接口 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'image-dataset-generation', modelInfo: model, language: i18n.language }); if (response.data?.code === 0) { toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成答案的图片问题' })); } else { toast.error(t('tasks.createFailed', { defaultValue: '创建后台任务失败' })); } } catch (error) { console.error('创建图片数据集任务失败:', error); toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message); } }; // 自动生成多轮对话数据集 const handleAutoGenerateMultiTurnDatasets = async () => { try { if (!model) { toast.error(t('questions.selectModelFirst', { defaultValue: '请先选择模型' })); return; } // 首先检查项目是否配置了多轮对话设置 const configResponse = await axios.get(`/api/projects/${projectId}/tasks`); if (configResponse.status !== 200) { throw new Error('获取项目配置失败'); } const config = configResponse.data; const multiTurnConfig = { systemPrompt: config.multiTurnSystemPrompt, scenario: config.multiTurnScenario, rounds: config.multiTurnRounds, roleA: config.multiTurnRoleA, roleB: config.multiTurnRoleB }; // 检查是否已配置必要的多轮对话设置 if ( !multiTurnConfig.scenario || !multiTurnConfig.roleA || !multiTurnConfig.roleB || !multiTurnConfig.rounds || multiTurnConfig.rounds < 1 ) { toast.error(t('questions.multiTurnNotConfigured', '请先在项目设置中配置多轮对话相关参数')); return; } // 调用创建任务接口 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'multi-turn-generation', modelInfo: model, language: i18n.language, config: JSON.stringify(multiTurnConfig) }); if (response.data?.code === 0) { toast.success( t('tasks.multiTurnCreateSuccess', { defaultValue: '多轮对话生成任务已创建,系统将自动处理未生成多轮对话的问题' }) ); } else { toast.error(t('tasks.createFailed', { defaultValue: '创建后台任务失败' })); } } catch (error) { console.error('创建多轮对话任务失败:', error); toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message); } }; return { // 状态 processing, progress, // 方法 handleBatchGenerateAnswers, handleAutoGenerateDatasets, handleAutoGenerateMultiTurnDatasets, handleAutoGenerateImageDatasets }; } ================================================ FILE: app/projects/[projectId]/questions/hooks/useQuestionTemplates.js ================================================ import { useState, useEffect } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; /** * 问题模板管理 Hook * @param {string} projectId - 项目ID * @param {string} sourceType - 数据源类型: 'image' | 'text' | null (null表示获取所有) */ export function useQuestionTemplates(projectId, sourceType = null) { const { t } = useTranslation(); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(false); // 获取模板列表 const fetchTemplates = async () => { try { setLoading(true); const params = sourceType ? `?sourceType=${sourceType}` : ''; const response = await axios.get(`/api/projects/${projectId}/questions/templates${params}`); if (response.data.success) { setTemplates(response.data.templates); } } catch (error) { console.error('Failed to fetch templates:', error); toast.error(t('questions.fetchTemplatesFailed')); } finally { setLoading(false); } }; // 创建模板 const createTemplate = async data => { try { const response = await axios.post(`/api/projects/${projectId}/questions/templates`, data); if (response.data.success) { const { template, generation } = response.data; // 显示模板创建成功消息 toast.success(t('questions.createTemplateSuccess')); // 如果有自动生成结果,显示相应消息 if (generation) { if (generation.success) { if (generation.successCount > 0) { toast.success( t('questions.template.autoGenerateSuccess', { count: generation.successCount }) ); } if (generation.failCount > 0) { toast.warning( t('questions.template.autoGeneratePartialFail', { success: generation.successCount, fail: generation.failCount }) ); } } else { toast.error(generation.message || t('questions.template.autoGenerateFailed')); } } fetchTemplates(); return template; } } catch (error) { console.error('Failed to create template:', error); toast.error(t('questions.createTemplateFailed')); throw error; } }; // 更新模板 const updateTemplate = async (templateId, data) => { try { const response = await axios.put(`/api/projects/${projectId}/questions/templates/${templateId}`, data); if (response.data.success) { toast.success(t('questions.updateTemplateSuccess')); fetchTemplates(); return response.data.template; } } catch (error) { console.error('Failed to update template:', error); toast.error(t('questions.updateTemplateFailed')); throw error; } }; // 删除模板 const deleteTemplate = async templateId => { try { const response = await axios.delete(`/api/projects/${projectId}/questions/templates/${templateId}`); if (response.data.success) { toast.success(t('questions.deleteTemplateSuccess')); fetchTemplates(); } } catch (error) { console.error('Failed to delete template:', error); toast.error(t('questions.deleteTemplateFailed')); throw error; } }; // 初始加载 useEffect(() => { if (projectId) { fetchTemplates(); } }, [projectId, sourceType]); return { templates, loading, createTemplate, updateTemplate, deleteTemplate, refetch: fetchTemplates }; } ================================================ FILE: app/projects/[projectId]/questions/hooks/useQuestionsFilter.js ================================================ 'use client'; import { useState } from 'react'; import { useDebounce } from '@/hooks/useDebounce'; import axios from 'axios'; export function useQuestionsFilter(projectId) { // 过滤和搜索状态 const [answerFilter, setAnswerFilter] = useState('all'); // 'all', 'answered', 'unanswered' const [searchTerm, setSearchTerm] = useState(''); const [searchMatchMode, setSearchMatchMode] = useState('match'); // 'match', 'notMatch' const [chunkNameFilter, setChunkNameFilter] = useState(''); const [sourceTypeFilter, setSourceTypeFilter] = useState('all'); // 'all', 'text', 'image' const debouncedSearchTerm = useDebounce(searchTerm); const debouncedChunkNameFilter = useDebounce(chunkNameFilter); // 选择状态 const [selectedQuestions, setSelectedQuestions] = useState([]); // 处理问题选择 const handleSelectQuestion = (questionKey, newSelected) => { if (newSelected) { // 处理批量选择的情况 setSelectedQuestions(newSelected); } else { // 处理单个问题选择的情况 setSelectedQuestions(prev => { if (prev.includes(questionKey)) { return prev.filter(id => id !== questionKey); } else { return [...prev, questionKey]; } }); } }; // 全选/取消全选 const handleSelectAll = async () => { if (selectedQuestions.length > 0) { setSelectedQuestions([]); } else { const response = await axios.get( `/api/projects/${projectId}/questions?status=${answerFilter}&input=${searchTerm}&searchMatchMode=${searchMatchMode}&chunkName=${encodeURIComponent(chunkNameFilter)}&sourceType=${sourceTypeFilter}&selectedAll=1` ); setSelectedQuestions(response.data.map(dataset => dataset.id)); } }; // 处理搜索输入变化 const handleSearchChange = event => { setSearchTerm(event.target.value); }; // 处理过滤器变化 const handleFilterChange = event => { setAnswerFilter(event.target.value); }; // 处理文本块名称筛选变化 const handleChunkNameFilterChange = event => { setChunkNameFilter(event.target.value); }; // 处理数据源类型筛选变化 const handleSourceTypeFilterChange = event => { setSourceTypeFilter(event.target.value); }; // 处理搜索匹配模式变化 const handleSearchMatchModeChange = event => { setSearchMatchMode(event.target.value); }; // 清空选择 const clearSelection = () => { setSelectedQuestions([]); }; // 重置所有过滤条件 const resetFilters = () => { setSearchTerm(''); setSearchMatchMode('match'); setAnswerFilter('all'); setChunkNameFilter(''); setSourceTypeFilter('all'); setSelectedQuestions([]); }; return { // 状态 answerFilter, searchTerm, debouncedSearchTerm, searchMatchMode, chunkNameFilter, debouncedChunkNameFilter, sourceTypeFilter, selectedQuestions, // 方法 setAnswerFilter, setSearchTerm, setSearchMatchMode, setChunkNameFilter, setSourceTypeFilter, setSelectedQuestions, handleSelectQuestion, handleSelectAll, handleSearchChange, handleFilterChange, handleChunkNameFilterChange, handleSourceTypeFilterChange, handleSearchMatchModeChange, clearSelection, resetFilters }; } ================================================ FILE: app/projects/[projectId]/questions/page.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Container, Typography, Box, Paper, Tabs, Tab, CircularProgress, Divider, LinearProgress } from '@mui/material'; import QuestionListView from '@/components/questions/QuestionListView'; import QuestionTreeView from '@/components/questions/QuestionTreeView'; import TabPanel from '@/components/text-split/components/TabPanel'; import useTaskSettings from '@/hooks/useTaskSettings'; import QuestionEditDialog from './components/QuestionEditDialog'; import QuestionsPageHeader from './components/QuestionsPageHeader'; import ConfirmDialog from './components/ConfirmDialog'; import TemplateListView from './components/TemplateListView'; import TemplateFormDialog from './components/template/TemplateFormDialog'; import ExportQuestionsDialog from './components/ExportQuestionsDialog'; import { useQuestionTemplates } from './hooks/useQuestionTemplates'; import { useQuestionEdit } from './hooks/useQuestionEdit'; import { useQuestionDelete } from './hooks/useQuestionDelete'; import { useQuestionsFilter } from './hooks/useQuestionsFilter'; import QuestionsFilter from './components/QuestionsFilter'; import { useQuestionGeneration } from './hooks/useQuestionGeneration'; import useQuestionExport from './hooks/useQuestionExport'; import axios from 'axios'; import { toast } from 'sonner'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; export default function QuestionsPage({ params }) { const { t } = useTranslation(); const { projectId } = params; const [loading, setLoading] = useState(true); const [questions, setQuestions] = useState({}); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [tags, setTags] = useState([]); const model = useAtomValue(selectedModelInfoAtom); const [activeTab, setActiveTab] = useState(0); // 模板管理 const { templates, loading: templatesLoading, createTemplate, updateTemplate, deleteTemplate } = useQuestionTemplates(projectId, null); // null 表示获取所有类型的模板 const [templateDialogOpen, setTemplateDialogOpen] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); const [exportDialogOpen, setExportDialogOpen] = useState(false); // 使用新的过滤和搜索 Hook const { answerFilter, searchTerm, debouncedSearchTerm, searchMatchMode, chunkNameFilter, debouncedChunkNameFilter, sourceTypeFilter, selectedQuestions, setSelectedQuestions, handleSelectQuestion, handleSelectAll, handleSearchChange, handleFilterChange, handleChunkNameFilterChange, handleSourceTypeFilterChange, handleSearchMatchModeChange } = useQuestionsFilter(projectId); const getQuestionList = async () => { try { // 获取问题列表 const questionsResponse = await axios.get( `/api/projects/${projectId}/questions?page=${currentPage}&size=10&status=${answerFilter}&input=${searchTerm}&searchMatchMode=${searchMatchMode}&chunkName=${encodeURIComponent(debouncedChunkNameFilter)}&sourceType=${sourceTypeFilter}` ); if (questionsResponse.status !== 200) { throw new Error(t('common.fetchError')); } setQuestions(questionsResponse.data || {}); // 获取标签树 const tagsResponse = await axios.get(`/api/projects/${projectId}/tags`); if (tagsResponse.status !== 200) { throw new Error(t('common.fetchError')); } setTags(tagsResponse.data.tags || []); setLoading(false); } catch (error) { console.error(t('common.fetchError'), error); toast.error(error.message); } }; // 当筛选条件改变时,重置页码到第1页 useEffect(() => { setCurrentPage(1); }, [answerFilter, debouncedSearchTerm, debouncedChunkNameFilter, sourceTypeFilter, searchMatchMode]); useEffect(() => { getQuestionList(); }, [currentPage, answerFilter, debouncedSearchTerm, debouncedChunkNameFilter, sourceTypeFilter, searchMatchMode]); const { taskSettings } = useTaskSettings(projectId); // 使用新的问题生成 Hook const { processing, progress, handleBatchGenerateAnswers, handleAutoGenerateDatasets, handleAutoGenerateMultiTurnDatasets, handleAutoGenerateImageDatasets } = useQuestionGeneration(projectId, model, taskSettings, getQuestionList); const { editDialogOpen, editMode, editingQuestion, handleOpenCreateDialog, handleOpenEditDialog, handleCloseDialog, handleSubmitQuestion } = useQuestionEdit(projectId, updatedQuestion => { getQuestionList(); toast.success(t('questions.operationSuccess')); }); const { confirmDialog, handleDeleteQuestion, handleBatchDeleteQuestions, closeConfirmDialog, handleConfirmAction } = useQuestionDelete(projectId, () => { getQuestionList(); }); const { exportQuestions } = useQuestionExport(projectId); // 获取所有数据 useEffect(() => { getQuestionList(); }, [projectId]); // 处理标签页切换 const handleTabChange = (event, newValue) => { setActiveTab(newValue); }; // 模板管理函数 const handleOpenCreateTemplateDialog = () => { setEditingTemplate(null); setTemplateDialogOpen(true); }; const handleEditTemplate = template => { setEditingTemplate(template); setTemplateDialogOpen(true); }; const handleCloseTemplateDialog = () => { setTemplateDialogOpen(false); setEditingTemplate(null); }; const handleSubmitTemplate = async data => { try { if (editingTemplate) { await updateTemplate(editingTemplate.id, data); } else { await createTemplate(data); } getQuestionList(); handleCloseTemplateDialog(); } catch (error) { console.error('Failed to save template:', error); } }; const handleDeleteTemplate = async templateId => { const confirmed = window.confirm(t('questions.template.deleteConfirm')); if (confirmed) { try { await deleteTemplate(templateId); } catch (error) { console.error('Failed to delete template:', error); } } }; const handleOpenExportDialog = () => { setExportDialogOpen(true); }; const handleCloseExportDialog = () => { setExportDialogOpen(false); }; const handleExportQuestions = async exportOptions => { const options = { ...exportOptions, selectedIds: selectedQuestions, filters: { searchTerm: debouncedSearchTerm, chunkName: debouncedChunkNameFilter, sourceType: sourceTypeFilter } }; await exportQuestions(options); }; if (loading) { return ( ); } return ( {/* 处理中的进度显示 - 全局蒙版样式 */} {processing && ( {t('datasets.generatingDataset')} {progress.percentage}% {t('questions.generatingProgress', { completed: progress.completed, total: progress.total })} {t('questions.generatedCount', { count: progress.datasetCount })} {t('questions.pleaseWait')} )} handleBatchDeleteQuestions(selectedQuestions, setSelectedQuestions)} onOpenCreateDialog={handleOpenCreateDialog} onOpenCreateTemplateDialog={handleOpenCreateTemplateDialog} onBatchGenerateAnswers={() => handleBatchGenerateAnswers(selectedQuestions)} onAutoGenerateDatasets={handleAutoGenerateDatasets} onAutoGenerateMultiTurnDatasets={handleAutoGenerateMultiTurnDatasets} onAutoGenerateImageDatasets={handleAutoGenerateImageDatasets} onExportQuestions={handleOpenExportDialog} /> 0 && selectedQuestions.length === questions?.total} isIndeterminate={selectedQuestions.length > 0 && selectedQuestions.length < questions?.total} onSelectAll={handleSelectAll} searchTerm={searchTerm} onSearchChange={handleSearchChange} searchMatchMode={searchMatchMode} onSearchMatchModeChange={handleSearchMatchModeChange} answerFilter={answerFilter} onFilterChange={handleFilterChange} chunkNameFilter={chunkNameFilter} onChunkNameFilterChange={handleChunkNameFilterChange} sourceTypeFilter={sourceTypeFilter} onSourceTypeFilterChange={handleSourceTypeFilterChange} activeTab={activeTab} /> setCurrentPage(newPage)} selectedQuestions={selectedQuestions} onSelectQuestion={handleSelectQuestion} onDeleteQuestion={questionId => handleDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions)} onEditQuestion={handleOpenEditDialog} refreshQuestions={getQuestionList} projectId={projectId} /> handleDeleteQuestion(questionId, selectedQuestions, setSelectedQuestions)} onEditQuestion={handleOpenEditDialog} projectId={projectId} searchTerm={searchTerm} /> {/* 确认对话框 */} ); } ================================================ FILE: app/projects/[projectId]/settings/components/CategoryTabs.js ================================================ import React from 'react'; import { Tabs, Tab } from '@mui/material'; /** * 顶部分类选择标签页组件 */ const CategoryTabs = ({ categoryEntries, selectedCategory, currentLanguage, onCategoryChange }) => { return ( { onCategoryChange(newValue); }} variant="scrollable" scrollButtons="auto" sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }} > {categoryEntries.map(([categoryKey, categoryConfig]) => ( ))} ); }; export default CategoryTabs; ================================================ FILE: app/projects/[projectId]/settings/components/PromptDetail.js ================================================ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, Box, Typography, Chip, Button, Paper } from '@mui/material'; import { Edit as EditIcon, Restore as RestoreIcon } from '@mui/icons-material'; import ReactMarkdown from 'react-markdown'; import 'github-markdown-css/github-markdown-light.css'; /** * 右侧提示词详情展示组件 */ const PromptDetail = ({ currentPromptConfig, selectedPrompt, promptContent, isCustomized, onEditClick, onDeleteClick }) => { const { t } = useTranslation(); if (!currentPromptConfig) { return ( {t('settings.prompts.selectPromptFirst')} ); } const handleEditClick = () => { onEditClick(); }; const handleDeleteClick = () => { onDeleteClick(); }; return ( {/* 标题、描述与操作区域 */} {currentPromptConfig.name} {isCustomized(selectedPrompt) && ( )} {isCustomized(selectedPrompt) && ( )} {currentPromptConfig.description} {/* Markdown 渲染提示词内容 */}
{promptContent}
); }; export default PromptDetail; ================================================ FILE: app/projects/[projectId]/settings/components/PromptEditDialog.js ================================================ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box, Typography, Chip } from '@mui/material'; import SaveIcon from '@mui/icons-material/Save'; import RestoreIcon from '@mui/icons-material/Restore'; /** * 提示词编辑对话框组件 */ const PromptEditDialog = ({ open, title, promptType, promptKey, content, loading, onClose, onSave, onRestore, onContentChange }) => { const { t } = useTranslation(); return ( {title} {t('settings.prompts.promptType')}: {promptType} {t('settings.prompts.keyName')}: {promptKey} onContentChange(e.target.value)} placeholder={t('settings.prompts.contentPlaceholder')} variant="outlined" /> ); }; export default PromptEditDialog; ================================================ FILE: app/projects/[projectId]/settings/components/PromptList.js ================================================ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Tabs, Tab, Typography, Chip } from '@mui/material'; import { shouldShowPrompt } from './promptUtils'; /** * 左侧提示词列表组件 */ const PromptList = ({ currentCategory, currentCategoryConfig, selectedPrompt, currentLanguage, isCustomized, onPromptSelect }) => { const { t } = useTranslation(); if (!currentCategoryConfig?.prompts) { return ( {t('settings.prompts.noPromptsAvailable')} ); } return ( onPromptSelect(newValue)} variant="scrollable" scrollButtons="auto" sx={{ borderRight: 1, borderColor: 'divider', '& .MuiTabs-indicator': { left: 0, right: 'auto' }, '& .MuiTab-root': { alignItems: 'flex-start', textAlign: 'left' } }} > {currentCategoryConfig && Object.entries(currentCategoryConfig.prompts).map(([promptKey, promptConfig]) => { if (!shouldShowPrompt(promptKey, currentLanguage)) return null; const customized = isCustomized(promptKey); return ( {promptConfig.name} {customized && ( )} } sx={{ alignItems: 'flex-start', minHeight: 60, px: 2, justifyContent: 'flex-start', width: '100%' }} /> ); })} ); }; export default PromptList; ================================================ FILE: app/projects/[projectId]/settings/components/PromptSettings.js ================================================ import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { Box, Grid, Card, CardContent } from '@mui/material'; import { fetchWithRetry } from '@/lib/util/request'; import { useSnackbar } from '@/hooks/useSnackbar'; // 导入拆分后的组件 import CategoryTabs from './CategoryTabs'; import PromptList from './PromptList'; import PromptDetail from './PromptDetail'; import PromptEditDialog from './PromptEditDialog'; import { getLanguageFromPromptKey, shouldShowPrompt } from './promptUtils'; /** * 提示词设置主组件 */ export default function PromptSettings() { const { projectId } = useParams(); const { i18n, t } = useTranslation(); const { showSuccess, showErrorMessage, SnackbarComponent } = useSnackbar(); // 基础状态 const [currentLanguage, setCurrentLanguage] = useState(i18n.language === 'en' ? 'en' : 'zh-CN'); const [loading, setLoading] = useState(false); const [templates, setTemplates] = useState({}); const [customPrompts, setCustomPrompts] = useState([]); // 当前选中状态 const [selectedCategory, setSelectedCategory] = useState(null); const [selectedPrompt, setSelectedPrompt] = useState(null); const [promptContent, setPromptContent] = useState(''); // 编辑对话框状态 const [editDialog, setEditDialog] = useState({ open: false, promptType: '', promptKey: '', language: '', content: '', defaultContent: '', isNew: false }); // ======= 数据加载与初始化 ======= // 加载提示词数据 useEffect(() => { loadPromptData(); }, [projectId, currentLanguage]); // 监听语言变化 useEffect(() => { const newLang = i18n.language === 'en' ? 'en' : 'zh-CN'; if (newLang !== currentLanguage) { setCurrentLanguage(newLang); } }, [i18n.language, currentLanguage]); // 监听选中提示词变化 useEffect(() => { if (selectedPrompt) { loadPromptContent(); } }, [selectedPrompt]); // 初始化选择第一个分类和提示词 useEffect(() => { if (Object.keys(templates).length > 0 && currentLanguage && !selectedCategory) { const firstCategory = Object.keys(templates)[0]; setSelectedCategory(firstCategory); // 根据当前语言环境选择第一个匹配的提示词 const promptEntries = Object.keys(templates[firstCategory]?.prompts || {}); const firstPrompt = promptEntries.find(promptKey => shouldShowPrompt(promptKey, currentLanguage)); if (firstPrompt) { setSelectedPrompt(firstPrompt); } } }, [templates, selectedCategory, currentLanguage]); // ======= API 操作函数 ======= // 加载提示词数据 const loadPromptData = async () => { try { setLoading(true); const response = await fetchWithRetry(`/api/projects/${projectId}/custom-prompts?language=${currentLanguage}`); const data = await response.json(); if (data.success) { setTemplates(data.templates); setCustomPrompts(data.customPrompts); } else { showErrorMessage(data.message || '加载提示词数据失败'); } } catch (error) { console.error('加载提示词数据出错:', error); showErrorMessage('加载提示词数据失败'); } finally { setLoading(false); } }; // 加载提示词内容 const loadPromptContent = async (forceRefresh = false) => { if (!selectedPrompt) return; try { setLoading(true); const content = await getCurrentPromptContent(selectedPrompt, forceRefresh); setPromptContent(content); } catch (error) { console.error('加载提示词内容出错:', error); showErrorMessage('加载提示词内容失败'); } finally { setLoading(false); } }; // 加载默认提示词内容 const loadDefaultContent = async (promptType, promptKey) => { if (i18n.language === 'en' && !promptKey.endsWith('_EN')) { promptKey += '_EN'; } try { const response = await fetchWithRetry( `/api/projects/${projectId}/default-prompts?promptType=${promptType}&promptKey=${promptKey}` ); const data = await response.json(); if (data.success) { return data.content; } return ''; } catch (error) { console.error('加载默认提示词内容出错:', error); return ''; } }; // ======= 交互处理函数 ======= // 处理编辑提示词 const handleEditPrompt = async (promptType, promptKey, language) => { const existingPrompt = customPrompts.find( p => p.promptType === promptType && p.promptKey === promptKey && p.language === language ); const defaultContent = await loadDefaultContent(promptType, promptKey); setEditDialog({ open: true, promptType, promptKey, language, content: existingPrompt?.content || defaultContent, defaultContent, isNew: !existingPrompt }); }; // 处理删除提示词 const handleDeletePrompt = async (promptType, promptKey, language) => { try { setLoading(true); const query = new URLSearchParams({ promptType, promptKey, language }).toString(); const response = await fetchWithRetry(`/api/projects/${projectId}/custom-prompts?${query}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { showSuccess(t('settings.prompts.restoreSuccess')); // 先重新加载数据,然后强制刷新内容 await loadPromptData(); await loadPromptContent(true); // 强制刷新 } else { showErrorMessage(data.message || t('settings.prompts.restoreFailed')); } } catch (error) { console.error(t('settings.prompts.deleteError'), error); showErrorMessage(t('settings.prompts.restoreFailed')); } finally { setLoading(false); } }; // 处理保存提示词 const handleSavePrompt = async () => { try { setLoading(true); const { promptType, promptKey, language, content } = editDialog; const response = await fetchWithRetry(`/api/projects/${projectId}/custom-prompts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ promptType, promptKey, language, content }) }); const data = await response.json(); if (data.success) { showSuccess(t('settings.prompts.saveSuccess')); setEditDialog({ ...editDialog, open: false }); // 先重新加载数据,然后强制刷新内容 await loadPromptData(); await loadPromptContent(true); // 强制刷新 } else { showErrorMessage(data.message || t('settings.prompts.saveFailed')); } } catch (error) { console.error(t('settings.prompts.saveError'), error); showErrorMessage(t('settings.prompts.saveFailed')); } finally { setLoading(false); } }; // 恢复默认内容 const handleRestoreDefault = () => { setEditDialog(prev => ({ ...prev, content: prev.defaultContent })); }; // ======= 工具函数 ======= // 检查提示词是否已自定义 const isCustomized = promptKey => { if (!selectedCategory || !promptKey || !templates[selectedCategory]) return false; const language = getLanguageFromPromptKey(promptKey); const promptType = templates[selectedCategory]?.prompts?.[promptKey]?.type; if (!promptType) return false; return customPrompts.some(p => p.promptType === promptType && p.promptKey === promptKey && p.language === language); }; // 获取当前提示词内容(直接从服务器获取最新数据) const getCurrentPromptContent = async (promptKey, forceRefresh = false) => { if (!selectedCategory || !promptKey || !templates[selectedCategory]) return ''; const language = getLanguageFromPromptKey(promptKey); const promptType = templates[selectedCategory]?.prompts?.[promptKey]?.type; if (!promptType) { return ''; } // 如果需要强制刷新,直接从服务器获取 if (forceRefresh) { try { const response = await fetchWithRetry( `/api/projects/${projectId}/custom-prompts?promptType=${promptType}&language=${language}` ); const data = await response.json(); if (data.success) { const existingPrompt = data.customPrompts.find( p => p.promptType === promptType && p.promptKey === promptKey && p.language === language ); if (existingPrompt) { return existingPrompt.content; } } } catch (error) { console.error(t('settings.prompts.fetchContentError'), error); } } else { // 使用缓存的状态 const existingPrompt = customPrompts.find( p => p.promptType === promptType && p.promptKey === promptKey && p.language === language ); if (existingPrompt) { return existingPrompt.content; } } // 回退到默认内容 return await loadDefaultContent(promptType, promptKey); }; // ======= 数据准备 ======= // 当前分类的配置 const currentCategoryConfig = templates[selectedCategory]; // 当前提示词的配置 const currentPromptConfig = currentCategoryConfig?.prompts?.[selectedPrompt]; // 分类配置项 const categoryEntries = Object.entries(templates); // 处理分类变更 const handleCategoryChange = newCategory => { setSelectedCategory(newCategory); // 根据当前语言环境选择第一个匹配的提示词 const promptEntries = Object.keys(templates[newCategory]?.prompts || {}); console.log('所有提示词:', promptEntries); const firstPrompt = promptEntries.find(promptKey => shouldShowPrompt(promptKey, currentLanguage)); setSelectedPrompt(firstPrompt); }; // 处理编辑按钮点击 const handleEditButtonClick = () => { const promptType = templates[selectedCategory]?.prompts?.[selectedPrompt]?.type; // 使用当前界面语言而不是从 promptKey 推断的语言 const language = currentLanguage; if (promptType) { handleEditPrompt(promptType, selectedPrompt, language); } }; // 处理删除按钮点击 const handleDeleteButtonClick = () => { const promptType = templates[selectedCategory]?.prompts?.[selectedPrompt]?.type; // 使用当前界面语言而不是从 promptKey 推断的语言 const language = currentLanguage; if (promptType) { handleDeletePrompt(promptType, selectedPrompt, language); } }; // 处理对话框内容变更 const handleDialogContentChange = newContent => { setEditDialog({ ...editDialog, content: newContent }); }; return ( {/* 主要分类选择 */} {/* 左右布局:左侧垂直提示词选择,右侧内容展示 */} {/* 左侧:垂直 TAB 选择具体提示词 */} {/* 右侧:提示词内容展示和操作 */} {/* 编辑提示词对话框 */} setEditDialog({ ...editDialog, open: false })} onSave={handleSavePrompt} onRestore={handleRestoreDefault} onContentChange={handleDialogContentChange} /> ); } ================================================ FILE: app/projects/[projectId]/settings/components/promptUtils.js ================================================ /** * 提示词设置相关工具函数 */ /** * 从提示词键名解析语言 * @param {string} promptKey 提示词键名 * @returns {string} 语言代码 ('zh-CN' 或 'en') */ export const getLanguageFromPromptKey = promptKey => { return promptKey?.endsWith('_EN') ? 'en' : 'zh-CN'; }; /** * 判断是否应该显示当前提示词(基于语言) * @param {string} promptKey 提示词键名 * @param {string} currentLanguage 当前界面语言 * @returns {boolean} 是否应该显示 */ export const shouldShowPrompt = (promptKey, currentLanguage) => { const promptLang = getLanguageFromPromptKey(promptKey); return promptLang === currentLanguage; }; /** * 构建提示词标题显示组件 * @param {Object} options 配置项 * @param {string} options.name 提示词名称 * @param {boolean} options.customized 是否已自定义 * @returns {Object} 包含名称和自定义标记的显示配置 */ export const buildPromptTitle = ({ name, customized }) => { return { name, customized }; }; ================================================ FILE: app/projects/[projectId]/settings/page.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Container, Typography, Box, Tabs, Tab, Paper, Alert, CircularProgress } from '@mui/material'; import { useSearchParams, useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; // 导入设置组件 import BasicSettings from '@/components/settings/BasicSettings'; import ModelSettings from '@/components/settings/ModelSettings'; import TaskSettings from '@/components/settings/TaskSettings'; import PromptSettings from './components/PromptSettings'; // 定义 TAB 枚举 const TABS = { BASIC: 'basic', MODEL: 'model', TASK: 'task', PROMPTS: 'prompts' }; export default function SettingsPage({ params }) { const { t } = useTranslation(); const { projectId } = params; const searchParams = useSearchParams(); const router = useRouter(); const [activeTab, setActiveTab] = useState(TABS.BASIC); const [projectExists, setProjectExists] = useState(true); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 从 URL hash 中获取当前 tab useEffect(() => { const tab = searchParams.get('tab'); if (tab && Object.values(TABS).includes(tab)) { setActiveTab(tab); } }, [searchParams]); // 检查项目是否存在 useEffect(() => { async function checkProject() { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}`); if (!response.ok) { if (response.status === 404) { setProjectExists(false); } else { throw new Error(t('projects.fetchFailed')); } } else { setProjectExists(true); } } catch (error) { console.error('获取项目详情出错:', error); setError(error.message); } finally { setLoading(false); } } checkProject(); }, [projectId, t]); // 处理 tab 切换 const handleTabChange = (event, newValue) => { setActiveTab(newValue); // 更新 URL hash router.push(`/projects/${projectId}/settings?tab=${newValue}`); }; if (loading) { return ( ); } if (!projectExists) { return ( {t('projects.notExist')} ); } if (error) { return ( {error} ); } return ( {activeTab === TABS.BASIC && } {activeTab === TABS.MODEL && } {activeTab === TABS.TASK && } {activeTab === TABS.PROMPTS && } ); } ================================================ FILE: app/projects/[projectId]/tasks/page.js ================================================ 'use client'; import React, { useState, useEffect } from 'react'; import { Box, Typography, Container, LinearProgress, Paper } from '@mui/material'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import TaskIcon from '@mui/icons-material/Task'; import { toast } from 'sonner'; import TaskFilters from '@/components/tasks/TaskFilters'; import TasksTable from '@/components/tasks/TasksTable'; export default function TasksPage({ params }) { const { projectId } = params; const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [tasks, setTasks] = useState([]); const [statusFilter, setStatusFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [totalCount, setTotalCount] = useState(0); const processingTasks = tasks.filter(task => task.status === 0 && task.totalCount > 0); const totalProgressCount = processingTasks.reduce((sum, task) => sum + task.totalCount, 0); const completedProgressCount = processingTasks.reduce((sum, task) => sum + task.completedCount, 0); const overallProgress = totalProgressCount > 0 ? Math.round((completedProgressCount / totalProgressCount) * 100) : 0; const fetchTasks = async () => { if (!projectId) return; try { setLoading(true); let url = `/api/projects/${projectId}/tasks/list`; const queryParams = []; if (statusFilter !== 'all') { queryParams.push(`status=${statusFilter}`); } if (typeFilter !== 'all') { queryParams.push(`taskType=${typeFilter}`); } queryParams.push(`page=${page}`); queryParams.push(`limit=${rowsPerPage}`); if (queryParams.length > 0) { url += `?${queryParams.join('&')}`; } const response = await axios.get(url); if (response.data?.code === 0) { setTasks(response.data.data || []); setTotalCount(response.data.total || response.data.data?.length || 0); } } catch (error) { console.error('Failed to fetch tasks:', error); toast.error(t('tasks.fetchFailed')); } finally { setLoading(false); } }; useEffect(() => { fetchTasks(); const intervalId = setInterval(() => { if (statusFilter === 'all' || statusFilter === '0') { fetchTasks(); } }, 5000); return () => clearInterval(intervalId); }, [projectId, statusFilter, typeFilter, page, rowsPerPage]); const handleDeleteTask = async taskId => { if (!confirm(t('tasks.confirmDelete'))) return; try { const response = await axios.delete(`/api/projects/${projectId}/tasks/${taskId}`); if (response.data?.code === 0) { toast.success(t('tasks.deleteSuccess')); fetchTasks(); } else { toast.error(t('tasks.deleteFailed')); } } catch (error) { console.error('Failed to delete task:', error); toast.error(t('tasks.deleteFailed')); } }; const handleAbortTask = async taskId => { if (!confirm(t('tasks.confirmAbort'))) return; try { const response = await axios.patch(`/api/projects/${projectId}/tasks/${taskId}`, { status: 3, note: t('tasks.status.aborted') }); if (response.data?.code === 0) { toast.success(t('tasks.abortSuccess')); fetchTasks(); } else { toast.error(t('tasks.abortFailed')); } } catch (error) { console.error('Failed to abort task:', error); toast.error(t('tasks.abortFailed')); } }; const handleChangePage = (event, newPage) => { setPage(newPage); }; const handleChangeRowsPerPage = event => { setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); }; return ( {t('tasks.title')} {processingTasks.length > 0 && ( {t('tasks.pending', { count: processingTasks.length })} - {completedProgressCount}/{totalProgressCount} ( {overallProgress}%) )} ); } ================================================ FILE: app/projects/[projectId]/text-split/page.js ================================================ 'use client'; import axios from 'axios'; import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Container, Box, Tabs, Tab, IconButton, Collapse, Dialog, DialogContent, DialogTitle, Typography, LinearProgress, CircularProgress } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import FullscreenIcon from '@mui/icons-material/Fullscreen'; import CloseIcon from '@mui/icons-material/Close'; import FileUploader from '@/components/text-split/FileUploader'; import FileList from '@/components/text-split/components/FileList'; import DeleteConfirmDialog from '@/components/text-split/components/DeleteConfirmDialog'; import PdfSettings from '@/components/text-split/PdfSettings'; import ChunkList from '@/components/text-split/ChunkList'; import DomainAnalysis from '@/components/text-split/DomainAnalysis'; import useTaskSettings from '@/hooks/useTaskSettings'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; import useChunks from './useChunks'; import useQuestionGeneration from './useQuestionGeneration'; import useDataCleaning from './useDataCleaning'; import useEvalGeneration from './useEvalGeneration'; import useFileProcessing from './useFileProcessing'; import useFileProcessingStatus from '@/hooks/useFileProcessingStatus'; import { toast } from 'sonner'; export default function TextSplitPage({ params }) { const { t } = useTranslation(); const theme = useTheme(); const { projectId } = params; const [activeTab, setActiveTab] = useState(0); const [renderedTab, setRenderedTab] = useState(0); const [tabSwitching, setTabSwitching] = useState(false); const tabSwitchTimerRef = useRef(null); const { taskSettings } = useTaskSettings(projectId); const [pdfStrategy, setPdfStrategy] = useState('default'); const [questionFilter, setQuestionFilter] = useState('all'); // 'all', 'generated', 'ungenerated' const [selectedViosnModel, setSelectedViosnModel] = useState(''); const selectedModelInfo = useAtomValue(selectedModelInfoAtom); const { taskFileProcessing, task } = useFileProcessingStatus(); const [currentPage, setCurrentPage] = useState(1); const [uploadedFiles, setUploadedFiles] = useState({ data: [], total: 0 }); const [searchFileName, setSearchFileName] = useState(''); const [showLoadingBar, setShowLoadingBar] = useState(false); // 娑撳﹣绱堕崠鍝勭厵閻ㄥ嫬鐫嶅鈧?閹舵ê褰旈悩鑸碘偓? const [uploaderExpanded, setUploaderExpanded] = useState(true); // 閺傚洨灏為崚妤勩€?FileList)鐏炴洜銇氱€电鐦藉鍡欏Ц閹? const [fileListDialogOpen, setFileListDialogOpen] = useState(false); // 娴h法鏁ら懛顏勭暰娑斿“ooks const { chunks, tocData, loading, fetchChunks, handleDeleteChunk, handleEditChunk, updateChunks, setLoading } = useChunks(projectId, questionFilter); // 閼惧嘲褰囬弬鍥︽閸掓銆? const fetchUploadedFiles = async (page = currentPage, fileName = searchFileName) => { try { setLoading(true); const params = new URLSearchParams({ page: page.toString(), size: '10' }); if (fileName && fileName.trim()) { params.append('fileName', fileName.trim()); } const response = await axios.get(`/api/projects/${projectId}/files?${params}`); setUploadedFiles(response.data); } catch (error) { console.error('Error fetching files:', error); toast.error(error.message || '閼惧嘲褰囬弬鍥︽閸掓銆冩径杈Е'); } finally { setLoading(false); } }; // 閸掔娀娅庨弬鍥︽绾喛顓荤€电鐦藉鍡欏Ц閹? const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [fileToDelete, setFileToDelete] = useState(null); // 閹垫挸绱戦崚鐘绘珟绾喛顓荤€电鐦藉? const openDeleteConfirm = (fileId, fileName) => { setFileToDelete({ fileId, fileName }); setDeleteConfirmOpen(true); }; // 閸忔娊妫撮崚鐘绘珟绾喛顓荤€电鐦藉? const closeDeleteConfirm = () => { setDeleteConfirmOpen(false); setFileToDelete(null); }; // 绾喛顓婚崚鐘绘珟閺傚洣娆? const confirmDeleteFile = async () => { if (!fileToDelete) return; try { setLoading(true); closeDeleteConfirm(); await axios.delete(`/api/projects/${projectId}/files/${fileToDelete.fileId}`); await fetchUploadedFiles(); fetchChunks(); toast.success( t('textSplit.deleteSuccess', { fileName: fileToDelete.fileName }) || `删除 ${fileToDelete.fileName} 成功` ); } catch (error) { console.error('删除文件出错:', error); toast.error(error.message || '删除文件失败'); } finally { setLoading(false); setFileToDelete(null); } }; const { handleGenerateQuestions } = useQuestionGeneration(projectId, taskSettings); const { handleDataCleaning } = useDataCleaning(projectId, taskSettings); const { handleGenerateEvalQuestions } = useEvalGeneration(projectId); const { handleFileProcessing } = useFileProcessing(projectId); // 文本块数据刷新:初始化 + 文件处理任务状态变化 useEffect(() => { fetchChunks('all'); }, [fetchChunks, taskFileProcessing]); // 文件列表刷新:文件分页、搜索关键词变化时触发 useEffect(() => { fetchUploadedFiles(currentPage, searchFileName); }, [projectId, currentPage, searchFileName]); useEffect(() => { let timerId; if (loading) { timerId = setTimeout(() => setShowLoadingBar(true), 180); } else { setShowLoadingBar(false); } return () => { if (timerId) clearTimeout(timerId); }; }, [loading]); useEffect(() => { return () => { if (tabSwitchTimerRef.current) { clearTimeout(tabSwitchTimerRef.current); } }; }, []); const handleTabChange = (event, newValue) => { if (newValue === activeTab) return; setActiveTab(newValue); setTabSwitching(true); if (tabSwitchTimerRef.current) { clearTimeout(tabSwitchTimerRef.current); } const switchContent = () => { setRenderedTab(newValue); tabSwitchTimerRef.current = null; if (typeof window !== 'undefined') { window.requestAnimationFrame(() => setTabSwitching(false)); } else { setTabSwitching(false); } }; if (typeof window !== 'undefined') { window.requestAnimationFrame(() => { tabSwitchTimerRef.current = setTimeout(switchContent, 80); }); } else { switchContent(); } }; /** * 鐎甸€涚瑐娴肩姴鎮楅惃鍕瀮娴犳儼绻樼悰灞筋槱閻? */ const handleUploadSuccess = async (fileNames, pdfFiles, domainTreeAction) => { try { await handleFileProcessing(fileNames, pdfStrategy, selectedViosnModel, domainTreeAction); location.reload(); } catch (error) { toast.error('File upload failed' + error.message || ''); } }; // 閸栧懓顥婇悽鐔稿灇闂傤噣顣介惃鍕槱閻炲棗鍤遍弫? const onGenerateQuestions = async chunkIds => { await handleGenerateQuestions(chunkIds, selectedModelInfo, fetchChunks); }; // 閸栧懓顥婇弫鐗堝祦濞撳懏绀傞惃鍕槱閻炲棗鍤遍弫? const onDataCleaning = async chunkIds => { await handleDataCleaning(chunkIds, selectedModelInfo, fetchChunks); }; // 閸栧懓顥婇悽鐔稿灇濞村鐦庢0妯兼窗閻ㄥ嫬顦╅悶鍡楀毐閺? const onGenerateEvalQuestions = async chunkId => { await handleGenerateEvalQuestions(chunkId, selectedModelInfo, () => { // 閹存劕濮涢崥搴″煕閺傛澘鍨悰? fetchChunks(); }); }; useEffect(() => { const url = new URL(window.location.href); if (questionFilter !== 'all') { url.searchParams.set('filter', questionFilter); } else { url.searchParams.delete('filter'); } window.history.replaceState({}, '', url); fetchChunks(questionFilter); }, [questionFilter]); const handleSelected = array => { if (array.length > 0) { axios.post(`/api/projects/${projectId}/chunks`, { array }).then(response => { updateChunks(response.data); }); } else { fetchChunks(); } }; return ( {/* 閺傚洣娆㈡稉濠佺炊缂佸嫪娆?*/} setUploaderExpanded(!uploaderExpanded)} sx={{ bgcolor: 'background.paper', boxShadow: 1, mr: uploaderExpanded ? 1 : 0 // 鐏炴洖绱戦弮鑸靛瘻闁筋喕绠i梻瀵告殌閻愬綊妫跨捄? }} size="small" > {uploaderExpanded ? : } {/* 閺傚洨灏為崚妤勩€冮幍鈺佺潔閹稿鎸抽敍灞肩矌閸︺劋绗傞柈銊ュ隘閸╃喎鐫嶅鈧弮鑸垫▔缁€?*/} {uploaderExpanded && ( setFileListDialogOpen(true)} sx={{ bgcolor: 'background.paper', boxShadow: 1 }} size="small" title={t('textSplit.expandFileList') || '扩展文件列表'} > )} {/* 閺嶅洨顒锋い?*/} {/* 閺呴缚鍏橀崚鍡楀閺嶅洨顒烽崘鍛啇 */} {tabSwitching ? ( {t('common.loading')} ) : ( <> {renderedTab === 0 && ( )} {renderedTab === 1 && } )} {/* 閸旂姾娴囨稉顓℃寢閻?*/} {showLoadingBar && ( {t('textSplit.loading')} )} {/* 婢跺嫮鎮婃稉顓℃寢閻?*/} {/* 閺佺増宓佸〒鍛鏉╂稑瀹抽拏娆戝 */} {/* 閺傚洣娆㈡径鍕倞鏉╂稑瀹抽拏娆戝 */} {/* 閺傚洣娆㈤崚鐘绘珟绾喛顓荤€电鐦藉?*/} {/* 閺傚洨灏為崚妤勩€冪€电鐦藉?*/} setFileListDialogOpen(false)} maxWidth="lg" fullWidth sx={{ '& .MuiDialog-paper': { bgcolor: 'background.default' } }} > {t('textSplit.fileList')} setFileListDialogOpen(false)} aria-label="close"> {/* 濮濄倕顦╂径宥囨暏 FileUploader 缂佸嫪娆㈡稉顓犳畱 FileList 闁劌鍨?*/} {/* 閺傚洣娆㈤崚妤勩€冮崘鍛啇 */} handleSelected(array)} onDeleteFile={(fileId, fileName) => openDeleteConfirm(fileId, fileName)} projectId={projectId} currentPage={currentPage} onPageChange={(page, fileName) => { if (fileName !== undefined) { // 閹兼粎鍌ㄩ弮鑸垫纯閺傜増鎮崇槐銏犲彠闁款喛鐦濋崪宀勩€夐惍? setSearchFileName(fileName); setCurrentPage(page); } else { // 缂堝銆夐弮璺哄涧閺囧瓨鏌婃い鐢电垳 setCurrentPage(page); } }} onRefresh={fetchUploadedFiles} // 娴肩娀鈧帒鍩涢弬鏉垮毐閺? isFullscreen={true} // 閸︺劌顕拠婵囶攱娑擃厾些闂勩倝鐝惔锕傛閸? /> ); } ================================================ FILE: app/projects/[projectId]/text-split/useChunks.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; /** * 文本块管理的自定义Hook * @param {string} projectId - 项目ID * @param {string} [currentFilter='all'] - 当前筛选条件 * @returns {Object} - 文本块状态和操作方法 */ export default function useChunks(projectId, currentFilter = 'all') { const { t } = useTranslation(); const [chunks, setChunks] = useState([]); const [tocData, setTocData] = useState(''); const [loading, setLoading] = useState(false); /** * 获取文本块列表 * @param {string} filter - 筛选条件 */ const fetchChunks = useCallback( async (filter = 'all') => { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}/split?filter=${filter}`); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.fetchChunksFailed')); } const data = await response.json(); setChunks(data.chunks || []); // 如果有文件结果,处理详细信息 if (data.toc) { console.log(t('textSplit.fileResultReceived'), data.fileResult); // 如果有目录结构,设置目录数据 setTocData(data.toc); } } catch (error) { toast.error(error.message); } finally { setLoading(false); } }, [projectId, t, setLoading, setChunks, setTocData] ); /** * 处理删除文本块 * @param {string} chunkId - 文本块ID */ const handleDeleteChunk = useCallback( async chunkId => { try { const response = await fetch(`/api/projects/${projectId}/chunks/${encodeURIComponent(chunkId)}`, { method: 'DELETE' }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.deleteChunkFailed')); } // 更新文本块列表 setChunks(prev => prev.filter(chunk => chunk.id !== chunkId)); } catch (error) { toast.error(error.message); } }, [projectId, t] ); /** * 处理文本块编辑 * @param {string} chunkId - 文本块ID * @param {string} newContent - 新内容 */ const handleEditChunk = useCallback( async (chunkId, newContent) => { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}/chunks/${encodeURIComponent(chunkId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: newContent }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.editChunkFailed')); } // 更新成功后使用当前筛选条件刷新文本块列表 // 直接从 URL 获取当前筛选参数,确保获取到的是最新的值 const url = new URL(window.location.href); const filterParam = url.searchParams.get('filter') || 'all'; await fetchChunks(filterParam); toast.success(t('textSplit.editChunkSuccess')); } catch (error) { toast.error(error.message); } finally { setLoading(false); } }, [projectId, t, fetchChunks] ); /** * 设置文本块列表 * @param {Array} data - 新的文本块列表 */ const updateChunks = useCallback(data => { setChunks(data); }, []); /** * 添加新的文本块 * @param {Array} newChunks - 新的文本块列表 */ const addChunks = useCallback(newChunks => { setChunks(prev => { const updatedChunks = [...prev]; newChunks.forEach(chunk => { if (!updatedChunks.find(c => c.id === chunk.id)) { updatedChunks.push(chunk); } }); return updatedChunks; }); }, []); /** * 设置TOC数据 * @param {string} toc - TOC数据 */ const updateTocData = useCallback(toc => { if (toc) { setTocData(toc); } }, []); return { chunks, tocData, loading, fetchChunks, handleDeleteChunk, handleEditChunk, updateChunks, addChunks, updateTocData, setLoading }; } ================================================ FILE: app/projects/[projectId]/text-split/useDataCleaning.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import i18n from '@/lib/i18n'; import request from '@/lib/util/request'; import { toast } from 'sonner'; export default function useDataCleaning(projectId) { const { t } = useTranslation(); const [processing, setProcessing] = useState(false); const [progress, setProgress] = useState({ total: 0, completed: 0, percentage: 0, cleanedCount: 0 }); const resetProgress = useCallback(() => { setTimeout(() => { setProgress({ total: 0, completed: 0, percentage: 0, cleanedCount: 0 }); }, 500); }, []); const handleDataCleaning = useCallback( async (chunkIds, selectedModelInfo, fetchChunks) => { try { if (!chunkIds || chunkIds.length === 0) return; if (!selectedModelInfo) { throw new Error(t('textSplit.selectModelFirst')); } setProcessing(true); if (chunkIds.length === 1) { const chunkId = chunkIds[0]; const currentLanguage = i18n.language === 'zh-CN' ? '中文' : 'en'; const response = await request(`/api/projects/${projectId}/chunks/${chunkId}/clean`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: selectedModelInfo, language: currentLanguage }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.dataCleaningFailed', { chunkId })); } const data = await response.json(); toast.success( t('textSplit.dataCleaningSuccess', { originalLength: data.originalLength, cleanedLength: data.cleanedLength }) ); if (fetchChunks) fetchChunks(); return; } const response = await request(`/api/projects/${projectId}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskType: 'data-cleaning', modelInfo: selectedModelInfo, language: i18n.language, detail: '批量数据清洗任务', note: { chunkIds } }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('tasks.createFailed')); } const data = await response.json(); if (data?.code !== 0) { throw new Error(data?.message || t('tasks.createFailed')); } toast.success(`${t('tasks.createSuccess')},${t('tasks.title')}查看进度`); } catch (error) { toast.error(error.message); } finally { setProcessing(false); resetProgress(); } }, [projectId, t, resetProgress] ); return { processing, progress, setProgress, setProcessing, handleDataCleaning, resetProgress }; } ================================================ FILE: app/projects/[projectId]/text-split/useEvalGeneration.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import i18n from '@/lib/i18n'; import request from '@/lib/util/request'; import { toast } from 'sonner'; /** * 测评题目生成的自定义Hook * @param {string} projectId - 项目ID * @returns {Object} - 测评题目生成状态和操作方法 */ export default function useEvalGeneration(projectId) { const { t } = useTranslation(); const [generating, setGenerating] = useState({}); /** * 为单个文本块生成测评题目 * @param {string} chunkId - 文本块ID * @param {Object} selectedModelInfo - 选定的模型信息 * @param {Function} onSuccess - 成功回调 */ const handleGenerateEvalQuestions = useCallback( async (chunkId, selectedModelInfo, onSuccess) => { try { // 检查模型信息 if (!selectedModelInfo) { throw new Error(t('textSplit.selectModelFirst')); } // 设置生成状态 setGenerating(prev => ({ ...prev, [chunkId]: true })); // 获取当前语言环境 const currentLanguage = i18n.language === 'zh-CN' ? 'zh-CN' : 'en'; // 调用API生成测评题目 const response = await request( `/api/projects/${projectId}/chunks/${encodeURIComponent(chunkId)}/eval-questions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: selectedModelInfo, language: currentLanguage }) } ); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.generateEvalQuestionsFailed')); } const data = await response.json(); // 显示成功消息 toast.success( t('textSplit.evalQuestionsGeneratedSuccess', { total: data.total, defaultValue: `成功生成 ${data.total} 道测评题目` }) ); // 调用成功回调 if (onSuccess) { onSuccess(data); } } catch (error) { console.error('Error generating eval questions:', error); toast.error(error.message || t('textSplit.generateEvalQuestionsFailed')); } finally { // 清除生成状态 setGenerating(prev => { const newState = { ...prev }; delete newState[chunkId]; return newState; }); } }, [projectId, t] ); return { generating, handleGenerateEvalQuestions }; } ================================================ FILE: app/projects/[projectId]/text-split/useFileProcessing.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { selectedModelInfoAtom } from '@/lib/store'; import { useAtomValue } from 'jotai/index'; import { toast } from 'sonner'; import i18n from '@/lib/i18n'; import axios from 'axios'; /** * 文件处理的自定义Hook * @param {string} projectId - 项目ID * @returns {Object} - 文件处理状态和操作方法 */ export default function useFileProcessing(projectId) { const { t } = useTranslation(); const [fileProcessing, setFileProcessing] = useState(false); const [progress, setProgress] = useState({ total: 0, completed: 0, percentage: 0, questionCount: 0 }); const model = useAtomValue(selectedModelInfoAtom); /** * 重置进度状态 */ const resetProgress = useCallback(() => { setTimeout(() => { setProgress({ total: 0, completed: 0, percentage: 0, questionCount: 0 }); }, 1000); // 延迟重置,让用户看到完成的进度 }, []); /** * 处理文件 * @param {Array} files - 文件列表 * @param {string} pdfStrategy - PDF处理策略 * @param {string} selectedViosnModel - 选定的视觉模型 */ const handleFileProcessing = useCallback( async (files, pdfStrategy, selectedViosnModel, domainTreeAction) => { try { const currentLanguage = i18n.language === 'zh-CN' ? '中文' : 'en'; //获取到视觉策略要使用的模型 const availableModels = JSON.parse(localStorage.getItem('modelConfigList')); const vsionModel = availableModels.find(m => m.id === selectedViosnModel); const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'file-processing', modelInfo: model, language: currentLanguage, detail: '文件处理任务', note: { vsionModel, projectId, fileList: files, strategy: pdfStrategy, domainTreeAction } }); if (response.data?.code !== 0) { throw new Error(t('textSplit.pdfProcessingFailed') + (response.data?.error || '')); } //提示后台任务进行中 toast.success(t('textSplit.pdfProcessingToast')); } catch (error) { toast.error(t('textSplit.pdfProcessingFailed') + error.message || ''); } }, [projectId, t, resetProgress, model] ); return { fileProcessing, progress, setFileProcessing, setProgress, handleFileProcessing, resetProgress }; } ================================================ FILE: app/projects/[projectId]/text-split/useQuestionGeneration.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import i18n from '@/lib/i18n'; import request from '@/lib/util/request'; import { toast } from 'sonner'; export default function useQuestionGeneration(projectId) { const { t } = useTranslation(); const [processing, setProcessing] = useState(false); const [progress, setProgress] = useState({ total: 0, completed: 0, percentage: 0, questionCount: 0 }); const resetProgress = useCallback(() => { setTimeout(() => { setProgress({ total: 0, completed: 0, percentage: 0, questionCount: 0 }); }, 500); }, []); const handleGenerateQuestions = useCallback( async (chunkIds, selectedModelInfo, fetchChunks) => { try { if (!chunkIds || chunkIds.length === 0) return; if (!selectedModelInfo) { throw new Error(t('textSplit.selectModelFirst')); } setProcessing(true); if (chunkIds.length === 1) { const chunkId = chunkIds[0]; const currentLanguage = i18n.language === 'zh-CN' ? '中文' : 'en'; const response = await request(`/api/projects/${projectId}/chunks/${chunkId}/questions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: selectedModelInfo, language: currentLanguage, enableGaExpansion: true }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.generateQuestionsFailed', { chunkId })); } const data = await response.json(); toast.success( t('textSplit.questionsGeneratedSuccess', { total: data.total }) ); if (fetchChunks) fetchChunks(); return; } const response = await request(`/api/projects/${projectId}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskType: 'question-generation', modelInfo: selectedModelInfo, language: i18n.language, detail: '批量生成问题任务', note: { chunkIds } }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('tasks.createFailed')); } const data = await response.json(); if (data?.code !== 0) { throw new Error(data?.message || t('tasks.createFailed')); } toast.success(`${t('tasks.createSuccess')},${t('tasks.title')}查看进度`); } catch (error) { toast.error(error.message); } finally { setProcessing(false); resetProgress(); } }, [projectId, t, resetProgress] ); return { processing, progress, setProgress, setProcessing, handleGenerateQuestions, resetProgress }; } ================================================ FILE: commitlint.config.mjs ================================================ export default { extends: ['@commitlint/config-conventional'] }; ================================================ FILE: components/ExportDatasetDialog.js ================================================ // ExportDatasetDialog.js 组件 import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Tabs, Tab } from '@mui/material'; // 导入拆分后的组件 import LocalExportTab from './export/LocalExportTab'; import LlamaFactoryTab from './export/LlamaFactoryTab'; import HuggingFaceTab from './export/HuggingFaceTab'; const ExportDatasetDialog = ({ open, onClose, onExport, projectId }) => { const { t } = useTranslation(); const [formatType, setFormatType] = useState('alpaca'); const [systemPrompt, setSystemPrompt] = useState(''); const [reasoningLanguage, setReasoningLanguage] = useState(''); const [confirmedOnly, setConfirmedOnly] = useState(false); const [fileFormat, setFileFormat] = useState('json'); const [includeCOT, setIncludeCOT] = useState(true); const [currentTab, setCurrentTab] = useState(0); // alpaca 格式特有的设置 const [alpacaFieldType, setAlpacaFieldType] = useState('instruction'); // 'instruction' 或 'input' const [customInstruction, setCustomInstruction] = useState(''); // 当选择 input 时使用的自定义 instruction const [customFields, setCustomFields] = useState({ questionField: 'instruction', answerField: 'output', cotField: 'complexCOT', // 添加思维链字段名 includeLabels: false, includeChunk: false, // 添加是否包含chunk字段 questionOnly: false // 添加仅导出问题选项 }); const handleFileFormatChange = event => { setFileFormat(event.target.value); }; const handleFormatChange = event => { setFormatType(event.target.value); // 根据格式类型设置默认字段名 if (event.target.value === 'alpaca') { setCustomFields({ ...customFields, questionField: 'instruction', answerField: 'output' }); } else if (event.target.value === 'sharegpt') { setCustomFields({ ...customFields, questionField: 'content', answerField: 'content' }); } else if (event.target.value === 'multilingual-thinking') { setCustomFields({ ...customFields, questionField: 'content', answerField: 'content' }); } else if (event.target.value === 'custom') { // 自定义格式保持当前值 } }; const handleSystemPromptChange = event => { setSystemPrompt(event.target.value); }; const handleReasoningLanguageChange = event => { setReasoningLanguage(event.target.value); }; const handleConfirmedOnlyChange = event => { setConfirmedOnly(event.target.checked); }; // 新增处理函数 const handleIncludeCOTChange = event => { setIncludeCOT(event.target.checked); }; const handleCustomFieldChange = field => event => { setCustomFields({ ...customFields, [field]: event.target.value }); }; const handleIncludeLabelsChange = event => { setCustomFields({ ...customFields, includeLabels: event.target.checked }); }; const handleIncludeChunkChange = event => { setCustomFields({ ...customFields, includeChunk: event.target.checked }); }; const handleQuestionOnlyChange = event => { setCustomFields({ ...customFields, questionOnly: event.target.checked }); }; // 处理 Alpaca 字段类型变更 const handleAlpacaFieldTypeChange = event => { setAlpacaFieldType(event.target.value); }; // 处理自定义 instruction 变更 const handleCustomInstructionChange = event => { setCustomInstruction(event.target.value); }; const handleExport = options => { // 如果 LocalExportTab 传入了完整的导出配置(例如平衡导出),直接使用该配置 if (options && typeof options === 'object' && options.balanceMode) { onExport(options); return; } // 否则使用当前对话框内的状态组装导出配置 onExport({ formatType, systemPrompt, reasoningLanguage, confirmedOnly, fileFormat, includeCOT, alpacaFieldType, // 添加 alpaca 字段类型 customInstruction, // 添加自定义 instruction customFields: formatType === 'custom' ? customFields : undefined }); }; return ( {t('export.title')} setCurrentTab(newValue)} aria-label="export tabs"> {/* 第一个标签页:本地导出 */} {currentTab === 0 && ( )} {/* 第二个标签页:Llama Factory */} {currentTab === 1 && ( )} {/* 第三个标签页:HuggingFace */} {currentTab === 2 && ( )} ); }; export default ExportDatasetDialog; ================================================ FILE: components/ExportProgressDialog.js ================================================ 'use client'; import React from 'react'; import { Dialog, DialogTitle, DialogContent, Box, LinearProgress, Typography, CircularProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; const ExportProgressDialog = ({ open, progress }) => { const { t } = useTranslation(); const { processed, total, hasMore } = progress; // 计算进度百分比 const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; return ( {t('datasets.exportProgress')} {/* 圆形进度指示器 */} {`${percentage}%`} {/* 进度详情 */} {t('datasets.exportingData')} {t('datasets.processedCount', { processed, total })} {/* 线性进度条 */} {/* 状态提示 */} {hasMore ? t('datasets.exportInProgress') : t('datasets.exportFinalizing')} ); }; export default ExportProgressDialog; ================================================ FILE: components/I18nProvider.js ================================================ 'use client'; import { useEffect } from 'react'; import i18n from '@/lib/i18n'; import { I18nextProvider } from 'react-i18next'; export default function I18nProvider({ children }) { useEffect(() => { // 确保i18n只在客户端初始化 if (typeof window !== 'undefined') { // 这里可以添加任何客户端特定的i18n初始化逻辑 } }, []); return {children}; } ================================================ FILE: components/LanguageSwitcher.js ================================================ 'use client'; import { useTranslation } from 'react-i18next'; import { IconButton, Menu, MenuItem, Tooltip, useTheme, Typography } from '@mui/material'; import { useState } from 'react'; import TranslateIcon from '@mui/icons-material/Translate'; export default function LanguageSwitcher() { const { i18n, t } = useTranslation(); const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const languages = [ { code: 'en', label: t('language.english', 'English'), short: 'EN' }, { code: 'zh-CN', label: t('language.chineseSimplified', '简体中文'), short: '中文' }, { code: 'tr', label: t('language.turkish', 'Türkçe'), short: 'TR' }, { code: 'pt-BR', label: t('language.portugues', 'Portugues'), short: 'pt-BR' } ]; const normalizedCurrentLanguage = i18n.language && String(i18n.language).toLowerCase().startsWith('zh') ? 'zh-CN' : i18n.language; const currentLanguage = languages.find(lang => lang.code === normalizedCurrentLanguage) || languages[0]; const handleClick = event => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; const handleLanguageChange = langCode => { i18n.changeLanguage(langCode); handleClose(); }; return ( <> {currentLanguage.short} {languages.map(lang => ( handleLanguageChange(lang.code)} selected={normalizedCurrentLanguage === lang.code} > {lang.short} {lang.label} ))} ); } ================================================ FILE: components/ModelSelect.js ================================================ 'use client'; import React, { useEffect, useState, useMemo } from 'react'; import { FormControl, Select, MenuItem, useTheme, ListSubheader, Box, IconButton, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useAtom, useAtomValue } from 'jotai/index'; import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store'; import axios from 'axios'; import SmartToyIcon from '@mui/icons-material/SmartToy'; import { getModelIcon } from '@/lib/util/modelIcon'; export default function ModelSelect({ size = 'small', minWidth = 50, projectId, minHeight = 36, required = false, onError }) { const theme = useTheme(); const { t } = useTranslation(); const models = useAtomValue(modelConfigListAtom); const [selectedModelInfo, setSelectedModelInfo] = useAtom(selectedModelInfoAtom); const [selectedModel, setSelectedModel] = useState(() => { if (selectedModelInfo && selectedModelInfo.id) { return selectedModelInfo.id; } return ''; }); const [error, setError] = useState(false); const [isHovered, setIsHovered] = useState(false); const [isOpen, setIsOpen] = useState(false); const handleModelChange = event => { if (!event || !event.target) return; const newModelId = event.target.value; if (error) { setError(false); if (onError) onError(false); } if (!newModelId) { setSelectedModel(''); setSelectedModelInfo(null); updateDefaultModel(null); } else { const selectedModelObj = models.find(model => model.id === newModelId); if (selectedModelObj) { setSelectedModel(newModelId); setSelectedModelInfo(selectedModelObj); updateDefaultModel(newModelId); } else { setSelectedModel(newModelId); setSelectedModelInfo({ id: newModelId }); } } setTimeout(() => { setIsHovered(false); setIsOpen(false); }, 200); }; const updateDefaultModel = async id => { const res = await axios.put(`/api/projects/${projectId}`, { projectId, defaultModelConfigId: id }); if (res.status === 200) { console.log('更新成功'); } }; const validateModel = () => { if (required && (!selectedModel || selectedModel === '')) { setError(true); if (onError) onError(true); return false; } return true; }; useEffect(() => { if (selectedModelInfo && selectedModelInfo.id) { setSelectedModel(selectedModelInfo.id); } else { setSelectedModel(''); } }, [selectedModelInfo]); useEffect(() => { if (required) { validateModel(); } }, [required]); const renderSelectedValue = value => { if (!value) { return ( {t('models.unselectedModel', t('playground.selectModelFirst'))} ); } const selectedModelObj = models.find(model => model.id === value); if (!selectedModelObj) return null; return ( { e.target.src = '/imgs/models/default.svg'; }} /> {selectedModelObj.modelName} ); }; const currentModelIcon = useMemo(() => { const selectedModelObj = models.find(model => model.id === selectedModel); return selectedModelObj ? getModelIcon(selectedModelObj.modelName, selectedModelObj.modelId) : null; }, [selectedModel, models]); const shouldShowFullSelect = isHovered || isOpen; return ( setIsHovered(true)} onMouseLeave={() => { setIsHovered(false); if (!isOpen) { setIsOpen(false); } }} sx={{ position: 'relative', display: 'flex', alignItems: 'center' }} > {!shouldShowFullSelect && ( m.id === selectedModel)?.modelName : t('playground.selectModelFirst', '请先选择模型') } placement="bottom" > {currentModelIcon ? ( { e.target.src = '/imgs/models/default.svg'; }} /> ) : ( )} )} ); } ================================================ FILE: components/Navbar/ActionButtons.js ================================================ 'use client'; import React from 'react'; import { Box, IconButton, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'; import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import GitHubIcon from '@mui/icons-material/GitHub'; import BarChartIcon from '@mui/icons-material/BarChart'; import LanguageSwitcher from '../LanguageSwitcher'; import UpdateChecker from '../UpdateChecker'; import TaskIcon from '../TaskIcon'; import ModelSelect from '../ModelSelect'; import * as styles from './styles'; /** * ActionButtons 组件 * 右侧操作区按钮:语言切换、主题切换、文档、GitHub、更新检查 */ export default function ActionButtons({ theme, resolvedTheme, toggleTheme, isProjectDetail, currentProject, onActionAreaEnter }) { const { t, i18n } = useTranslation(); const isZhLanguage = String(i18n.language || '') .toLowerCase() .startsWith('zh'); return ( {isProjectDetail && } {isProjectDetail && } {/* Monitoring Dashboard - Only visible on Home page */} {!isProjectDetail && ( )} {/* Language Switcher - Always visible */} {/* Theme Toggle - Always visible */} {resolvedTheme === 'dark' ? ( ) : ( )} {/* Documentation - Hide below xl */} {/* GitHub - Hide at larger tablet screens and below */} {/* Update Checker - Hide below xl */} ); } ================================================ FILE: components/Navbar/ContextBar.js ================================================ 'use client'; import React, { useState } from 'react'; import { Box, Chip, Typography, useTheme, Menu, MenuItem, ListItemIcon, ListItemText, Paper, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; import { useSetAtom } from 'jotai'; import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store'; import { toast } from 'sonner'; import axios from 'axios'; // Icons import FolderIcon from '@mui/icons-material/Folder'; import CheckIcon from '@mui/icons-material/Check'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; // 样式 import * as styles from './contextBarStyles'; export default function ContextBar({ projects = [], currentProjectId, onMouseLeave }) { const { t } = useTranslation(); const theme = useTheme(); const router = useRouter(); // State const [projectMenuAnchor, setProjectMenuAnchor] = useState(null); // Jotai atoms const setConfigList = useSetAtom(modelConfigListAtom); const setSelectedModelInfo = useSetAtom(selectedModelInfoAtom); // Get current project const currentProject = projects.find(p => p.id === currentProjectId); // Handlers const handleProjectMenuOpen = event => { event.preventDefault(); setProjectMenuAnchor(event.currentTarget); }; const handleProjectMenuClose = () => { setProjectMenuAnchor(null); // 菜单关闭时,如果提供了 onMouseLeave 回调,则调用它 if (onMouseLeave) { onMouseLeave(); } }; const handleProjectChange = async newProjectId => { handleProjectMenuClose(); try { // Fetch model config for new project const response = await axios.get(`/api/projects/${newProjectId}/model-config`); setConfigList(response.data.data); if (response.data.defaultModelConfigId) { const defaultModel = response.data.data.find(item => item.id === response.data.defaultModelConfigId); setSelectedModelInfo(defaultModel || null); } else { setSelectedModelInfo(null); } // Navigate to the new project's text-split page router.push(`/projects/${newProjectId}/text-split`); } catch (error) { console.error('Error switching project:', error); toast.error(t('common.error', 'Error switching project')); } }; if (!currentProjectId || !currentProject) { return null; } return ( {/* Project Selector */} {t('common.project', 'Project')}: } label={ {currentProject?.name || t('projects.selectProject', 'Select Project')} } onClick={handleProjectMenuOpen} clickable variant="outlined" size="medium" sx={styles.getProjectChipStyles(theme)} aria-label={t('projects.selectProject', 'Select project')} aria-controls={projectMenuAnchor ? 'project-menu' : undefined} aria-haspopup="true" aria-expanded={Boolean(projectMenuAnchor)} /> {/* Project Menu */} {t('projects.allProjects', 'All Projects')} {projects.map((project, index) => ( handleProjectChange(project.id)} selected={project.id === currentProjectId} role="menuitem" sx={styles.getMenuItemStyles(theme)} > {project.id === currentProjectId ? ( ) : ( )} ))} ); } ================================================ FILE: components/Navbar/DesktopMenus.js ================================================ 'use client'; import React from 'react'; import { Menu, MenuItem, ListItemIcon, ListItemText, Divider } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Link from 'next/link'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import ImageIcon from '@mui/icons-material/Image'; import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined'; import ChatIcon from '@mui/icons-material/Chat'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined'; import StorageIcon from '@mui/icons-material/Storage'; import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined'; import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; import VisibilityIcon from '@mui/icons-material/Visibility'; import * as styles from './styles'; /** * DesktopMenus 缂備礁瀚▎? * 婵℃鐭傚鎵博椤栨稑浜鹃柛瀣矎瑜板秹宕¢弴顏嗙闁告牕鎳庨幆鍫ュ极閻楀牆绁︽繝褎鍔戦埀顑跨劍閺嗙喖骞戦鈧▔锔剧不閿涘嫭鍊為柕鍡曠劍濞叉寧寰勫顐ょ憦濞戞搩浜hぐ宥夊础? */ export default function DesktopMenus({ theme, menuState, isMenuOpen, handleMenuClose, currentProject, onNavigateStart }) { const { t } = useTranslation(); return ( <> {/* 闁轰胶澧楀畵浣糕攦閹邦垰缍呴柛?*/} { onNavigateStart?.(); handleMenuClose(); }} role="menuitem" sx={styles.getMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} role="menuitem" sx={styles.getMenuItemStyles(theme)} > {/* 闁轰胶澧楀畵渚€姊块崱娆樺悁闁荤偛妫滆ぐ宥夊础?*/} { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > {/* 閻犲洤瀚崣濠囨嚕濠婂啫绀?*/} { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > {/* 闁哄洦娼欓ˇ鍧楁嚕濠婂啫绀?*/} { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > { onNavigateStart?.(); handleMenuClose(); }} sx={styles.getSimpleMenuItemStyles(theme)} > ); } ================================================ FILE: components/Navbar/Logo.js ================================================ 'use client'; import React from 'react'; import { Box, Typography, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import * as styles from './styles'; /** * Logo 组件 * 显示应用 Logo 和标题,支持点击跳转到首页 */ export default function Logo({ theme }) { const { t } = useTranslation(); return ( { e.preventDefault(); window.location.href = '/'; }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.location.href = '/'; } }} > Easy DataSet ); } ================================================ FILE: components/Navbar/MobileDrawer.js ================================================ 'use client'; import React from 'react'; import { Drawer, Box, Typography, IconButton, Tooltip, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Collapse } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Link from 'next/link'; import CloseIcon from '@mui/icons-material/Close'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import TokenOutlinedIcon from '@mui/icons-material/TokenOutlined'; import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined'; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import ChatIcon from '@mui/icons-material/Chat'; import ImageIcon from '@mui/icons-material/Image'; import StorageIcon from '@mui/icons-material/Storage'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import GitHubIcon from '@mui/icons-material/GitHub'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined'; import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; import VisibilityIcon from '@mui/icons-material/Visibility'; import UpdateChecker from '../UpdateChecker'; import * as styles from './styles'; /** * MobileDrawer 组件 * 移动端抽屉菜单,包含所有导航项 */ export default function MobileDrawer({ theme, drawerOpen, toggleDrawer, expandedMenu, toggleMobileSubmenu, currentProject, onNavigateStart }) { const { t, i18n } = useTranslation(); const handleNavigateStart = () => { onNavigateStart?.(); toggleDrawer(); }; return ( {/* Drawer Header */} {t('common.navigation', 'Navigation')} {/* Drawer Menu List */} {/* 数据源菜单 */} toggleMobileSubmenu('source')} aria-expanded={expandedMenu === 'source'} aria-controls="source-submenu" role="menuitem" sx={styles.getDrawerListItemButtonStyles(theme)} > {expandedMenu === 'source' ? : } {/* 数据蒸馏 */} {/* 问题管理 */} {/* 数据集管理 */} toggleMobileSubmenu('dataset')} role="menuitem" aria-expanded={expandedMenu === 'dataset'} aria-controls="dataset-submenu" sx={styles.getDrawerListItemButtonStyles(theme)} > {expandedMenu === 'dataset' ? : } {/* 评估菜单 */} toggleMobileSubmenu('eval')} role="menuitem" aria-expanded={expandedMenu === 'eval'} aria-controls="eval-submenu" sx={styles.getDrawerListItemButtonStyles(theme)} > {expandedMenu === 'eval' ? : } {/* 更多菜单 */} toggleMobileSubmenu('more')} role="menuitem" aria-expanded={expandedMenu === 'more'} aria-controls="more-submenu" sx={styles.getDrawerListItemButtonStyles(theme)} > {expandedMenu === 'more' ? : } {/* Utilities Section */} { window.open('https://github.com/ConardLi/easy-dataset', '_blank'); toggleDrawer(); }} sx={styles.getDrawerListItemButtonStyles(theme)} > ); } ================================================ FILE: components/Navbar/NavigationTabs.js ================================================ 'use client'; import React from 'react'; import { Box, Tabs, Tab } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Link from 'next/link'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import TokenOutlinedIcon from '@mui/icons-material/TokenOutlined'; import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined'; import DatasetOutlinedIcon from '@mui/icons-material/DatasetOutlined'; import AssessmentOutlinedIcon from '@mui/icons-material/AssessmentOutlined'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import * as styles from './styles'; /** * NavigationTabs 组件 * 桌面端导航 Tabs,包含数据源、数据蒸馏、问题管理、数据集管理、更多等 Tab */ export default function NavigationTabs({ theme, pathname, currentProject, handleMenuOpen, handleMenuClose, onNavigateStart }) { const { t } = useTranslation(); // 计算当前 Tab 值 const getCurrentTabValue = () => { if (pathname.includes('/settings') || pathname.includes('/playground') || pathname.includes('/datasets-sq')) { return 'more'; } if (pathname.includes('/eval-datasets') || pathname.includes('/eval-tasks')) { return 'eval'; } if (pathname.includes('/datasets') || pathname.includes('/multi-turn') || pathname.includes('/image-datasets')) { return 'datasets'; } if (pathname.includes('/text-split') || pathname.includes('/images')) { return 'source'; } return pathname; }; return ( } iconPosition="start" label={ {t('common.dataSource')} } value="source" onMouseEnter={e => handleMenuOpen(e, 'source')} sx={styles.tabIconWrapperStyles} /> } iconPosition="start" label={t('distill.title')} value={`/projects/${currentProject}/distill`} component={Link} href={`/projects/${currentProject}/distill`} onClick={() => { onNavigateStart?.(); handleMenuClose(); }} sx={styles.tabIconWrapperStyles} /> } iconPosition="start" label={t('questions.title')} value={`/projects/${currentProject}/questions`} component={Link} href={`/projects/${currentProject}/questions`} onClick={() => { onNavigateStart?.(); handleMenuClose(); }} sx={styles.tabIconWrapperStyles} /> } iconPosition="start" label={ {t('datasets.management')} } value="datasets" onMouseEnter={e => handleMenuOpen(e, 'dataset')} sx={styles.tabIconWrapperStyles} /> } iconPosition="start" label={ {t('eval.title')} } value="eval" onMouseEnter={e => handleMenuOpen(e, 'eval')} sx={styles.tabIconWrapperStyles} /> } iconPosition="start" label={ {t('common.more')} } onMouseEnter={e => handleMenuOpen(e, 'more')} value="more" sx={styles.tabIconWrapperStyles} /> ); } ================================================ FILE: components/Navbar/contextBarStyles.js ================================================ /** * ContextBar 组件样式 * 包含项目选择器和模型选择器的所有样式 */ import { alpha } from '@mui/material'; // ===== 主容器样式 ===== export const getContextBarPaperStyles = theme => ({ position: 'absolute', top: 64, // Below navbar left: 0, zIndex: 1100, borderBottom: 1, borderColor: 'divider', bgcolor: theme.palette.mode === 'dark' ? alpha(theme.palette.background.paper, 0.9) : alpha(theme.palette.background.paper, 0.95), backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', px: { xs: 2, sm: 3, md: 4 }, py: { xs: 1.25, sm: 1.5 }, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: theme.palette.mode === 'dark' ? '0 1px 3px rgba(0, 0, 0, 0.2)' : '0 1px 3px rgba(0, 0, 0, 0.08)', width: 'auto' }); export const contextBarContainerStyles = { display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 1.5, md: 2 }, flexWrap: 'nowrap', width: 'auto' }; // ===== 选择器容器样式 ===== export const selectorContainerStyles = { display: 'flex', alignItems: 'center', gap: 1 }; // ===== 标签样式 ===== export const labelTypographyStyles = { color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', fontSize: '0.7rem', display: { xs: 'none', sm: 'block' } }; // ===== Chip 内部文本样式 ===== export const chipLabelBoxStyles = { display: 'flex', alignItems: 'center', gap: 0.5 }; export const chipTextStyles = { fontWeight: 600, fontSize: { xs: '0.8rem', sm: '0.875rem' }, maxWidth: { xs: '80px', sm: '120px', md: '150px' }, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }; export const chipArrowStyles = { ml: -0.25, flexShrink: 0 }; // ===== 项目选择器 Chip 样式 ===== export const getProjectChipStyles = theme => ({ minWidth: 'auto', maxWidth: { xs: '120px', sm: '150px', md: '180px' }, height: { xs: 32, sm: 36 }, minWidth: { xs: 120, sm: 150, md: 180 }, maxWidth: { xs: '120px', sm: '150px', md: '180px' }, borderRadius: 1.5, borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)', bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { borderColor: 'primary.main', bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)', transform: 'translateY(-1px)', boxShadow: theme.palette.mode === 'dark' ? '0 4px 12px rgba(144, 202, 249, 0.15)' : '0 4px 12px rgba(25, 118, 210, 0.15)' }, '&:active': { transform: 'translateY(0)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 }, '& .MuiChip-icon': { color: 'text.primary', fontSize: '1.1rem', ml: 0.5, flexShrink: 0 }, '& .MuiChip-label': { px: 1, overflow: 'hidden' } }); // ===== 模型选择器 Chip 样式 ===== export const getModelChipStyles = theme => ({ minWidth: { xs: 140, sm: 160, md: 180 }, maxWidth: { xs: 200, sm: 280, md: 360 }, height: { xs: 36, sm: 40 }, borderRadius: 2, bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.15)' : 'rgba(25, 118, 210, 0.08)', transform: 'translateY(-1px)', boxShadow: theme.palette.mode === 'dark' ? '0 4px 12px rgba(144, 202, 249, 0.25)' : '0 4px 12px rgba(25, 118, 210, 0.25)' }, '&:active': { transform: 'translateY(0)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 }, '& .MuiChip-icon': { color: 'primary.main', fontSize: '1.1rem', ml: 0.5, flexShrink: 0 }, '& .MuiChip-label': { px: 1, overflow: 'hidden' } }); // ===== 菜单样式 ===== export const getMenuPaperStyles = theme => ({ mt: 1, minWidth: 240, maxWidth: 400, maxHeight: 400, borderRadius: 2, overflow: 'visible', bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'background.paper', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', boxShadow: theme.palette.mode === 'dark' ? '0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)' : '0 12px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)', '&::before': { content: '""', display: 'block', position: 'absolute', top: -6, left: 24, width: 12, height: 12, bgcolor: 'background.paper', transform: 'translateY(-50%) rotate(45deg)', zIndex: 0, borderLeft: `1px solid ${theme.palette.divider}`, borderTop: `1px solid ${theme.palette.divider}` } }); export const menuListPropsStyles = { dense: false, sx: { py: 1 } }; // ===== 菜单标题样式 ===== export const menuHeaderTypographyStyles = { px: 2, py: 1, display: 'block', color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', fontSize: '0.7rem' }; // ===== 菜单项样式 ===== export const getMenuItemStyles = theme => ({ mx: 1, borderRadius: 1.5, minHeight: 44, py: 1.25, px: 1.5, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.08)' : 'rgba(25, 118, 210, 0.04)', transform: 'translateX(4px)' }, '&.Mui-selected': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.16)' : 'rgba(25, 118, 210, 0.08)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(144, 202, 249, 0.24)' : 'rgba(25, 118, 210, 0.12)' } } }); export const menuItemIconStyles = { minWidth: 36 }; export const getMenuItemTextPrimaryProps = isSelected => ({ variant: 'body2', fontWeight: isSelected ? 600 : 400 }); export const menuItemTextSecondaryProps = { variant: 'caption', sx: { fontSize: '0.7rem' } }; // ===== 模型图标样式 ===== export const modelIconStyles = { width: 20, height: 20, objectFit: 'contain', flexShrink: 0, borderRadius: '50%', mr: 1 }; // ===== 分组标题样式 ===== export const getProviderHeaderStyles = theme => ({ pl: 2, color: theme.palette.text.secondary, fontWeight: 600, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.5px', mt: 1, mb: 0.5 }); ================================================ FILE: components/Navbar/index.js ================================================ 'use client'; import React, { useState, useRef, useEffect } from 'react'; import { AppBar, Toolbar, Box, IconButton, useTheme as useMuiTheme, Tooltip, useMediaQuery, LinearProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { usePathname, useRouter } from 'next/navigation'; import { useTheme } from 'next-themes'; import MenuIcon from '@mui/icons-material/Menu'; // 样式 import * as styles from './styles'; // 子组件 import Logo from './Logo'; import ActionButtons from './ActionButtons'; import NavigationTabs from './NavigationTabs'; import MobileDrawer from './MobileDrawer'; import DesktopMenus from './DesktopMenus'; import ContextBar from './ContextBar'; export default function Navbar({ projects = [], currentProject }) { const { t } = useTranslation(); const pathname = usePathname(); const router = useRouter(); const theme = useMuiTheme(); const { resolvedTheme, setTheme } = useTheme(); const isProjectDetail = pathname.includes('/projects/') && pathname.split('/').length > 3; // 检测移动设备 const isMobile = useMediaQuery(theme.breakpoints.down('lg')); // 移动端抽屉状态 const [drawerOpen, setDrawerOpen] = useState(false); const [expandedMenu, setExpandedMenu] = useState(null); // 桌面端菜单状态 const [menuState, setMenuState] = useState({ anchorEl: null, menuType: null }); const [navLoading, setNavLoading] = useState(false); const navLoadingTimeoutRef = useRef(null); // ContextBar 悬浮状态 const [contextBarHovered, setContextBarHovered] = useState(false); const contextTriggerRef = useRef(null); const contextBarRef = useRef(null); useEffect(() => { if (!contextBarHovered) return; const handleOutsideClick = event => { if (contextBarRef.current?.contains(event.target)) return; if (contextTriggerRef.current?.contains(event.target)) return; const projectMenuEl = document.getElementById('project-menu'); if (projectMenuEl?.contains(event.target)) return; setContextBarHovered(false); }; document.addEventListener('pointerdown', handleOutsideClick, true); return () => { document.removeEventListener('pointerdown', handleOutsideClick, true); }; }, [contextBarHovered]); useEffect(() => { if (!menuState.menuType) return; const handleOutsideMenuClick = event => { if (menuState.anchorEl?.contains(event.target)) return; if (event.target?.closest?.('.MuiMenu-root')) return; setMenuState({ anchorEl: null, menuType: null }); }; document.addEventListener('pointerdown', handleOutsideMenuClick, true); return () => { document.removeEventListener('pointerdown', handleOutsideMenuClick, true); }; }, [menuState.anchorEl, menuState.menuType]); useEffect(() => { setNavLoading(false); if (navLoadingTimeoutRef.current) { clearTimeout(navLoadingTimeoutRef.current); navLoadingTimeoutRef.current = null; } }, [pathname]); useEffect(() => { if (!isProjectDetail || !currentProject) return; const prefetchRoutes = [ `/projects/${currentProject}/multi-turn`, `/projects/${currentProject}/eval-datasets`, `/projects/${currentProject}/eval-tasks` ]; prefetchRoutes.forEach(route => router.prefetch(route)); }, [router, currentProject, isProjectDetail]); useEffect(() => { return () => { if (navLoadingTimeoutRef.current) { clearTimeout(navLoadingTimeoutRef.current); } }; }, []); const handleNavigateStart = () => { setNavLoading(true); if (navLoadingTimeoutRef.current) { clearTimeout(navLoadingTimeoutRef.current); } navLoadingTimeoutRef.current = setTimeout(() => { setNavLoading(false); navLoadingTimeoutRef.current = null; }, 12000); }; const handleMenuOpen = (event, menuType) => { setMenuState({ anchorEl: event.currentTarget, menuType }); }; const handleMenuClose = () => { setMenuState({ anchorEl: null, menuType: null }); }; const isMenuOpen = menuType => menuState.menuType === menuType; const toggleDrawer = () => { setDrawerOpen(!drawerOpen); setExpandedMenu(null); }; const toggleMobileSubmenu = menuType => { setExpandedMenu(expandedMenu === menuType ? null : menuType); }; const toggleTheme = () => { setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); }; return ( <> {/* 左侧: 汉堡菜单(移动端) + Logo */} isProjectDetail && setContextBarHovered(true)} > {/* 汉堡菜单按钮 */} {isProjectDetail && isMobile && ( )} {/* Logo 组件 */} {/* 中间导航 - 仅桌面端 */} {isProjectDetail && !isMobile && ( )} {/* 右侧操作区 */} {isProjectDetail && ( )} {/* ContextBar - 在 Logo 或 ContextBar 悬浮时展示 */} {isProjectDetail && contextBarHovered && ( setContextBarHovered(false)}> setContextBarHovered(false)} /> )} {/* 移动端抽屉组件 */} {/* 桌面端菜单组件 */} ); } ================================================ FILE: components/Navbar/styles.js ================================================ /** * Navbar 组件样式配置 */ // AppBar 样式 export const getAppBarStyles = theme => ({ borderBottom: `1px solid ${theme.palette.divider}`, bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'primary.main', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: theme.palette.mode === 'dark' ? '0 1px 3px rgba(0, 0, 0, 0.3)' : '0 1px 3px rgba(0, 0, 0, 0.1)' }); // Toolbar 样式 export const toolbarStyles = { height: '64px', minHeight: '64px !important', display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: { xs: 2, sm: 2, md: 3 }, gap: 2 }; // Logo 容器样式 export const logoContainerStyles = { display: 'flex', alignItems: 'center', gap: 1.5, flexShrink: 0 }; // 汉堡菜单按钮样式 export const getHamburgerButtonStyles = theme => ({ color: theme.palette.mode === 'dark' ? 'inherit' : 'white', minWidth: 44, minHeight: 44, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { transform: 'scale(1.1)', bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.15)' }, '&:active': { transform: 'scale(0.95)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`, outlineOffset: 2 } }); // Logo 链接样式 export const getLogoLinkStyles = theme => ({ display: 'flex', alignItems: 'center', cursor: 'pointer', textDecoration: 'none', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', borderRadius: 1.5, px: 0.5, '&:hover': { opacity: 0.85, transform: 'translateY(-1px)' }, '&:active': { transform: 'translateY(0)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`, outlineOffset: 2 } }); // Logo 图片样式 export const logoImageStyles = { width: 32, height: 32, mr: 1.5, transition: 'transform 0.2s ease' }; // Logo 文字样式 export const getLogoTextStyles = theme => ({ fontWeight: 700, letterSpacing: '-0.5px', fontSize: '1.125rem', display: { xs: 'none', md: 'block' }, color: 'white', ...(theme.palette.mode === 'dark' && { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }) }); // 中间导航容器样式 export const navContainerStyles = { flexGrow: 1, display: 'flex', justifyContent: 'center', mx: { lg: 1, xl: 3 }, overflow: 'hidden' }; // Tabs 样式 export const getTabsStyles = theme => ({ minHeight: '64px', '& .MuiTab-root': { minWidth: 100, maxWidth: 180, fontSize: '0.875rem', fontWeight: 500, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', color: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(255, 255, 255, 1)', px: 2, minHeight: '64px', textTransform: 'none', letterSpacing: '0.3px', '&:hover': { color: 'white', bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.15)' } }, '& .Mui-selected': { color: 'white !important', fontWeight: 600, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.2)' }, '& .MuiTabs-indicator': { height: 3, borderRadius: '3px 3px 0 0', backgroundColor: theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white', boxShadow: theme.palette.mode === 'dark' ? '0 0 8px rgba(103, 126, 234, 0.5)' : '0 0 8px rgba(255, 255, 255, 0.5)' } }); // Tab 图标包装器样式 export const tabIconWrapperStyles = { '& .MuiTab-iconWrapper': { mr: 1 } }; // 右侧操作区容器样式 export const actionAreaStyles = { display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }; // 文档/GitHub 按钮样式 export const getIconButtonStyles = theme => ({ display: { xs: 'none', xl: 'flex' }, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.2)', color: theme.palette.mode === 'dark' ? 'inherit' : 'white', borderRadius: 1.5, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.35)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.mode === 'dark' ? theme.palette.secondary.main : 'white'}`, outlineOffset: 2 } }); // Drawer Paper 样式 export const getDrawerPaperStyles = theme => ({ width: { xs: '85vw', sm: 320 }, maxWidth: 380, bgcolor: theme.palette.mode === 'dark' ? 'background.paper' : 'background.default', backgroundImage: theme.palette.mode === 'dark' ? 'linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05))' : 'none', boxShadow: theme.palette.mode === 'dark' ? '0 8px 32px rgba(0, 0, 0, 0.6)' : '0 8px 32px rgba(0, 0, 0, 0.15)' }); // Drawer 头部样式 export const getDrawerHeaderStyles = theme => ({ p: 2.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${theme.palette.divider}`, minHeight: 64 }); // Drawer 关闭按钮样式 export const getDrawerCloseButtonStyles = theme => ({ minWidth: 44, minHeight: 44, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { transform: 'rotate(90deg)', bgcolor: 'action.hover' }, '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 } }); // Drawer 列表样式 export const drawerListStyles = { pt: 1, px: 1 }; // Drawer 列表项按钮样式 export const getDrawerListItemButtonStyles = theme => ({ borderRadius: '8px', minHeight: 48, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.12)' : 'rgba(103, 126, 234, 0.08)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: -2 } }); // Drawer 子菜单容器样式 export const getDrawerSubmenuContainerStyles = theme => ({ bgcolor: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)', borderRadius: '8px', my: 0.5 }); // Drawer 子菜单项样式 export const getDrawerSubmenuItemStyles = theme => ({ pl: 4, mx: 1, borderRadius: '8px', minHeight: 44, py: 1.5, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.08)' : 'rgba(103, 126, 234, 0.05)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: -2 } }); // Drawer 工具区域样式 export const getDrawerUtilitiesStyles = theme => ({ mt: 'auto', pt: 2, borderTop: `1px solid ${theme.palette.divider}` }); // Menu Paper 样式 export const getMenuPaperStyles = theme => ({ mt: 1.5, borderRadius: '12px', minWidth: 220, overflow: 'visible', bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', boxShadow: theme.palette.mode === 'dark' ? '0 12px 40px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.1)' : '0 12px 40px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.05)', '&::before': { content: '""', display: 'block', position: 'absolute', top: 0, right: '50%', width: 12, height: 12, bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)', transform: 'translateY(-50%) translateX(50%) rotate(45deg)', zIndex: 0, boxShadow: theme.palette.mode === 'dark' ? '-2px -2px 4px rgba(0, 0, 0, 0.3)' : '-2px -2px 4px rgba(0, 0, 0, 0.1)' } }); // Menu 列表样式 export const menuListStyles = { py: 1.5 }; // Menu 项样式 export const getMenuItemStyles = theme => ({ mx: 1, borderRadius: '8px', py: 1.25, minHeight: 44, transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.15)' : 'rgba(103, 126, 234, 0.1)', transform: 'translateX(4px)' }, '&:focus-visible': { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: -2 } }); // Dataset/More Menu Paper 样式(简化版) export const getSimpleMenuPaperStyles = theme => ({ mt: 1.5, borderRadius: '12px', minWidth: 220, overflow: 'visible', bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)', backdropFilter: 'blur(20px)', boxShadow: theme.palette.mode === 'dark' ? '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)' : '0 8px 32px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)', '&::before': { content: '""', display: 'block', position: 'absolute', top: 0, right: '50%', width: 12, height: 12, bgcolor: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.98)' : 'rgba(255, 255, 255, 0.98)', transform: 'translateY(-50%) translateX(50%) rotate(45deg)', zIndex: 0 } }); // 简化 Menu 列表样式 export const simpleMenuListStyles = { py: 1 }; // 简化 Menu 项样式 export const getSimpleMenuItemStyles = theme => ({ mx: 0.75, borderRadius: '8px', py: 1, transition: 'all 0.15s ease', '&:hover': { bgcolor: theme.palette.mode === 'dark' ? 'rgba(103, 126, 234, 0.15)' : 'rgba(103, 126, 234, 0.1)', transform: 'translateX(4px)' } }); // ListItemIcon 样式 export const listItemIconStyles = { minWidth: 40 }; export const smallListItemIconStyles = { minWidth: 36 }; // ListItemText 样式 export const listItemTextStyles = { fontWeight: 600, fontSize: '0.95rem' }; export const smallListItemTextStyles = { fontSize: '0.9rem', fontWeight: 500 }; // 图标颜色样式 export const getIconColorStyles = theme => ({ color: theme.palette.mode === 'dark' ? 'primary.light' : 'primary.main' }); export const getPrimaryIconColorStyles = theme => ({ color: theme.palette.primary.main }); ================================================ FILE: components/TaskIcon.js ================================================ 'use client'; import React, { useState, useEffect } from 'react'; import { Badge, IconButton, Tooltip, CircularProgress, Menu, MenuItem, Divider, ListItemIcon } from '@mui/material'; import TaskAltIcon from '@mui/icons-material/TaskAlt'; import ListAltIcon from '@mui/icons-material/ListAlt'; import QuizIcon from '@mui/icons-material/Quiz'; import AssessmentIcon from '@mui/icons-material/Assessment'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; import { useTranslation } from 'react-i18next'; import { useRouter, usePathname } from 'next/navigation'; import useFileProcessingStatus from '@/hooks/useFileProcessingStatus'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; import axios from 'axios'; import { toast } from 'sonner'; export default function TaskIcon({ projectId, theme }) { const { t, i18n } = useTranslation(); const router = useRouter(); const pathname = usePathname(); const [tasks, setTasks] = useState([]); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const isMenuOpen = Boolean(menuAnchorEl); const selectedModel = useAtomValue(selectedModelInfoAtom); const { setTaskFileProcessing, setTask } = useFileProcessingStatus(); const fetchPendingTasks = async () => { if (!projectId) return; try { const response = await axios.get(`/api/projects/${projectId}/tasks/list?status=0`); if (response.data?.code === 0) { const pendingTasks = response.data.data || []; setTasks(pendingTasks); const hasActiveFileTask = pendingTasks.some( task => task.projectId === projectId && task.taskType === 'file-processing' ); setTaskFileProcessing(hasActiveFileTask); if (hasActiveFileTask) { const activeTask = pendingTasks.find( task => task.projectId === projectId && task.taskType === 'file-processing' ); try { const detailInfo = JSON.parse(activeTask?.detail || '{}'); setTask(detailInfo); } catch { setTask(null); } } } } catch (error) { console.error('Failed to fetch task list:', error); } }; useEffect(() => { if (!projectId) return; fetchPendingTasks(); const intervalId = setInterval(() => { fetchPendingTasks(); }, 10000); return () => { clearInterval(intervalId); }; }, [projectId]); useEffect(() => { setMenuAnchorEl(null); }, [pathname]); const handleOpenTaskList = () => { setMenuAnchorEl(null); router.push(`/projects/${projectId}/tasks`); }; const handleMenuOpen = event => { if (isMenuOpen) { setMenuAnchorEl(null); return; } setMenuAnchorEl(event.currentTarget); }; const handleMenuClose = () => { setMenuAnchorEl(null); }; const createBatchTask = async (taskType, detail) => { if (!projectId || !selectedModel?.id) { toast.error(t('textSplit.selectModelFirst')); return; } try { const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType, modelInfo: selectedModel, language: i18n.language, detail }); if (response.data?.code === 0) { toast.success(t('tasks.createSuccess')); await fetchPendingTasks(); } else { toast.error(`${t('tasks.createFailed')}: ${response.data?.message || ''}`); } } catch (error) { console.error('Create batch task failed:', error); toast.error(`${t('tasks.createFailed')}: ${error.message}`); } }; const handleCreateAutoQuestionTask = async () => { await createBatchTask('question-generation', '批量生成问题任务'); handleMenuClose(); }; const handleCreateAutoEvalTask = async () => { await createBatchTask('eval-generation', '批量生成评估集任务'); handleMenuClose(); }; const handleCreateAutoCleaningTask = async () => { await createBatchTask('data-cleaning', '批量数据清洗任务'); handleMenuClose(); }; const renderTaskIcon = () => { const pendingTasks = tasks.filter(task => task.status === 0); if (pendingTasks.length > 0) { return ( ); } return ; }; const getTooltipText = () => { const pendingTasks = tasks.filter(task => task.status === 0); if (pendingTasks.length > 0) { return t('tasks.pending', { count: pendingTasks.length }); } return t('tasks.completed'); }; if (!projectId) return null; return ( <> {renderTaskIcon()} {t('tasks.title')} {t('textSplit.autoGenerateQuestions', { defaultValue: '自动提取问题' })} {t('textSplit.autoEvalGeneration', { defaultValue: '自动生成评估集' })} {t('textSplit.autoDataCleaning', { defaultValue: '自动数据清洗' })} ); } ================================================ FILE: components/ThemeRegistry.js ================================================ 'use client'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; // 导入字体 import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; import '@fontsource/inter/500.css'; import '@fontsource/inter/600.css'; import '@fontsource/inter/700.css'; import '@fontsource/jetbrains-mono/400.css'; import '@fontsource/jetbrains-mono/500.css'; // 创建主题配置 const getTheme = mode => { // 主色调 const mainBlue = '#2A5CAA'; const darkGray = '#2D2D2D'; // 辅助色 - 数据可视化色谱 const dataVizColors = [ '#6366F1', // 紫蓝色 '#10B981', // 绿色 '#F59E0B', // 琥珀色 '#EC4899', // 粉色 '#8B5CF6', // 紫色 '#3B82F6' // 蓝色 ]; // 状态色 const successColor = '#10B981'; // 翡翠绿 const warningColor = '#F59E0B'; // 琥珀色 const errorColor = '#EF4444'; // 珊瑚红 // 渐变色 const gradientPrimary = 'linear-gradient(90deg, #2A5CAA 0%, #8B5CF6 100%)'; // 根据模式调整颜色 return createTheme({ palette: { mode, primary: { main: mainBlue, dark: '#1E4785', light: '#4878C6', contrastText: '#FFFFFF' }, secondary: { main: '#8B5CF6', dark: '#7039F2', light: '#A78BFA', contrastText: '#FFFFFF' }, error: { main: errorColor, dark: '#DC2626', light: '#F87171' }, warning: { main: warningColor, dark: '#D97706', light: '#FBBF24' }, success: { main: successColor, dark: '#059669', light: '#34D399' }, background: { default: mode === 'dark' ? '#121212' : '#F8F9FA', paper: mode === 'dark' ? '#1E1E1E' : '#FFFFFF', subtle: mode === 'dark' ? '#2A2A2A' : '#F3F4F6' }, text: { primary: mode === 'dark' ? '#F3F4F6' : darkGray, secondary: mode === 'dark' ? '#9CA3AF' : '#6B7280', disabled: mode === 'dark' ? '#4B5563' : '#9CA3AF' }, divider: mode === 'dark' ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)', dataViz: dataVizColors, gradient: { primary: gradientPrimary } }, typography: { fontFamily: '"Inter", "HarmonyOS Sans", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', fontSize: 14, fontWeightLight: 300, fontWeightRegular: 400, fontWeightMedium: 500, fontWeightBold: 600, h1: { fontSize: '2rem', // 32px fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.01em' }, h2: { fontSize: '1.5rem', // 24px fontWeight: 600, lineHeight: 1.3, letterSpacing: '-0.005em' }, h3: { fontSize: '1.25rem', // 20px fontWeight: 600, lineHeight: 1.4 }, h4: { fontSize: '1.125rem', // 18px fontWeight: 600, lineHeight: 1.4 }, h5: { fontSize: '1rem', // 16px fontWeight: 600, lineHeight: 1.5 }, h6: { fontSize: '0.875rem', // 14px fontWeight: 600, lineHeight: 1.5 }, body1: { fontSize: '1rem', // 16px lineHeight: 1.5 }, body2: { fontSize: '0.875rem', // 14px lineHeight: 1.5 }, caption: { fontSize: '0.75rem', // 12px lineHeight: 1.5 }, code: { fontFamily: '"JetBrains Mono", monospace', fontSize: '0.875rem' } }, shape: { borderRadius: 8 }, spacing: 8, // 基础间距单位为8px components: { MuiCssBaseline: { styleOverrides: { body: { scrollbarWidth: 'thin', scrollbarColor: mode === 'dark' ? '#4B5563 transparent' : '#9CA3AF transparent', '&::-webkit-scrollbar': { width: '8px', height: '8px' }, '&::-webkit-scrollbar-track': { background: 'transparent' }, '&::-webkit-scrollbar-thumb': { background: mode === 'dark' ? '#4B5563' : '#9CA3AF', borderRadius: '4px' } }, // 确保代码块使用 JetBrains Mono 字体 'code, pre': { fontFamily: '"JetBrains Mono", monospace' }, // 自定义渐变文本的通用样式 '.gradient-text': { background: gradientPrimary, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text', textFillColor: 'transparent' } } }, MuiButton: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, borderRadius: '8px', padding: '6px 16px' }, contained: { boxShadow: 'none', '&:hover': { boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)' } }, containedPrimary: { background: mainBlue, '&:hover': { backgroundColor: '#1E4785' } }, containedSecondary: { background: '#8B5CF6', '&:hover': { backgroundColor: '#7039F2' } }, outlined: { borderWidth: '1.5px', '&:hover': { borderWidth: '1.5px' } } } }, MuiAppBar: { styleOverrides: { root: { boxShadow: 'none', background: mode === 'dark' ? '#1A1A1A' : mainBlue } } }, MuiCard: { styleOverrides: { root: { borderRadius: '12px', boxShadow: mode === 'dark' ? '0px 4px 8px rgba(0, 0, 0, 0.4)' : '0px 4px 8px rgba(0, 0, 0, 0.05)' } } }, MuiPaper: { styleOverrides: { root: { borderRadius: '12px' } } }, MuiChip: { styleOverrides: { root: { borderRadius: '6px', fontWeight: 500 } } }, MuiTableHead: { styleOverrides: { root: { '& .MuiTableCell-head': { fontWeight: 600, backgroundColor: mode === 'dark' ? '#2A2A2A' : '#F3F4F6' } } } }, MuiTabs: { styleOverrides: { indicator: { height: '3px', borderRadius: '3px 3px 0 0' } } }, MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, '&.Mui-selected': { fontWeight: 600 } } } }, MuiListItemButton: { styleOverrides: { root: { borderRadius: '8px' } } }, MuiModal: { defaultProps: { disableScrollLock: true } }, MuiDialog: { defaultProps: { disableScrollLock: true } }, MuiPopover: { defaultProps: { disableScrollLock: true } }, MuiMenu: { defaultProps: { disableScrollLock: true } }, MuiDialogTitle: { styleOverrides: { root: { fontSize: '1.25rem', fontWeight: 600 } } } } }); }; export default function ThemeRegistry({ children }) { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted) { return null; } return ( {children} ); } function InnerThemeRegistry({ children }) { const { resolvedTheme } = useTheme(); const theme = getTheme(resolvedTheme === 'dark' ? 'dark' : 'light'); return ( {children} ); } ================================================ FILE: components/UpdateChecker.js ================================================ import React, { useState, useEffect } from 'react'; import { Box, Button, Snackbar, Alert, Typography, Link, CircularProgress, LinearProgress } from '@mui/material'; import UpdateIcon from '@mui/icons-material/Update'; import { useTranslation } from 'react-i18next'; const UpdateChecker = () => { const { t } = useTranslation(); const [updateAvailable, setUpdateAvailable] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [open, setOpen] = useState(false); const [checking, setChecking] = useState(false); const [downloading, setDownloading] = useState(false); const [downloadProgress, setDownloadProgress] = useState(0); const [updateDownloaded, setUpdateDownloaded] = useState(false); const [updateError, setUpdateError] = useState(null); // 检查更新 const checkForUpdates = async () => { if (!window.electron?.updater) { console.warn('Update feature is not available, possibly running in browser environment'); return; } try { setChecking(true); setUpdateError(null); const result = await window.electron.updater.checkForUpdates(); console.log('Update check result:', result); // 返回当前版本信息 if (result) { setUpdateInfo(prev => ({ ...prev, currentVersion: result.currentVersion })); } } catch (error) { console.error('Failed to check for updates:', error); // setUpdateError(error.message || 'Failed to check for updates'); } finally { setChecking(false); } }; // 下载更新 const downloadUpdate = async () => { if (!window.electron?.updater) return; try { setDownloading(true); setUpdateError(null); await window.electron.updater.downloadUpdate(); } catch (error) { console.error('下载更新失败:', error); setUpdateError(error.message || '下载更新失败'); setDownloading(false); } }; // 安装更新 const installUpdate = async () => { if (!window.electron?.updater) return; try { await window.electron.updater.installUpdate(); } catch (error) { console.error('Failed to install update:', error); // setUpdateError(error.message || 'Failed to install update'); } }; // 设置更新事件监听 useEffect(() => { if (!window.electron?.updater) return; // 有可用更新 const removeUpdateAvailable = window.electron.updater.onUpdateAvailable(info => { console.log('发现新版本:', info); setUpdateAvailable(true); setUpdateInfo(prev => ({ ...prev, ...info, releaseUrl: `https://github.com/ConardLi/easy-dataset/releases` })); setOpen(true); }); // 没有可用更新 const removeUpdateNotAvailable = window.electron.updater.onUpdateNotAvailable(() => { console.log('没有可用更新'); setUpdateAvailable(false); }); // 更新错误 const removeUpdateError = window.electron.updater.onUpdateError(error => { console.error('更新错误:', error); // setUpdateError(error); }); // 下载进度 const removeDownloadProgress = window.electron.updater.onDownloadProgress(progress => { console.log('下载进度:', progress); setDownloadProgress(progress.percent || 0); }); // 更新下载完成 const removeUpdateDownloaded = window.electron.updater.onUpdateDownloaded(info => { console.log('更新下载完成:', info); setDownloading(false); setUpdateDownloaded(true); }); // 组件挂载时检查更新 const timer = setTimeout(() => { checkForUpdates(); }, 5000); // 清理函数 return () => { clearTimeout(timer); removeUpdateAvailable(); removeUpdateNotAvailable(); removeUpdateError(); removeDownloadProgress(); removeUpdateDownloaded(); }; }, []); // 定期检查更新(每小时一次) useEffect(() => { if (!window.electron?.updater) return; const interval = setInterval( () => { checkForUpdates(); }, 60 * 60 * 1000 ); return () => clearInterval(interval); }, []); const handleClose = () => { setOpen(false); }; // 如果没有更新或者不在 Electron 环境中,不显示任何内容 if (!updateAvailable && !open) return null; return ( <> {updateAvailable && ( )} {t('update.newVersionAvailable')} {updateInfo && ( <> {t('update.currentVersion')}: {updateInfo.currentVersion} {t('update.latestVersion')}: {updateInfo.version} )} {checking && ( {t('update.checking')} )} {updateError && ( {updateError} )} {downloading && ( {t('update.downloading')}: {Math.round(downloadProgress)}% )} {/* {!downloading && !updateDownloaded ? ( ) : updateDownloaded ? ( ) : null} */} {updateInfo?.releaseUrl && ( )} ); }; export default UpdateChecker; ================================================ FILE: components/common/MessageAlert.js ================================================ 'use client'; import { Snackbar, Alert } from '@mui/material'; export default function MessageAlert({ message, onClose }) { if (!message) return null; const severity = message.severity || 'error'; const text = typeof message === 'string' ? message : message.message; return ( {text} ); } ================================================ FILE: components/conversations/ConversationContent.js ================================================ 'use client'; import { Box, Typography, Card, CardContent, Chip, TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; /** * 多轮对话内容展示和编辑组件 */ export default function ConversationContent({ messages, editMode, onMessageChange, conversation }) { const { t } = useTranslation(); // 获取角色显示信息 const getRoleDisplay = role => { switch (role) { case 'system': return { name: t('datasets.system'), color: 'default' }; case 'user': return { name: conversation?.roleA || t('datasets.user'), color: 'primary' }; case 'assistant': return { name: conversation?.roleB || t('datasets.assistant'), color: 'secondary' }; default: return { name: role, color: 'default' }; } }; return ( {t('datasets.conversationContent')} {messages.map((message, index) => { const roleInfo = getRoleDisplay(message.role); return ( {message.role !== 'system' && ( {t('datasets.round', { round: Math.floor((index + 1) / 2) + 1 })} )} {editMode ? ( onMessageChange && onMessageChange(index, e.target.value)} variant="outlined" size="small" sx={{ '& .MuiInputBase-input': { fontFamily: 'inherit', fontSize: '0.875rem', lineHeight: 1.5 } }} /> ) : ( {message.content} )} ); })} ); } ================================================ FILE: components/conversations/ConversationHeader.js ================================================ 'use client'; import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper, Tooltip } from '@mui/material'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; /** * 多轮对话详情页面的头部导航组件 */ export default function ConversationHeader({ projectId, conversationId, conversation, editMode, saving, onEdit, onSave, onCancel, onDelete, onNavigate }) { const router = useRouter(); const { t } = useTranslation(); return ( {t('datasets.conversationDetail')} {conversation && ( {conversation.scenario && ( <> {conversation.scenario} • {conversation.turnCount}/{conversation.maxTurns} 轮 )} )} {/* 翻页按钮 */} onNavigate && onNavigate('prev')}> onNavigate && onNavigate('next')}> {/* 编辑/保存按钮 */} {editMode ? ( <> ) : ( <> )} ); } ================================================ FILE: components/conversations/ConversationMetadata.js ================================================ 'use client'; import { Box, Typography, Chip, Tooltip, alpha, Paper } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; /** * 多轮对话元数据展示组件 */ export default function ConversationMetadata({ conversation }) { const { t } = useTranslation(); const theme = useTheme(); if (!conversation) return null; return ( {t('datasets.metadata')} {conversation.scenario && ( )} {conversation.roleA && ( )} {conversation.roleB && ( )} {conversation.confirmed && ( )} ); } ================================================ FILE: components/conversations/ConversationRatingSection.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, Divider, Paper, TextField } from '@mui/material'; import { toast } from 'sonner'; import StarRating from '@/components/datasets/StarRating'; import TagSelector from '@/components/datasets/TagSelector'; import NoteInput from '@/components/datasets/NoteInput'; import { useTranslation } from 'react-i18next'; /** * 多轮对话评分、标签、备注综合组件 */ export default function ConversationRatingSection({ conversation, projectId, onUpdate }) { const { t } = useTranslation(); const [availableTags, setAvailableTags] = useState([]); const [loading, setLoading] = useState(false); // 解析对话中的标签 const parseConversationTags = tagsString => { try { if (typeof tagsString === 'string' && tagsString.trim()) { return tagsString.split(/\s+/).filter(tag => tag.length > 0); } return []; } catch (e) { return []; } }; // 本地状态管理 const [localScore, setLocalScore] = useState(conversation.score || 0); const [localTags, setLocalTags] = useState(() => parseConversationTags(conversation.tags)); const [localNote, setLocalNote] = useState(conversation.note || ''); // 获取项目中已使用的标签 useEffect(() => { const fetchAvailableTags = async () => { try { const response = await fetch(`/api/projects/${projectId}/dataset-conversations/tags`); if (response.ok) { const data = await response.json(); setAvailableTags(data.tags || []); } } catch (error) { console.error('获取可用标签失败:', error); } }; if (projectId) { fetchAvailableTags(); } }, [projectId]); // 同步props中的conversation到本地状态 useEffect(() => { setLocalScore(conversation.score || 0); setLocalTags(parseConversationTags(conversation.tags)); setLocalNote(conversation.note || ''); }, [conversation]); // 更新对话元数据 const updateMetadata = async updates => { if (loading) return; // 立即更新本地状态 if (updates.score !== undefined) { setLocalScore(updates.score); } if (updates.tagsArray !== undefined) { setLocalTags(updates.tagsArray); } if (updates.note !== undefined) { setLocalNote(updates.note); } setLoading(true); try { const response = await fetch(`/api/projects/${projectId}/dataset-conversations/${conversation.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ score: updates.score, tags: updates.tags, note: updates.note }) }); if (!response.ok) { throw new Error(t('datasets.saveFailed')); } const result = await response.json(); toast.success(t('datasets.saveSuccess')); // 如果有父组件的更新回调,调用它 if (onUpdate) { onUpdate(result.data); } } catch (error) { console.error('更新对话元数据失败:', error); toast.error(error.message || t('datasets.saveFailed')); // 出错时恢复本地状态 if (updates.score !== undefined) { setLocalScore(conversation.score || 0); } if (updates.tagsArray !== undefined) { setLocalTags(parseConversationTags(conversation.tags)); } if (updates.note !== undefined) { setLocalNote(conversation.note || ''); } } finally { setLoading(false); } }; // 处理评分变更 const handleScoreChange = newScore => { updateMetadata({ score: newScore }); }; // 处理标签变更 const handleTagsChange = newTags => { const tagsString = Array.isArray(newTags) ? newTags.join(' ') : ''; updateMetadata({ tags: tagsString, tagsArray: newTags }); }; // 处理备注变更 const handleNoteChange = newNote => { updateMetadata({ note: newNote }); }; return ( {/* 评分区域 */} {t('datasets.rating')} {/* 标签区域 */} {t('datasets.customTags')} {/* 备注区域 */} {/* 确认状态 */} {/* {t('datasets.confirmationStatus')} {conversation.confirmed ? t('datasets.confirmed') : t('datasets.unconfirmed')} */} {/* AI评估 */} {conversation.aiEvaluation && ( <> {t('datasets.aiEvaluation')} {conversation.aiEvaluation} )} ); } ================================================ FILE: components/dataset-square/DatasetSearchBar.js ================================================ 'use client'; import { useState, useEffect, useRef } from 'react'; import { Box, TextField, InputAdornment, List, ListItem, ListItemButton, ListItemText, Paper, Typography, ClickAwayListener, Fade, Avatar, useTheme, alpha } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import LaunchIcon from '@mui/icons-material/Launch'; import TravelExploreIcon from '@mui/icons-material/TravelExplore'; import sites from '@/constant/sites.json'; import { useTranslation } from 'react-i18next'; export function DatasetSearchBar() { const [searchQuery, setSearchQuery] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const searchRef = useRef(null); const suggestionsRef = useRef(null); const theme = useTheme(); const { t } = useTranslation(); // 从 localStorage 加载最近搜索 useEffect(() => { const savedSearches = localStorage.getItem('recentDatasetSearches'); if (savedSearches) { try { const searches = JSON.parse(savedSearches); setRecentSearches(searches); } catch (e) { console.error('解析最近搜索失败', e); } } }, []); // 处理搜索输入变化 const handleSearchChange = event => { setSearchQuery(event.target.value); if (event.target.value) { setShowSuggestions(true); } else { setShowSuggestions(false); } }; // 处理回车搜索 const handleSearchSubmit = event => { if (event.key === 'Enter' && searchQuery.trim()) { // 默认使用第一个搜索引擎 if (sites.length > 0) { handleSuggestionClick(sites[0]); } } }; // 保存最近搜索 const saveRecentSearch = query => { if (!query.trim()) return; // 添加到最近搜索并去重 const updatedSearches = [query, ...recentSearches.filter(s => s !== query)].slice(0, 5); setRecentSearches(updatedSearches); // 保存到 localStorage try { localStorage.setItem('recentDatasetSearches', JSON.stringify(updatedSearches)); } catch (e) { console.error('保存最近搜索失败', e); } }; // 处理点击搜索建议 const handleSuggestionClick = site => { if (searchQuery.trim()) { // 根据不同网站处理搜索参数 let searchUrl = site.link; // 如果链接中不包含问号,则添加搜索参数 if (site.link.includes('huggingface.co')) { searchUrl = `${site.link}?sort=trending&search=${encodeURIComponent(searchQuery)}`; } else if (site.link.includes('kaggle.com')) { searchUrl = `${site.link}?search=${encodeURIComponent(searchQuery)}`; } else if (site.link.includes('datasetsearch.research.google.com')) { searchUrl = `${site.link}/search?query=${encodeURIComponent(searchQuery)}&src=0`; } else if (site.link.includes('paperswithcode.com')) { searchUrl = `${site.link}?q=${encodeURIComponent(searchQuery)}`; } else if (site.link.includes('modelscope.cn')) { searchUrl = `${site.link}?query=${encodeURIComponent(searchQuery)}`; } else if (site.link.includes('opendatalab.com')) { searchUrl = `${site.link}?keywords=${encodeURIComponent(searchQuery)}`; } else if (site.link.includes('tianchi.aliyun.com')) { searchUrl = `${site.link}?q=${encodeURIComponent(searchQuery)}`; } else { // 默认处理方式,在URL后添加搜索参数 searchUrl = `${site.link}${site.link.includes('?') ? '&' : '?'}search=${encodeURIComponent(searchQuery)}`; } // 保存最近搜索 saveRecentSearch(searchQuery); window.open(searchUrl, '_blank'); } setShowSuggestions(false); }; // 处理点击外部关闭建议 const handleClickAway = event => { // 确保点击的不是建议框本身 if (suggestionsRef.current && !suggestionsRef.current.contains(event.target)) { setShowSuggestions(false); } }; return ( searchQuery && setShowSuggestions(true)} InputProps={{ startAdornment: ( ), sx: { height: 56, borderRadius: 3, backgroundColor: theme.palette.mode === 'dark' ? alpha(theme.palette.background.default, 0.6) : alpha(theme.palette.background.default, 0.8), backdropFilter: 'blur(8px)', px: 2, transition: 'all 0.3s ease', boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.15)}`, '&.MuiOutlinedInput-root': { '& fieldset': { borderColor: 'transparent' }, '&:hover fieldset': { borderColor: 'transparent' }, '&.Mui-focused': { boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.3)}`, backgroundColor: theme.palette.mode === 'dark' ? alpha(theme.palette.background.paper, 0.8) : alpha(theme.palette.common.white, 0.95) }, '&.Mui-focused fieldset': { borderColor: 'transparent' } } } }} sx={{ mb: 1, '& .MuiInputBase-input': { fontSize: '1rem', fontWeight: 500, color: theme.palette.text.primary }, '& .MuiInputBase-input::placeholder': { color: alpha(theme.palette.text.primary, 0.6), opacity: 0.7 } }} /> {/* 搜索建议下拉框 - 使用绝对定位确保不被裁剪 */} {showSuggestions && searchQuery && ( {sites.slice(0, 5).map((site, index) => ( handleSuggestionClick(site)} sx={{ py: 1.5, '&:hover': { bgcolor: alpha(theme.palette.primary.main, 0.05) } }} > {t('datasetSquare.searchVia')} {site.name} Search "{searchQuery}" } /> ))} )} ); } ================================================ FILE: components/dataset-square/DatasetSiteCard.js ================================================ 'use client'; import { Card, CardActionArea, CardContent, CardMedia, Typography, Box, Chip, useTheme, alpha } from '@mui/material'; import LaunchIcon from '@mui/icons-material/Launch'; import StorageIcon from '@mui/icons-material/Storage'; import { useTranslation } from 'react-i18next'; export function DatasetSiteCard({ site }) { const { name, link, description, image, labels } = site; const theme = useTheme(); // 处理图片路径,如果没有图片则使用默认图片 const imageUrl = image || `/imgs/default-dataset.png`; const { t } = useTranslation(); // 处理卡片点击 const handleCardClick = () => { window.open(link, '_blank'); }; return ( {/* 网站截图 */} } label={t('datasetSquare.dataset')} size="small" sx={{ position: 'absolute', top: 10, right: 10, zIndex: 2, backgroundColor: alpha(theme.palette.background.paper, 0.8), backdropFilter: 'blur(4px)', border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`, '& .MuiChip-icon': { color: theme.palette.primary.main } }} /> {/* 网站信息 */} {name} {description} {/* 标签显示 */} {labels && labels.length > 0 && ( {labels.map((label, index) => ( ))} )} ); } ================================================ FILE: components/dataset-square/DatasetSiteList.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Grid, Box, Typography, Skeleton, Divider, Tabs, Tab, Fade, Chip, useTheme, alpha, Paper } from '@mui/material'; import StorageIcon from '@mui/icons-material/Storage'; import CategoryIcon from '@mui/icons-material/Category'; import StarIcon from '@mui/icons-material/Star'; import { DatasetSiteCard } from './DatasetSiteCard'; import sites from '@/constant/sites.json'; import { useTranslation } from 'react-i18next'; export function DatasetSiteList() { const [loading, setLoading] = useState(true); const theme = useTheme(); const { t } = useTranslation(); // 定义类别 const CATEGORIES = { ALL: t('datasetSquare.categories.all'), POPULAR: t('datasetSquare.categories.popular'), CHINESE: t('datasetSquare.categories.chinese'), ENGLISH: t('datasetSquare.categories.english'), RESEARCH: t('datasetSquare.categories.research'), MULTIMODAL: t('datasetSquare.categories.multimodal') }; const [activeCategory, setActiveCategory] = useState(CATEGORIES.ALL); // 模拟加载效果 useEffect(() => { const timer = setTimeout(() => { setLoading(false); }, 800); return () => clearTimeout(timer); }, []); // 处理类别切换 const handleCategoryChange = (event, newValue) => { setActiveCategory(newValue); }; // 根据当前选中的类别过滤网站 const getFilteredSites = () => { if (activeCategory === CATEGORIES.ALL) { return sites; } else if (activeCategory === CATEGORIES.POPULAR) { return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.popular'))); } else if (activeCategory === CATEGORIES.CHINESE) { return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.chinese'))); } else if (activeCategory === CATEGORIES.ENGLISH) { return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.english'))); } else if (activeCategory === CATEGORIES.RESEARCH) { return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.research'))); } else if (activeCategory === CATEGORIES.MULTIMODAL) { return sites.filter(site => site.labels && site.labels.includes(t('datasetSquare.categories.multimodal'))); } return sites; }; const filteredSites = getFilteredSites(); return ( {/* 类别选择器 */} {t('datasetSquare.categoryTitle')} } iconPosition="start" /> } iconPosition="start" /> {/* 数据集网站列表 */} {loading ? ( // 加载骨架屏 {Array.from(new Array(8)).map((_, index) => ( ))} ) : ( {/* 结果数量提示 */} {t('datasetSquare.foundResources', { count: filteredSites.length })}{' '} {activeCategory !== CATEGORIES.ALL && ( setActiveCategory(CATEGORIES.ALL)} sx={{ borderRadius: 1.5 }} /> )} {filteredSites.length > 0 ? ( {filteredSites.map((site, index) => ( ))} ) : ( {t('datasetSquare.noDatasets')} {t('datasetSquare.tryOtherCategories')} )} )} ); } ================================================ FILE: components/datasets/DatasetHeader.js ================================================ 'use client'; import { Box, Button, Divider, Typography, IconButton, CircularProgress, Paper, Tooltip } from '@mui/material'; import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import DeleteIcon from '@mui/icons-material/Delete'; import UndoIcon from '@mui/icons-material/Undo'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/navigation'; /** * 数据集详情页面的头部导航组件 */ export default function DatasetHeader({ projectId, datasetsAllCount, datasetsConfirmCount, confirming, unconfirming, currentDataset, shortcutsEnabled, setShortcutsEnabled, onNavigate, onConfirm, onUnconfirm, onDelete }) { const router = useRouter(); const { t } = useTranslation(); return ( {t('datasets.datasetDetail')} {t('datasets.stats', { total: datasetsAllCount, confirmed: datasetsConfirmCount, percentage: ((datasetsConfirmCount / datasetsAllCount) * 100).toFixed(2) })} {/* 快捷键启用选项 - 已注释掉,保持原代码结构 */} {/* {t('datasets.enableShortcuts')} ? */} onNavigate('prev')}> onNavigate('next')}> {/* 确认/取消确认按钮 */} {currentDataset.confirmed ? ( ) : ( )} ); } ================================================ FILE: components/datasets/DatasetMetadata.js ================================================ 'use client'; import { Box, Typography, Chip, Tooltip, alpha, CircularProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; import { useState } from 'react'; /** * 数据集元数据展示组件 */ export default function DatasetMetadata({ currentDataset, onViewChunk }) { const { t } = useTranslation(); const theme = useTheme(); return ( {t('datasets.metadata')} {currentDataset.questionLabel && ( )} { try { // 使用新API接口获取文本块内容 const response = await fetch( `/api/projects/${currentDataset.projectId}/chunks/name?chunkName=${encodeURIComponent(currentDataset.chunkName)}` ); if (!response.ok) { throw new Error(`获取文本块失败: ${response.statusText}`); } const chunkData = await response.json(); // 调用父组件的方法显示文本块 onViewChunk({ name: currentDataset.chunkName, content: chunkData.content }); } catch (error) { console.error('获取文本块内容失败:', error); // 即使API请求失败,也尝试调用查看方法 onViewChunk({ name: currentDataset.chunkName, content: '内容加载失败,请重试' }); } }} sx={{ cursor: 'pointer' }} /> {currentDataset.confirmed && ( )} ); } ================================================ FILE: components/datasets/DatasetRatingSection.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, Divider, Paper, Button, Stack } from '@mui/material'; import { toast } from 'sonner'; import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import StarRating from './StarRating'; import TagSelector from './TagSelector'; import NoteInput from './NoteInput'; import EvalVariantDialog from './EvalVariantDialog'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; /** * 数据集评分、标签、备注综合组件 */ export default function DatasetRatingSection({ dataset, projectId, onUpdate, currentDataset }) { const { t, i18n } = useTranslation(); const [availableTags, setAvailableTags] = useState([]); const [loading, setLoading] = useState(false); const [addingToEval, setAddingToEval] = useState(false); const [generatingVariant, setGeneratingVariant] = useState(false); const [variantDialog, setVariantDialog] = useState({ open: false, data: null }); const selectedModel = useAtomValue(selectedModelInfoAtom); // 解析数据集中的标签 const parseDatasetTags = tagsString => { try { return JSON.parse(tagsString || '[]'); } catch (e) { return []; } }; // 本地状态管理,从 props 初始化 const [localScore, setLocalScore] = useState(dataset.score || 0); const [localTags, setLocalTags] = useState(() => parseDatasetTags(dataset.tags)); const [localNote, setLocalNote] = useState(dataset.note || ''); // 获取项目中已使用的标签 useEffect(() => { const fetchAvailableTags = async () => { try { const response = await fetch(`/api/projects/${projectId}/datasets/tags`); if (response.ok) { const data = await response.json(); setAvailableTags(data.tags || []); } } catch (error) { console.error('获取可用标签失败:', error); } }; if (projectId) { fetchAvailableTags(); } }, [projectId]); // 同步props中的dataset到本地状态 useEffect(() => { setLocalScore(dataset.score || 0); setLocalTags(parseDatasetTags(dataset.tags)); setLocalNote(dataset.note || ''); }, [dataset]); // 更新数据集元数据 const updateMetadata = async updates => { if (loading) return; // 立即更新本地状态,提升响应速度 if (updates.score !== undefined) { setLocalScore(updates.score); } if (updates.tags !== undefined) { setLocalTags(updates.tags); } if (updates.note !== undefined) { setLocalNote(updates.note); } setLoading(true); try { const response = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); if (!response.ok) { throw new Error('更新失败'); } const result = await response.json(); // 显示成功提示 toast.success(t('datasets.updateSuccess', '更新成功')); // 如果有父组件的更新回调,调用它 if (onUpdate) { onUpdate(result.dataset); } } catch (error) { console.error('更新数据集元数据失败:', error); // 显示错误提示 toast.error(t('datasets.updateFailed', '更新失败')); // 出错时恢复本地状态 if (updates.score !== undefined) { setLocalScore(dataset.score || 0); } if (updates.tags !== undefined) { setLocalTags(parseDatasetTags(dataset.tags)); } if (updates.note !== undefined) { setLocalNote(dataset.note || ''); } } finally { setLoading(false); } }; // 处理评分变更 const handleScoreChange = newScore => { updateMetadata({ score: newScore }); }; // 处理标签变更 const handleTagsChange = newTags => { updateMetadata({ tags: newTags }); }; // 处理备注变更 const handleNoteChange = newNote => { updateMetadata({ note: newNote }); }; // 添加到评估数据集 const handleAddToEval = async () => { if (addingToEval) return; setAddingToEval(true); try { const response = await fetch(`/api/projects/${projectId}/datasets/${dataset.id}/copy-to-eval`, { method: 'POST' }); if (!response.ok) { throw new Error('Failed to add to eval dataset'); } toast.success(t('datasets.addToEvalSuccess', '成功添加到评估数据集')); // 更新本地标签显示 const currentTags = localTags || []; if (!currentTags.includes('Eval')) { setLocalTags([...currentTags, 'Eval']); } } catch (error) { console.error('添加评估数据集失败:', error); toast.error(t('datasets.addToEvalFailed', '添加失败')); } finally { setAddingToEval(false); } }; // 生成评估集变体 const handleGenerateEvalVariant = async config => { if (!selectedModel) { toast.error(t('datasets.selectModelFirst', '请先选择模型')); throw new Error('No model selected'); } try { const language = i18n.language === 'zh-CN' ? 'zh-CN' : 'en'; const response = await fetch(`/api/projects/${projectId}/datasets/generate-eval-variant`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ datasetId: dataset.id, model: selectedModel, language, questionType: config.questionType, count: config.count }) }); if (!response.ok) { throw new Error('Failed to generate variant'); } const { data } = await response.json(); // 为每个生成的项添加题型信息,以便保存时使用 return Array.isArray(data) ? data.map(item => ({ ...item, questionType: config.questionType })) : []; } catch (error) { console.error('生成变体失败:', error); toast.error(t('datasets.generateVariantFailed', '生成变体失败')); throw error; } }; // 保存评估集变体 const handleSaveEvalVariant = async variantItems => { try { // 过滤掉 'Eval' 标签,并确保转为逗号分隔的字符串 const tagsToSync = (localTags || []).filter(tag => tag !== 'Eval').join(','); const itemsToSave = variantItems.map(item => ({ question: item.question, correctAnswer: item.correctAnswer, questionType: item.questionType || 'open_ended', options: item.options, tags: tagsToSync, note: dataset.note, chunkId: null // 变体暂时不关联原始文本块 })); const response = await fetch(`/api/projects/${projectId}/eval-datasets`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: itemsToSave }) }); if (!response.ok) { throw new Error('Failed to save eval dataset'); } const result = await response.json(); toast.success(t('datasets.saveVariantSuccess', '已保存到评估数据集')); // 关闭对话框 setVariantDialog({ open: false, data: null }); } catch (error) { console.error('保存变体失败:', error); toast.error(t('datasets.saveVariantFailed', '保存失败')); } }; return ( {/* 评分区域 */} {t('datasets.rating', '评分')} {/* 标签区域 */} {t('datasets.customTags', '自定义标签')} {/* 备注区域 */} {currentDataset.aiEvaluation && ( {t('datasets.aiEvaluation')} {currentDataset.aiEvaluation} )} setVariantDialog({ open: false, data: null })} onGenerate={handleGenerateEvalVariant} onSave={handleSaveEvalVariant} /> ); } ================================================ FILE: components/datasets/EditableField.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, Button, TextField, IconButton, Switch, FormControlLabel, CircularProgress, Chip } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import ReactMarkdown from 'react-markdown'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; import 'github-markdown-css/github-markdown-light.css'; function getValue(value, answerType, useMarkdown, t, onOptimize) { if (value) { if (answerType === 'custom_format' && onOptimize) { try { const data = JSON.parse(value); value = JSON.stringify(data, null, 2); return ( {JSON.stringify(data, null, 2)} ); } catch {} } if (answerType === 'label' && onOptimize) { try { const labels = JSON.parse(value); if (Array.isArray(labels)) { return ( {labels.map((label, idx) => ( ))} ); } } catch { return {value}; } } return useMarkdown ? (
{value}
) : ( {value} ); } else { return ( {t('common.noData')} ); } } /** * 可编辑字段组件,支持 Markdown 和原始文本两种展示方式 */ export default function EditableField({ label, value, multiline = true, editing, onEdit, onChange, onSave, onCancel, onOptimize, tokenCount, optimizing = false, dataset }) { const { t } = useTranslation(); const theme = useTheme(); const { answerType } = dataset; const custom = answerType === 'custom_format' || answerType === 'label'; // 从 localStorage 读取 Markdown 展示设置,默认为 false const [useMarkdown, setUseMarkdown] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('dataset-use-markdown'); return saved ? JSON.parse(saved) : false; } return false; }); // 当 useMarkdown 状态改变时,保存到 localStorage useEffect(() => { if (typeof window !== 'undefined') { localStorage.setItem('dataset-use-markdown', JSON.stringify(useMarkdown)); } }, [useMarkdown]); const toggleMarkdown = () => { setUseMarkdown(!useMarkdown); }; const getAnswerTypeLabel = type => { switch (type) { case 'label': return t('imageDatasets.typeLabel', '标签'); case 'custom_format': return t('imageDatasets.typeCustom', '自定义'); default: return t('imageDatasets.typeText', '文本'); } }; return ( {label} {!editing && value && ( <> {onOptimize && ( {getAnswerTypeLabel(answerType)} )} {/* 字符数标签 */} {value.length} Characters {/* Token 标签 */} {tokenCount > 0 && ( {tokenCount} Tokens )} )} {!editing && ( <> {onOptimize && !custom && ( {optimizing ? : } )} {!custom && ( } label={{useMarkdown ? 'Markdown' : 'Text'}} sx={{ ml: 1 }} /> )} )} {editing ? ( <> ) : ( {getValue(value, answerType, useMarkdown, t, onOptimize)} )} ); } ================================================ FILE: components/datasets/EvalVariantDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box, Typography, Select, MenuItem, FormControl, InputLabel, Slider, Card, CardContent, IconButton, CircularProgress } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; /** * 评估集变体编辑对话框 */ export default function EvalVariantDialog({ open, onClose, onGenerate, onSave }) { const { t } = useTranslation(); const [step, setStep] = useState('config'); // 'config' | 'preview' const [loading, setLoading] = useState(false); const [config, setConfig] = useState({ questionType: 'open_ended', count: 1 }); const [items, setItems] = useState([]); // Reset state when dialog opens useEffect(() => { if (open) { setStep('config'); setConfig({ questionType: 'open_ended', count: 1 }); setItems([]); setLoading(false); } }, [open]); const handleGenerate = async () => { setLoading(true); try { const data = await onGenerate(config); // Ensure data is an array const newItems = Array.isArray(data) ? data : [data]; setItems(newItems); setStep('preview'); } catch (error) { console.error(error); } finally { setLoading(false); } }; const handleSave = () => { onSave(items); }; const handleItemChange = (index, field, value) => { const newItems = [...items]; newItems[index] = { ...newItems[index], [field]: value }; setItems(newItems); }; const handleDeleteItem = index => { const newItems = items.filter((_, i) => i !== index); setItems(newItems); if (newItems.length === 0) { setStep('config'); } }; const renderConfigStep = () => ( {t('datasets.evalVariantConfigHint', '请选择生成的题目类型和数量,AI 将基于当前问答对进行改写。')} {t('datasets.questionType', '题目类型')} {t('datasets.generateCount', '生成数量')}: {config.count} setConfig({ ...config, count: value })} step={1} marks min={1} max={5} valueLabelDisplay="auto" /> ); const renderPreviewStep = () => ( {t('datasets.evalVariantPreviewHint', '您可以编辑生成的题目,确认无误后保存到评估集。')} {items.map((item, index) => ( handleDeleteItem(index)} sx={{ position: 'absolute', right: 8, top: 8 }} > {t('datasets.questionIndex', '题目 {{index}}', { index: index + 1 })} handleItemChange(index, 'question', e.target.value)} size="small" /> {/* Render Options for choice questions */} {(item.options || config.questionType.includes('choice')) && ( { let val = e.target.value; try { // Try to parse if user inputs valid JSON, otherwise keep string const parsed = JSON.parse(val); if (Array.isArray(parsed)) val = parsed; } catch (e) {} handleItemChange(index, 'options', val); }} helperText={t('datasets.optionsHint', '例如: ["选项A", "选项B"]')} size="small" /> )} { let val = e.target.value; // For multiple choice, answer might be array if (config.questionType === 'multiple_choice') { try { const parsed = JSON.parse(val); if (Array.isArray(parsed)) val = parsed; } catch (e) {} } handleItemChange(index, 'correctAnswer', val); }} helperText={ config.questionType === 'multiple_choice' ? t('datasets.answerArrayHint', '多选题答案请输入数组,如 ["A", "C"]') : config.questionType === 'true_false' ? t('datasets.answerBoolHint', '判断题答案请输入 ✅ 或 ❌') : '' } size="small" /> ))} ); return ( {step === 'config' ? t('datasets.evalVariantTitle', '生成评估集变体') : t('datasets.evalVariantPreviewTitle', '确认生成的题目')} {step === 'config' ? renderConfigStep() : renderPreviewStep()} {step === 'config' ? ( ) : ( )} ); } ================================================ FILE: components/datasets/ImportDatasetDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, Stepper, Step, StepLabel, Alert } from '@mui/material'; import { useTranslation } from 'react-i18next'; import FileUploadStep from './import/FileUploadStep'; // import DatasetSourceStep from './import/DatasetSourceStep'; // 不再需要 import FieldMappingStep from './import/FieldMappingStep'; import ImportProgressStep from './import/ImportProgressStep'; /** * 数据集导入对话框 */ export default function ImportDatasetDialog({ open, onClose, projectId, onImportSuccess }) { const { t } = useTranslation(); const [importType, setImportType] = useState('file'); // 只支持文件上传 const [currentStep, setCurrentStep] = useState(0); const [importData, setImportData] = useState({ rawData: null, previewData: null, fieldMapping: {}, sourceInfo: null }); const [error, setError] = useState(''); const steps = [ t('import.fileUpload', '文件上传'), t('import.mapFields', '字段映射'), t('import.importing', '导入中') ]; const handleNext = () => { setCurrentStep(prev => prev + 1); }; const handleBack = () => { setCurrentStep(prev => prev - 1); }; const handleClose = () => { setCurrentStep(0); setImportData({ rawData: null, previewData: null, fieldMapping: {}, sourceInfo: null }); setError(''); onClose(); }; const handleDataLoaded = (data, preview, source) => { setImportData({ ...importData, rawData: data, previewData: preview, sourceInfo: source }); setError(''); handleNext(); }; const handleFieldMappingComplete = mapping => { setImportData({ ...importData, fieldMapping: mapping }); handleNext(); }; const handleImportComplete = () => { handleClose(); if (onImportSuccess) { onImportSuccess(); } }; const renderStepContent = () => { switch (currentStep) { case 0: return ; case 1: return ( ); case 2: return ( ); default: return null; } }; return ( {t('import.title', '导入数据集')} {/* 导入类型选择 - 只保留文件上传 */} {t('import.fileUpload', '文件上传')} {t('import.fileUploadDescription', '上传本地文件导入数据集')} {/* 步骤指示器 */} {steps.map(label => ( {label} ))} {/* 错误提示 */} {error && ( {error} )} {/* 步骤内容 */} {renderStepContent()} {currentStep > 0 && currentStep < 2 && } ); } ================================================ FILE: components/datasets/NoteInput.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, TextField, Typography, IconButton, Tooltip, Collapse } from '@mui/material'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import CancelIcon from '@mui/icons-material/Cancel'; import NotesIcon from '@mui/icons-material/Notes'; import { useTranslation } from 'react-i18next'; /** * 备注输入组件 */ export default function NoteInput({ value = '', onChange, placeholder, readOnly = false, maxLength = 500, minRows = 3, maxRows = 6 }) { const { t } = useTranslation(); const [isEditing, setIsEditing] = useState(false); const [noteValue, setNoteValue] = useState(value); const [tempValue, setTempValue] = useState(value); // 同步外部value变化 useEffect(() => { setNoteValue(value); setTempValue(value); }, [value]); // 开始编辑 const handleStartEdit = () => { setIsEditing(true); setTempValue(noteValue); }; // 保存备注 const handleSave = () => { setNoteValue(tempValue); setIsEditing(false); if (onChange) { onChange(tempValue); } }; // 取消编辑 const handleCancel = () => { setTempValue(noteValue); setIsEditing(false); }; // 处理键盘快捷键 const handleKeyDown = event => { if (event.ctrlKey || event.metaKey) { if (event.key === 'Enter') { event.preventDefault(); handleSave(); } else if (event.key === 'Escape') { event.preventDefault(); handleCancel(); } } }; if (readOnly) { return ( {t('datasets.note', '备注')} {noteValue ? ( {noteValue} ) : ( {t('datasets.noNote', '暂无备注')} )} ); } return ( {/* 标题和操作按钮 */} {t('datasets.note', '备注')} {noteValue && !isEditing && ( ({noteValue.length} / {maxLength}) )} {!isEditing && ( )} {/* 显示模式 */} {noteValue ? ( {noteValue} ) : ( {placeholder || t('datasets.clickToAddNote', '点击添加备注...')} )} {/* 编辑模式 */} setTempValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={placeholder || t('datasets.enterNote', '请输入备注...')} inputProps={{ maxLength }} helperText={ {t('datasets.noteShortcuts', 'Ctrl+Enter 保存,Esc 取消')} maxLength * 0.9 ? 'warning.main' : 'text.secondary'} > {tempValue.length} / {maxLength} } sx={{ mb: 1 }} /> maxLength}> ); } ================================================ FILE: components/datasets/OptimizeDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField } from '@mui/material'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; /** * AI优化对话框组件 */ export default function OptimizeDialog({ open, onClose, onConfirm }) { const [advice, setAdvice] = useState(''); const { t } = useTranslation(); const handleConfirm = () => { onConfirm(advice); setAdvice(''); onClose(); }; const handleClose = () => { onClose(); setAdvice(''); }; return ( {t('datasets.optimizeTitle')} setAdvice(e.target.value)} placeholder={t('datasets.optimizePlaceholder')} /> ); } ================================================ FILE: components/datasets/StarRating.js ================================================ 'use client'; import { useState } from 'react'; import { Box, Rating, Typography } from '@mui/material'; import StarIcon from '@mui/icons-material/Star'; import { useTranslation } from 'react-i18next'; /** * 五星评分组件 */ export default function StarRating({ value = 0, onChange, readOnly = false, size = 'medium', showLabel = true }) { const { t } = useTranslation(); const [hover, setHover] = useState(-1); const labels = { 0.5: t('rating.veryPoor', '很差'), 1: t('rating.poor', '差'), 1.5: t('rating.belowAverage', '偏差'), 2: t('rating.fair', '一般'), 2.5: t('rating.average', '中等'), 3: t('rating.good', '良好'), 3.5: t('rating.veryGood', '很好'), 4: t('rating.excellent', '优秀'), 4.5: t('rating.outstanding', '杰出'), 5: t('rating.perfect', '完美') }; const getLabelText = value => { return `${value} Star${value !== 1 ? 's' : ''}, ${labels[value]}`; }; return ( { if (!readOnly && onChange) { onChange(newValue || 0); } }} onChangeActive={(event, newHover) => { if (!readOnly) { setHover(newHover); } }} readOnly={readOnly} size={size} icon={} emptyIcon={} sx={{ '& .MuiRating-iconFilled': { color: '#ffc107' }, '& .MuiRating-iconHover': { color: '#ffb300' } }} /> {showLabel && ( {labels[hover !== -1 ? hover : value] || (value === 0 ? t('rating.unrated', '未评分') : '')} )} ); } ================================================ FILE: components/datasets/TagSelector.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Chip, TextField, Autocomplete, Typography, IconButton, Tooltip } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import CloseIcon from '@mui/icons-material/Close'; import { useTranslation } from 'react-i18next'; /** * 标签选择器组件 * 支持从已有标签选择和自定义添加新标签 */ export default function TagSelector({ value = [], onChange, availableTags = [], placeholder, readOnly = false, maxTags = 10 }) { const { t } = useTranslation(); const [inputValue, setInputValue] = useState(''); // 确保 value 始终是数组 const normalizeValue = val => { if (Array.isArray(val)) { return val; } if (typeof val === 'string') { try { const parsed = JSON.parse(val); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } return []; }; const [selectedTags, setSelectedTags] = useState(() => normalizeValue(value)); // 同步外部value变化 useEffect(() => { setSelectedTags(normalizeValue(value)); }, [value]); // 处理标签变更 const handleTagsChange = newTags => { setSelectedTags(newTags); if (onChange) { onChange(newTags); } }; // 添加新标签 const handleAddTag = newTag => { if (!newTag || newTag.trim() === '') return; const trimmedTag = newTag.trim(); if (selectedTags.includes(trimmedTag)) return; if (selectedTags.length >= maxTags) { return; } const updatedTags = [...selectedTags, trimmedTag]; handleTagsChange(updatedTags); setInputValue(''); }; // 删除标签 const handleDeleteTag = tagToDelete => { const updatedTags = selectedTags.filter(tag => tag !== tagToDelete); handleTagsChange(updatedTags); }; // 处理键盘事件 const handleKeyPress = event => { if (event.key === 'Enter' && inputValue.trim()) { event.preventDefault(); handleAddTag(inputValue); } }; // 获取可选的标签选项(排除已选择的) const getAvailableOptions = () => { return availableTags.filter(tag => !selectedTags.includes(tag)); }; if (readOnly) { return ( {selectedTags.length > 0 ? ( selectedTags.map((tag, index) => ( )) ) : ( {t('tags.noTags', '暂无标签')} )} ); } return ( {/* 已选择的标签 */} {selectedTags.length > 0 && ( {selectedTags.map((tag, index) => ( handleDeleteTag(tag)} deleteIcon={} /> ))} )} {/* 标签输入区域 */} {selectedTags.length < maxTags && ( { setInputValue(newInputValue); }} onChange={(event, newValue) => { if (newValue) { handleAddTag(newValue); } }} renderInput={params => ( )} renderOption={(props, option) => ( {option} )} sx={{ flexGrow: 1 }} /> handleAddTag(inputValue)} disabled={!inputValue.trim()} color="primary" > )} {/* 标签数量提示 */} {selectedTags.length >= maxTags && ( {t('tags.maxTagsReached', `最多可添加 ${maxTags} 个标签`)} )} {/* 可用标签提示 */} {availableTags.length > 0 && selectedTags.length < maxTags && ( {t('tags.availableTagsHint', '可从已有标签中选择,或输入新标签')} )} ); } ================================================ FILE: components/datasets/import/FieldMappingStep.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, FormControl, InputLabel, Select, MenuItem, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Alert, Button, Chip } from '@mui/material'; import { useTranslation } from 'react-i18next'; /** * 字段映射步骤组件 */ export default function FieldMappingStep({ previewData, onMappingComplete, onError }) { const { t } = useTranslation(); const [fieldMapping, setFieldMapping] = useState({ question: '', answer: '', cot: '', tags: '' }); const [availableFields, setAvailableFields] = useState([]); const [mappingValid, setMappingValid] = useState(false); // 智能字段识别(支持 Alpaca: instruction + input -> question,output -> answer) const smartFieldMapping = fields => { const mapping = { question: '', answer: '', cot: '', tags: '' }; const lower = fields.map(f => f.toLowerCase()); const instructionIdx = lower.findIndex(f => f.includes('instruction')); const inputIdx = lower.findIndex(f => f.includes('input')); const outputIdx = lower.findIndex(f => f.includes('output')); // Alpaca 格式的优先识别 if (instructionIdx !== -1 && inputIdx !== -1) { // 如果同时有instruction和input字段,将它们组合为question mapping.question = [fields[instructionIdx], fields[inputIdx]]; } else if (instructionIdx !== -1) { // 如果只有instruction字段(比如从ShareGPT转换而来),直接映射为question mapping.question = fields[instructionIdx]; } if (outputIdx !== -1) { mapping.answer = fields[outputIdx]; } const questionKeywords = ['question', 'input', 'query', 'prompt', 'instruction', '问题', '输入', '指令']; const answerKeywords = ['answer', 'output', 'response', 'completion', 'target', '答案', '输出', '回答']; const cotKeywords = ['cot', 'reasoning', 'explanation', 'thinking', 'rationale', '思维链', '推理', '解释']; const tagKeywords = ['tag', 'tags', 'label', 'labels', 'category', 'categories', '标签', '类别']; fields.forEach(field => { const fieldLower = field.toLowerCase(); if (!mapping.question || (typeof mapping.question === 'string' && !mapping.question)) { if (questionKeywords.some(keyword => fieldLower.includes(keyword))) { mapping.question = field; } } else if (!mapping.answer) { if (answerKeywords.some(keyword => fieldLower.includes(keyword))) { mapping.answer = field; } } else if (!mapping.cot) { if (cotKeywords.some(keyword => fieldLower.includes(keyword))) { mapping.cot = field; } } else if (!mapping.tags) { if (tagKeywords.some(keyword => fieldLower.includes(keyword))) { mapping.tags = field; } } }); return mapping; }; useEffect(() => { if (previewData && previewData.length > 0) { const fields = Object.keys(previewData[0]); setAvailableFields(fields); // 智能识别字段映射 const smartMapping = smartFieldMapping(fields); setFieldMapping(smartMapping); } }, [previewData]); useEffect(() => { // 验证映射是否有效(问题和答案字段必须选择) const hasQuestion = Array.isArray(fieldMapping.question) ? fieldMapping.question.length > 0 : !!fieldMapping.question; const hasAnswer = !!fieldMapping.answer; const isValid = hasQuestion && hasAnswer; setMappingValid(isValid); }, [fieldMapping]); const handleFieldChange = (targetField, sourceField) => { setFieldMapping(prev => ({ ...prev, [targetField]: targetField === 'question' ? Array.isArray(sourceField) ? sourceField.filter(Boolean) : sourceField : sourceField })); }; const handleConfirmMapping = () => { if (!mappingValid) { onError(t('import.mappingRequired', '问题和答案字段为必选项')); return; } // 检查是否有重复映射(兼容数组) const flatFields = Object.values(fieldMapping) .filter(Boolean) .flatMap(f => (Array.isArray(f) ? f.filter(Boolean) : [f])); const uniqueFields = [...new Set(flatFields)]; if (flatFields.length !== uniqueFields.length) { onError(t('import.duplicateMapping', '不能将多个目标字段映射到同一个源字段')); return; } onMappingComplete(fieldMapping); }; const getFieldDescription = field => { switch (field) { case 'question': return t('import.questionDesc', '用户的问题或输入内容(必选,可多选)'); case 'answer': return t('import.answerDesc', 'AI的回答或输出内容(必选)'); case 'cot': return t('import.cotDesc', '思维链或推理过程(可选)'); case 'tags': return t('import.tagsDesc', '标签数组,多个标签用逗号分隔(可选)'); default: return ''; } }; const isFieldRequired = field => { return field === 'question' || field === 'answer'; }; if (!previewData || previewData.length === 0) { return {t('import.noPreviewData', '没有可预览的数据')}; } return ( {t('import.fieldMapping', '字段映射')} {t( 'import.mappingDescription', '请将源数据的字段映射到目标字段。系统已自动识别可能的映射关系,您可以根据需要调整。' )} {/* 字段映射选择 */} {t('import.selectMapping', '选择字段映射')} {Object.keys(fieldMapping).map(targetField => ( {t(`import.${targetField}Field`, targetField)} {isFieldRequired(targetField) && *} {targetField === 'question' ? ( ) : ( )} {getFieldDescription(targetField)} ))} {/* 数据预览 */} {t('import.dataPreview', '数据预览')} {t('import.previewNote', '显示前3条记录,每个字段值最多显示100个字符')} {availableFields.map(field => ( {field} {Object.entries(fieldMapping).map(([targetField, sourceField]) => { const match = Array.isArray(sourceField) ? sourceField.includes(field) : sourceField === field; if (match) { return ( ); } return null; })} ))} {previewData.map((row, index) => ( {availableFields.map(field => ( {row[field] || '-'} ))} ))}
{/* 确认按钮 */} {!mappingValid && ( {t('import.requiredFields', '请至少选择问题和答案字段的映射')} )}
); } ================================================ FILE: components/datasets/import/FileUploadStep.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { Box, Typography, Button, Paper, List, ListItem, ListItemIcon, ListItemText, LinearProgress, Alert } from '@mui/material'; import { CloudUpload as UploadIcon, Description as FileIcon, CheckCircle as CheckIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; // import { useDropzone } from 'react-dropzone'; /** * 文件上传步骤组件 */ export default function FileUploadStep({ onDataLoaded, onError }) { const { t } = useTranslation(); const [uploading, setUploading] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); // 健壮的CSV解析函数,支持多行字段和引号转义 const parseCSV = text => { const result = []; const lines = []; let currentLine = ''; let inQuotes = false; // 逐字符解析,正确处理引号内的换行符 for (let i = 0; i < text.length; i++) { const char = text[i]; const nextChar = text[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { // 转义的引号 currentLine += '"'; i++; // 跳过下一个引号 } else { // 切换引号状态 inQuotes = !inQuotes; } } else if (char === '\n' && !inQuotes) { // 行结束(不在引号内) if (currentLine.trim()) { lines.push(currentLine); } currentLine = ''; } else { currentLine += char; } } // 添加最后一行 if (currentLine.trim()) { lines.push(currentLine); } if (lines.length < 2) { throw new Error('CSV文件格式不正确,至少需要标题行和一行数据'); } // 解析标题行 const headers = parseCSVLine(lines[0]); // 解析数据行 for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); if (values.length > 0) { const obj = {}; headers.forEach((header, index) => { obj[header] = values[index] || ''; }); result.push(obj); } } return result; }; // 解析单行CSV,处理逗号分隔和引号转义 const parseCSVLine = line => { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; const nextChar = line[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { // 转义的引号 current += '"'; i++; // 跳过下一个引号 } else { // 切换引号状态 inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { // 字段分隔符(不在引号内) result.push(current.trim()); current = ''; } else { current += char; } } // 添加最后一个字段 result.push(current.trim()); return result; }; // 检测并转换ShareGPT格式为Alpaca格式 const convertShareGPTToAlpaca = item => { // 检查是否包含conversations字段且格式正确 if (item.conversations && Array.isArray(item.conversations)) { const conversations = item.conversations; // 查找system、human、gpt消息 let systemMessage = ''; let instruction = ''; let output = ''; for (const conv of conversations) { if (conv.from === 'system' && conv.value) { systemMessage = conv.value; } else if (conv.from === 'human' && conv.value) { instruction = conv.value; } else if (conv.from === 'gpt' && conv.value) { output = conv.value; break; // 只取第一轮对话 } } // 如果有system消息,将其作为instruction的前缀 if (systemMessage && instruction) { instruction = `${systemMessage}\n\n${instruction}`; } else if (systemMessage && !instruction) { instruction = systemMessage; } // 转换为Alpaca格式 return { instruction: instruction || '', input: '', // ShareGPT格式通常没有单独的input字段 output: output || '', // 保留其他字段 ...Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'conversations')) }; } return item; // 如果不是ShareGPT格式,返回原始数据 }; const parseFileContent = async file => { const text = await file.text(); const extension = file.name.split('.').pop().toLowerCase(); try { let data = []; if (extension === 'json') { const parsed = JSON.parse(text); data = Array.isArray(parsed) ? parsed : [parsed]; } else if (extension === 'jsonl') { data = text .split('\n') .filter(line => line.trim()) .map(line => JSON.parse(line)); } else if (extension === 'csv') { // 更健壮的CSV解析,支持多行字段和引号转义 data = parseCSV(text); if (data.length === 0) { throw new Error('CSV文件格式不正确或没有数据'); } } else { throw new Error('不支持的文件格式'); } if (data.length === 0) { throw new Error('文件中没有找到有效数据'); } // 检测并转换ShareGPT格式为Alpaca格式 data = data.map(convertShareGPTToAlpaca); // 生成预览数据(取前3条记录,每个字段值截取前100字符) const previewData = data.slice(0, 3).map(item => { const preview = {}; Object.keys(item).forEach(key => { const value = String(item[key] || ''); preview[key] = value.length > 100 ? value.substring(0, 100) + '...' : value; }); return preview; }); return { data, preview: previewData, source: { type: 'file', fileName: file.name, fileSize: file.size, totalRecords: data.length } }; } catch (error) { throw new Error(`解析文件失败: ${error.message}`); } }; const handleFileSelect = async event => { const files = event.target.files; if (!files || files.length === 0) return; const file = files[0]; setUploading(true); try { const result = await parseFileContent(file); setUploadedFiles([ { name: file.name, size: file.size, status: 'success' } ]); onDataLoaded(result.data, result.preview, result.source); } catch (error) { setUploadedFiles([ { name: file.name, size: file.size, status: 'error', error: error.message } ]); onError(error.message); } finally { setUploading(false); } }; const formatFileSize = bytes => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; return ( {t('import.uploadFile', '上传文件')} {t('import.supportedFormats', '支持 JSON、JSONL、CSV 格式文件')} {/* 文件上传区域 */} document.getElementById('file-upload-input').click()} > {t('import.dragDropFile', '拖拽文件到此处或点击选择文件')} {t('import.maxFileSize', '最大文件大小: 50MB')} {/* 上传进度 */} {uploading && ( {t('import.processingFile', '正在处理文件...')} )} {/* 已上传文件列表 */} {uploadedFiles.length > 0 && ( {t('import.uploadedFiles', '已上传文件')} {uploadedFiles.map((file, index) => ( {file.status === 'success' ? : } ))} {uploadedFiles.some(f => f.status === 'error') && ( {t('import.uploadError', '文件上传失败,请检查文件格式是否正确')} )} )} ); } ================================================ FILE: components/datasets/import/ImportProgressStep.js ================================================ 'use client'; import { useState, useEffect, useRef } from 'react'; import { Box, Typography, LinearProgress, Alert, Paper, List, ListItem, ListItemIcon, ListItemText, Chip } from '@mui/material'; import { CheckCircle as CheckIcon, Error as ErrorIcon, Info as InfoIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; /** * 导入进度步骤组件 */ export default function ImportProgressStep({ projectId, rawData, fieldMapping, sourceInfo, onComplete, onError }) { const { t } = useTranslation(); const [progress, setProgress] = useState(0); const [currentStep, setCurrentStep] = useState(''); const [importStats, setImportStats] = useState({ total: 0, processed: 0, success: 0, failed: 0, skipped: 0, errors: [] }); const [completed, setCompleted] = useState(false); const startedRef = useRef(false); // 防止在开发模式下因严格模式导致重复执行 useEffect(() => { if (!startedRef.current && rawData && fieldMapping && projectId) { startedRef.current = true; startImport(); } }, [rawData, fieldMapping, projectId]); const startImport = async () => { try { setCurrentStep(t('import.preparingData', '准备数据...')); setImportStats(prev => ({ ...prev, total: rawData.length })); // 转换数据格式 const convertedData = rawData.map(item => { // 支持 question 映射多个字段,拼接为一个字符串 const qFields = fieldMapping.question; const question = Array.isArray(qFields) ? qFields .map(f => item[f] || '') .filter(v => v && String(v).trim()) .join('\n') : item[qFields] || ''; const converted = { question, answer: item[fieldMapping.answer] || '', cot: fieldMapping.cot ? item[fieldMapping.cot] || '' : '', questionLabel: '', // 默认标签,后续可以通过AI生成 chunkName: sourceInfo?.datasetName || sourceInfo?.fileName || 'Imported Data', chunkContent: `Imported from ${sourceInfo?.type || 'file'}`, model: 'imported', confirmed: false, score: 0, tags: fieldMapping.tags ? JSON.stringify(parseTagsField(item[fieldMapping.tags])) : '[]', note: '', other: JSON.stringify(getOtherFields(item, fieldMapping)) }; // 不在前端抛错,由后端负责校验并统计 skipped return converted; }); setProgress(25); setCurrentStep(t('import.uploadingData', '上传数据...')); // 分批上传数据 const batchSize = 500; let processed = 0; let success = 0; let failed = 0; let skipped = 0; const errors = []; for (let i = 0; i < convertedData.length; i += batchSize) { const batch = convertedData.slice(i, i + batchSize); try { const response = await fetch(`/api/projects/${projectId}/datasets/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ datasets: batch, sourceInfo }) }); if (!response.ok) { throw new Error(`批次上传失败: ${response.statusText}`); } const result = await response.json(); success += result.success || 0; failed += typeof result.failed === 'number' ? result.failed : result.errors?.length || 0; skipped += result.skipped || 0; processed += batch.length; if (result.errors && result.errors.length > 0) { errors.push(...result.errors); } } catch (error) { failed += batch.length; processed += batch.length; errors.push(`批次 ${Math.floor(i / batchSize) + 1}: ${error.message}`); } // 更新进度 const progressPercent = 25 + (processed / convertedData.length) * 70; setProgress(progressPercent); setImportStats({ total: convertedData.length, processed, success, failed, skipped, errors }); setCurrentStep( t('import.processing', '处理中... {{processed}}/{{total}}', { processed, total: convertedData.length }) ); } setProgress(100); setCurrentStep(t('import.completed', '导入完成')); setCompleted(true); // 延迟一下再调用完成回调,让用户看到完成状态 setTimeout(() => { onComplete(); }, 2000); } catch (error) { onError(error.message); setImportStats(prev => ({ ...prev, errors: [...prev.errors, error.message] })); } }; // 解析标签字段 const parseTagsField = tagsValue => { if (!tagsValue) return []; if (Array.isArray(tagsValue)) { return tagsValue; } if (typeof tagsValue === 'string') { return tagsValue .split(',') .map(tag => tag.trim()) .filter(tag => tag); } return []; }; // 获取其他字段(兼容数组映射) const getOtherFields = (item, mapping) => { const used = []; Object.values(mapping).forEach(field => { if (!field) return; if (Array.isArray(field)) used.push(...field); else used.push(field); }); const mappedFields = new Set(used); const otherFields = {}; Object.keys(item).forEach(key => { if (!mappedFields.has(key)) { otherFields[key] = item[key]; } }); return otherFields; }; return ( {t('import.importing', '正在导入数据集')} {/* 进度条 */} {currentStep} {Math.round(progress)}% {t('import.complete', '完成')} {/* 导入统计 */} {t('import.importStats', '导入统计')} } label={t('import.total', '总计: {{count}}', { count: importStats.total })} variant="outlined" /> } label={t('import.success', '成功: {{count}}', { count: importStats.success })} color="success" variant="outlined" /> {importStats.skipped > 0 && ( } label={t('import.skipped', '跳过: {{count}}', { count: importStats.skipped })} color="warning" variant="outlined" /> )} {importStats.failed > 0 && ( } label={t('import.failed', '失败: {{count}}', { count: importStats.failed })} color="error" variant="outlined" /> )} {sourceInfo && ( {t('import.source', '数据源')}:{' '} {sourceInfo.type === 'file' ? sourceInfo.fileName : sourceInfo.datasetName} {sourceInfo.description && ( {t('import.description', '描述')}: {sourceInfo.description} )} )} {/* 错误列表 */} {importStats.errors.length > 0 && ( {t('import.errors', '错误信息')} {importStats.errors.slice(0, 10).map((error, index) => ( ))} {importStats.errors.length > 10 && ( {t('import.moreErrors', '还有 {{count}} 个错误未显示...', { count: importStats.errors.length - 10 })} )} )} {/* 完成提示 */} {completed && ( {t('import.importSuccess', '数据集导入完成!成功导入 {{success}} 条记录。', { success: importStats.success })} )} ); } ================================================ FILE: components/datasets/utils/ratingUtils.js ================================================ /** * 评分相关的工具函数 */ /** * 根据评分获取对应的颜色和标签(不包含国际化) * @param {number} score - 评分 (0-5) * @returns {object} - 包含颜色、背景色和标签的对象 */ export const getRatingConfig = score => { if (score >= 4.5) { return { color: '#2e7d32', // 深绿色 backgroundColor: '#e8f5e8', label: '优秀', variant: 'excellent' }; } else if (score >= 3.5) { return { color: '#388e3c', // 绿色 backgroundColor: '#f1f8e9', label: '良好', variant: 'good' }; } else if (score >= 2.5) { return { color: '#f57c00', // 橙色 backgroundColor: '#fff3e0', label: '一般', variant: 'average' }; } else if (score >= 1.5) { return { color: '#f44336', // 红色 backgroundColor: '#ffebee', label: '较差', variant: 'poor' }; } else if (score > 0) { return { color: '#d32f2f', // 深红色 backgroundColor: '#ffebee', label: '很差', variant: 'very-poor' }; } else { return { color: '#757575', // 灰色 backgroundColor: '#f5f5f5', label: '未评分', variant: 'unrated' }; } }; /** * 根据评分获取对应的颜色和国际化标签 * @param {number} score - 评分 (0-5) * @param {function} t - 国际化翻译函数 * @returns {object} - 包含颜色、背景色和国际化标签的对象 */ export const getRatingConfigI18n = (score, t) => { const baseConfig = getRatingConfig(score); // 根据variant获取对应的翻译键 let translationKey; let fallbackText; switch (baseConfig.variant) { case 'excellent': translationKey = 'datasets.ratingExcellent'; fallbackText = '优秀'; break; case 'good': translationKey = 'datasets.ratingGood'; fallbackText = '良好'; break; case 'average': translationKey = 'datasets.ratingAverage'; fallbackText = '一般'; break; case 'poor': translationKey = 'datasets.ratingPoor'; fallbackText = '较差'; break; case 'very-poor': translationKey = 'datasets.ratingVeryPoor'; fallbackText = '很差'; break; case 'unrated': translationKey = 'datasets.ratingUnrated'; fallbackText = '未评分'; break; default: translationKey = 'datasets.ratingUnrated'; fallbackText = '未评分'; } return { ...baseConfig, label: t(translationKey, fallbackText) }; }; /** * 格式化评分显示 * @param {number} score - 评分 * @returns {string} - 格式化后的评分字符串 */ export const formatScore = score => { if (score === 0) return ''; return score.toFixed(1); }; /** * 获取评分范围的描述 * @param {number} score - 评分 * @returns {string} - 评分范围描述 */ export const getScoreDescription = score => { const config = getRatingConfig(score); return `${formatScore(score)} - ${config.label}`; }; /** * 评分范围常量 */ export const SCORE_RANGES = { EXCELLENT: { min: 4.5, max: 5.0, label: '优秀' }, GOOD: { min: 3.5, max: 4.4, label: '良好' }, AVERAGE: { min: 2.5, max: 3.4, label: '一般' }, POOR: { min: 1.5, max: 2.4, label: '较差' }, VERY_POOR: { min: 0.1, max: 1.4, label: '很差' }, UNRATED: { min: 0, max: 0, label: '未评分' } }; ================================================ FILE: components/distill/AutoDistillDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Typography, Box, Alert, Paper, Divider, FormControl, FormLabel, RadioGroup, FormControlLabel, Radio } from '@mui/material'; /** * 全自动蒸馏数据集配置弹框 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调 * @param {Function} props.onStart - 开始蒸馏任务的回调 * @param {Function} props.onStartBackground - 开始后台蒸馏任务的回调 * @param {string} props.projectId - 项目ID * @param {Object} props.project - 项目信息 * @param {Object} props.stats - 当前统计信息 */ export default function AutoDistillDialog({ open, onClose, onStart, onStartBackground, projectId, project, stats = {} }) { const { t } = useTranslation(); // 表单状态 const [topic, setTopic] = useState(''); const [levels, setLevels] = useState(2); const [tagsPerLevel, setTagsPerLevel] = useState(10); const [questionsPerTag, setQuestionsPerTag] = useState(10); const [datasetType, setDatasetType] = useState('single-turn'); // 'single-turn' | 'multi-turn' | 'both' // 计算信息 const [estimatedTags, setEstimatedTags] = useState(0); // 所有标签总数(包括根节点和中间节点) const [leafTags, setLeafTags] = useState(0); // 叶子节点数量(即最后一层标签数) const [estimatedQuestions, setEstimatedQuestions] = useState(0); const [newTags, setNewTags] = useState(0); const [newQuestions, setNewQuestions] = useState(0); const [error, setError] = useState(''); // 初始化默认主题 useEffect(() => { if (project && project.name) { setTopic(project.name); } }, [project]); // 计算预估标签和问题数量 useEffect(() => { /* * 根据公式:总问题数 = \left( \prod_{i=1}^{n} L_i \right) \times Q * 当每层标签数量相同(L)时:总问题数 = L^n \times Q */ const leafTags = Math.pow(tagsPerLevel, levels); // 总问题数 = 叶子节点数 * 每个节点的问题数 const totalQuestions = leafTags * questionsPerTag; let totalTags; if (tagsPerLevel === 1) { // 如果每层只有1个标签,总数就是 levels+1 totalTags = levels + 1; } else { // 使用等比数列求和公式 totalTags = (1 - Math.pow(tagsPerLevel, levels + 1)) / (1 - tagsPerLevel); } setLeafTags(leafTags); setEstimatedTags(leafTags); // 改为只显示叶子节点数量,而非所有节点数量 setEstimatedQuestions(totalQuestions); // 计算新增标签和问题数量 const currentTags = stats.tagsCount || 0; const currentQuestions = stats.questionsCount || 0; // 只考虑最后一层的标签数量 setNewTags(Math.max(0, leafTags - currentTags)); setNewQuestions(Math.max(0, totalQuestions - currentQuestions)); // 验证是否可以执行任务 if (leafTags <= currentTags && totalQuestions <= currentQuestions) { setError(t('distill.autoDistillInsufficientError')); } else { setError(''); } }, [levels, tagsPerLevel, questionsPerTag, stats, t]); // 处理开始任务 const handleStart = () => { if (error) return; onStart({ topic, levels, tagsPerLevel, questionsPerTag, estimatedTags, estimatedQuestions, datasetType }); }; // 处理开始后台任务 const handleStartBackground = () => { if (error) return; onStartBackground({ topic, levels, tagsPerLevel, questionsPerTag, estimatedTags, estimatedQuestions, datasetType }); }; return ( {t('distill.autoDistillTitle')} {/* 左侧:输入区域 */} setTopic(e.target.value)} fullWidth margin="normal" required disabled helperText={t('distill.rootTopicHelperText')} /> {t('distill.tagLevels')} { const value = Math.min(5, Math.max(1, Number(e.target.value))); setLevels(value); }} helperText={t('distill.tagLevelsHelper', { max: 5 })} /> {t('distill.tagsPerLevel')} { const value = Math.min(50, Math.max(1, Number(e.target.value))); setTagsPerLevel(value); }} helperText={t('distill.tagsPerLevelHelper', { max: 50 })} /> {t('distill.questionsPerTag')} { const value = Math.min(50, Math.max(1, Number(e.target.value))); setQuestionsPerTag(value); }} helperText={t('distill.questionsPerTagHelper', { max: 50 })} /> {t('distill.datasetType', { defaultValue: '数据集类型' })} setDatasetType(e.target.value)}> } label={t('distill.singleTurnDataset', { defaultValue: '单轮对话数据集' })} /> } label={t('distill.multiTurnDataset', { defaultValue: '多轮对话数据集' })} /> } label={t('distill.bothDatasetTypes', { defaultValue: '两种数据集都生成' })} /> {/* 右侧:预估信息区域 */} {t('distill.estimationInfo')} {t('distill.estimatedTags')}: {estimatedTags} {t('distill.estimatedQuestions')}: {estimatedQuestions} {t('distill.currentTags')}: {stats.tagsCount || 0} {t('distill.currentQuestions')}: {stats.questionsCount || 0} {t('distill.newTags')}: {newTags} {t('distill.newQuestions')}: {newQuestions} {error && ( {error} )} ); } ================================================ FILE: components/distill/AutoDistillProgress.js ================================================ 'use client'; import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, Box, Typography, LinearProgress, Paper, Divider, IconButton, Button } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; /** * 全自动蒸馏进度组件 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调 * @param {Object} props.progress - 进度信息 */ export default function AutoDistillProgress({ open, onClose, progress = {} }) { const { t } = useTranslation(); const logContainerRef = useRef(null); // 自动滚动到底部 useEffect(() => { if (logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } }, [progress.logs]); const getStageText = () => { const { stage } = progress; switch (stage) { case 'level1': return t('distill.stageBuildingLevel1'); case 'level2': return t('distill.stageBuildingLevel2'); case 'level3': return t('distill.stageBuildingLevel3'); case 'level4': return t('distill.stageBuildingLevel4'); case 'level5': return t('distill.stageBuildingLevel5'); case 'questions': return t('distill.stageBuildingQuestions'); case 'datasets': return t('distill.stageBuildingDatasets'); case 'multi-turn-datasets': return t('distill.stageBuildingMultiTurnDatasets', { defaultValue: '生成多轮对话数据集中...' }); case 'completed': return t('distill.stageCompleted'); default: return t('distill.stageInitializing'); } }; const getOverallProgress = () => { const { tagsBuilt, tagsTotal, questionsBuilt, questionsTotal, datasetsBuilt, datasetsTotal } = progress; // 整体进度按比例计算:标签构建占30%,问题生成占35%,数据集生成占35% let tagProgress = tagsTotal ? (tagsBuilt / tagsTotal) * 30 : 0; let questionProgress = questionsTotal ? (questionsBuilt / questionsTotal) * 35 : 0; let datasetProgress = datasetsTotal ? (datasetsBuilt / datasetsTotal) * 35 : 0; return Math.min(100, Math.round(tagProgress + questionProgress + datasetProgress)); }; return ( {t('distill.autoDistillProgress')} {(progress.stage === 'completed' || !progress.stage) && ( )} {/* 整体进度 */} {t('distill.overallProgress')} {getOverallProgress()}% 0 ? 'repeat(4, 1fr)' : 'repeat(3, 1fr)', gap: 2 }} > {t('distill.tagsProgress')} {progress.tagsBuilt || 0} / {progress.tagsTotal || 0} {t('distill.questionsProgress')} {progress.questionsBuilt || 0} / {progress.questionsTotal || 0} {t('distill.datasetsProgress')} {progress.datasetsBuilt || 0} / {progress.datasetsTotal || 0} {progress.multiTurnDatasetsTotal > 0 && ( {t('distill.multiTurnDatasetsProgress', { defaultValue: '多轮对话进度' })} {progress.multiTurnDatasetsBuilt || 0} / {progress.multiTurnDatasetsTotal || 0} )} {/* 当前阶段 */} {t('distill.currentStage')} {getStageText()} {/* 实时日志 */} {t('distill.realTimeLogs')} {progress.logs?.length > 0 ? ( progress.logs.map((log, index) => { // 检测成功日志,显示为绿色 Successfully let color = 'inherit'; if (log.includes('成功') || log.includes('完成') || log.includes('Successfully')) { color = '#4caf50'; } if (log.includes('失败') || log.toLowerCase().includes('error')) { color = '#f44336'; } return ( {log} ); }) ) : ( {t('distill.waitingForLogs')} )} ); } ================================================ FILE: components/distill/ConfirmDialog.js ================================================ 'use client'; import { Dialog, DialogActions, DialogTitle, Button } from '@mui/material'; /** * 通用确认对话框组件 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调 * @param {Function} props.onConfirm - 确认操作的回调 * @param {string} props.title - 对话框标题 * @param {string} props.cancelText - 取消按钮文本 * @param {string} props.confirmText - 确认按钮文本 */ export default function ConfirmDialog({ open, onClose, onConfirm, title, cancelText = '取消', confirmText = '确认', confirmColor = 'error' }) { return ( {title} ); } ================================================ FILE: components/distill/DistillTreeView.js ================================================ 'use client'; import { useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Typography, List } from '@mui/material'; import axios from 'axios'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; import { useGenerateDataset } from '@/hooks/useGenerateDataset'; import { toast } from 'sonner'; // 导入子组件 import TagTreeItem from './TagTreeItem'; import TagMenu from './TagMenu'; import TagEditDialog from './TagEditDialog'; import ConfirmDialog from './ConfirmDialog'; import { sortTagsByNumber } from './utils'; /** * 蒸馏树形视图组件 * @param {Object} props * @param {string} props.projectId - 项目ID * @param {Array} props.tags - 标签列表 * @param {Function} props.onGenerateSubTags - 生成子标签的回调函数 * @param {Function} props.onGenerateQuestions - 生成问题的回调函数 * @param {Function} props.onTagsUpdate - 标签更新的回调函数 */ const DistillTreeView = forwardRef(function DistillTreeView( { projectId, tags = [], onGenerateSubTags, onGenerateQuestions, onTagsUpdate }, ref ) { const { t } = useTranslation(); const selectedModel = useAtomValue(selectedModelInfoAtom); const [expandedTags, setExpandedTags] = useState({}); const [tagQuestions, setTagQuestions] = useState({}); const [loadingTags, setLoadingTags] = useState({}); const [loadingQuestions, setLoadingQuestions] = useState({}); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [selectedTagForMenu, setSelectedTagForMenu] = useState(null); const [allQuestions, setAllQuestions] = useState([]); const [loading, setLoading] = useState(false); const [processingQuestions, setProcessingQuestions] = useState({}); const [processingMultiTurnQuestions, setProcessingMultiTurnQuestions] = useState({}); const [deleteQuestionConfirmOpen, setDeleteQuestionConfirmOpen] = useState(false); const [questionToDelete, setQuestionToDelete] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [tagToDelete, setTagToDelete] = useState(null); const [editDialogOpen, setEditDialogOpen] = useState(false); const [tagToEdit, setTagToEdit] = useState(null); const [project, setProject] = useState(null); const [projectName, setProjectName] = useState(''); // 使用生成数据集的hook const { generateSingleDataset } = useGenerateDataset(); // 获取问题统计信息 const fetchQuestionsStats = useCallback(async () => { try { setLoading(true); const response = await axios.get(`/api/projects/${projectId}/questions/tree?isDistill=true`); setAllQuestions(response.data); console.log('获取问题统计信息成功:', { totalQuestions: response.data.length }); } catch (error) { console.error('获取问题统计信息失败:', error); } finally { setLoading(false); } }, [projectId]); // 暴露方法给父组件 useImperativeHandle(ref, () => ({ fetchQuestionsStats })); // 获取标签下的问题 const fetchQuestionsByTag = useCallback( async tagId => { try { setLoadingQuestions(prev => ({ ...prev, [tagId]: true })); const response = await axios.get(`/api/projects/${projectId}/distill/questions/by-tag?tagId=${tagId}`); setTagQuestions(prev => ({ ...prev, [tagId]: response.data })); } catch (error) { console.error('获取标签问题失败:', error); } finally { setLoadingQuestions(prev => ({ ...prev, [tagId]: false })); } }, [projectId] ); // 获取项目信息,获取项目名称 useEffect(() => { if (projectId) { axios .get(`/api/projects/${projectId}`) .then(response => { setProject(response.data); setProjectName(response.data.name || ''); }) .catch(error => { console.error('获取项目信息失败:', error); }); } }, [projectId]); // 初始化时获取问题统计信息 useEffect(() => { fetchQuestionsStats(); }, [fetchQuestionsStats]); // 构建标签树 const tagTree = useMemo(() => { const rootTags = []; const tagMap = {}; // 创建标签映射 tags.forEach(tag => { tagMap[tag.id] = { ...tag, children: [] }; }); // 构建树结构 tags.forEach(tag => { if (tag.parentId && tagMap[tag.parentId]) { tagMap[tag.parentId].children.push(tagMap[tag.id]); } else { rootTags.push(tagMap[tag.id]); } }); return rootTags; }, [tags]); // 切换标签展开/折叠状态 const toggleTag = useCallback( tagId => { setExpandedTags(prev => ({ ...prev, [tagId]: !prev[tagId] })); // 如果展开且还没有加载过问题,则加载问题 if (!expandedTags[tagId] && !tagQuestions[tagId]) { fetchQuestionsByTag(tagId); } }, [expandedTags, tagQuestions, fetchQuestionsByTag] ); // 处理菜单打开 const handleMenuOpen = (event, tag) => { event.stopPropagation(); setMenuAnchorEl(event.currentTarget); setSelectedTagForMenu(tag); }; // 处理菜单关闭 const handleMenuClose = () => { setMenuAnchorEl(null); setSelectedTagForMenu(null); }; // 打开编辑标签对话框 const openEditDialog = () => { setTagToEdit(selectedTagForMenu); setEditDialogOpen(true); handleMenuClose(); }; // 关闭编辑标签对话框 const closeEditDialog = () => { setEditDialogOpen(false); setTagToEdit(null); }; // 处理编辑标签成功 const handleEditTagSuccess = updatedTag => { // 更新标签数据,不刷新页面 const updateTagInTree = tagList => { return tagList.map(tag => { if (tag.id === updatedTag.id) { return { ...tag, label: updatedTag.label }; } if (tag.children && tag.children.length > 0) { return { ...tag, children: updateTagInTree(tag.children) }; } return tag; }); }; // 调用父组件的回调更新标签列表 const updatedTags = updateTagInTree(tags); onTagsUpdate?.(updatedTags); }; // 打开删除确认对话框 const openDeleteConfirm = () => { console.log('打开删除确认对话框', selectedTagForMenu); // 保存要删除的标签 setTagToDelete(selectedTagForMenu); setDeleteConfirmOpen(true); handleMenuClose(); }; // 关闭删除确认对话框 const closeDeleteConfirm = () => { setDeleteConfirmOpen(false); }; // 处理删除标签 const handleDeleteTag = () => { if (!tagToDelete) { console.log('没有要删除的标签信息'); return; } console.log('开始删除标签:', tagToDelete.id, tagToDelete.label); // 先关闭确认对话框 closeDeleteConfirm(); // 执行删除操作 const deleteTagAction = async () => { try { console.log('发送删除请求:', `/api/projects/${projectId}/tags?id=${tagToDelete.id}`); // 发送删除请求 const response = await axios.delete(`/api/projects/${projectId}/tags?id=${tagToDelete.id}`); console.log('删除标签成功:', response.data); // 刷新页面 window.location.reload(); } catch (error) { console.error('删除标签失败:', error); console.error('错误详情:', error.response ? error.response.data : '无响应数据'); alert(`删除标签失败: ${error.message}`); } }; // 立即执行删除操作 deleteTagAction(); }; // 打开删除问题确认对话框 const openDeleteQuestionConfirm = (questionId, event) => { event.stopPropagation(); setQuestionToDelete(questionId); setDeleteQuestionConfirmOpen(true); }; // 关闭删除问题确认对话框 const closeDeleteQuestionConfirm = () => { setDeleteQuestionConfirmOpen(false); setQuestionToDelete(null); }; // 处理删除问题 const handleDeleteQuestion = async () => { if (!questionToDelete) return; try { await axios.delete(`/api/projects/${projectId}/questions/${questionToDelete}`); // 更新问题列表 setTagQuestions(prev => { const newQuestions = { ...prev }; Object.keys(newQuestions).forEach(tagId => { newQuestions[tagId] = newQuestions[tagId].filter(q => q.id !== questionToDelete); }); return newQuestions; }); // 关闭确认对话框 closeDeleteQuestionConfirm(); } catch (error) { console.error('删除问题失败:', error); } }; // 处理生成数据集 const handleGenerateDataset = async (questionId, questionInfo, event) => { event.stopPropagation(); // 设置处理状态 setProcessingQuestions(prev => ({ ...prev, [questionId]: true })); await generateSingleDataset({ projectId, questionId, questionInfo }); // 重置处理状态 setProcessingQuestions(prev => ({ ...prev, [questionId]: false })); }; // 处理生成多轮对话数据集 const handleGenerateMultiTurnDataset = async (questionId, questionInfo, event) => { event.stopPropagation(); try { // 设置处理状态 setProcessingMultiTurnQuestions(prev => ({ ...prev, [questionId]: true })); // 首先检查项目是否配置了多轮对话设置 const configResponse = await axios.get(`/api/projects/${projectId}/tasks`); if (configResponse.status !== 200) { throw new Error('获取项目配置失败'); } const config = configResponse.data; const multiTurnConfig = { systemPrompt: config.multiTurnSystemPrompt, scenario: config.multiTurnScenario, rounds: config.multiTurnRounds, roleA: config.multiTurnRoleA, roleB: config.multiTurnRoleB }; // 检查是否已配置必要的多轮对话设置 if ( !multiTurnConfig.scenario || !multiTurnConfig.roleA || !multiTurnConfig.roleB || !multiTurnConfig.rounds || multiTurnConfig.rounds < 1 ) { throw new Error('请先在项目设置中配置多轮对话相关参数'); } // 检查是否选择了模型 if (!selectedModel || Object.keys(selectedModel).length === 0) { throw new Error('请先选择一个模型'); } // 调用多轮对话生成API const response = await axios.post(`/api/projects/${projectId}/dataset-conversations`, { questionId, ...multiTurnConfig, model: selectedModel, language: 'zh-CN' }); if (response.status === 200) { // 成功后刷新问题统计 fetchQuestionsStats(); toast.success(t('datasets.multiTurnGenerateSuccess', { defaultValue: '多轮对话数据集生成成功!' })); // 通知父组件刷新统计信息 if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('refreshDistillStats')); } } } catch (error) { console.error('生成多轮对话数据集失败:', error); toast.error(error.message || t('datasets.multiTurnGenerateError', { defaultValue: '生成多轮对话数据集失败' })); } finally { // 重置处理状态 setProcessingMultiTurnQuestions(prev => ({ ...prev, [questionId]: false })); } }; // 获取标签路径 const getTagPath = useCallback( tag => { if (!tag) return ''; const findPath = (currentTag, path = []) => { const newPath = [currentTag.label, ...path]; if (!currentTag.parentId) { // 如果是顶级标签,确保路径以项目名称开始 if (projectName && !newPath.includes(projectName)) { return [projectName, ...newPath]; } return newPath; } const parentTag = tags.find(t => t.id === currentTag.parentId); if (!parentTag) { // 如果没有找到父标签,确保路径以项目名称开始 if (projectName && !newPath.includes(projectName)) { return [projectName, ...newPath]; } return newPath; } return findPath(parentTag, newPath); }; const path = findPath(tag); // 最终检查,确保路径以项目名称开始 if (projectName && path.length > 0 && path[0] !== projectName) { path.unshift(projectName); } return path.join(' > '); }, [tags, projectName] ); // 渲染标签树 const renderTagTree = (tagList, level = 0) => { // 对同级标签进行排序 const sortedTagList = sortTagsByNumber(tagList); return ( {sortedTagList.map(tag => ( { // 包装函数,处理问题生成后的刷新 const handleGenerateQuestionsWithRefresh = async () => { // 调用父组件传入的函数生成问题 await onGenerateQuestions(tag, getTagPath(tag)); // 生成问题后刷新数据 await fetchQuestionsStats(); // 如果标签已展开,刷新该标签的问题详情 if (expandedTags[tag.id]) { await fetchQuestionsByTag(tag.id); } }; handleGenerateQuestionsWithRefresh(); }} onGenerateSubTags={tag => onGenerateSubTags(tag, getTagPath(tag))} questions={tagQuestions[tag.id] || []} loadingQuestions={loadingQuestions[tag.id]} processingQuestions={processingQuestions} processingMultiTurnQuestions={processingMultiTurnQuestions} onDeleteQuestion={openDeleteQuestionConfirm} onGenerateDataset={handleGenerateDataset} onGenerateMultiTurnDataset={handleGenerateMultiTurnDataset} allQuestions={allQuestions} tagQuestions={tagQuestions} > {/* 递归渲染子标签 */} {tag.children && tag.children.length > 0 && expandedTags[tag.id] && renderTagTree(tag.children, level + 1)} ))} ); }; return ( {tagTree.length > 0 ? ( renderTagTree(tagTree) ) : ( {t('distill.noTags')} )} {/* 标签操作菜单 */} {/* 编辑标签对话框 */} {/* 删除标签确认对话框 */} {/* 删除问题确认对话框 */} ); }); export default DistillTreeView; ================================================ FILE: components/distill/QuestionGenerationDialog.js ================================================ 'use client'; import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography, Box, CircularProgress, Alert, List, ListItem, ListItemText, Paper, IconButton, Divider } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import axios from 'axios'; import i18n from '@/lib/i18n'; /** * 问题生成对话框组件 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调函数 * @param {Function} props.onGenerated - 问题生成完成的回调函数 * @param {string} props.projectId - 项目ID * @param {Object} props.tag - 标签对象 * @param {string} props.tagPath - 标签路径 * @param {Object} props.model - 选择的模型配置 */ export default function QuestionGenerationDialog({ open, onClose, onGenerated, projectId, tag, tagPath, model }) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [count, setCount] = useState(5); const [generatedQuestions, setGeneratedQuestions] = useState([]); // 处理生成问题 const handleGenerateQuestions = async () => { try { setLoading(true); setError(''); const response = await axios.post(`/api/projects/${projectId}/distill/questions`, { tagPath, currentTag: tag.label, tagId: tag.id, count, model, language: i18n.language }); setGeneratedQuestions(response.data); } catch (error) { console.error('生成问题失败:', error); setError(error.response?.data?.error || t('distill.generateQuestionsError')); } finally { setLoading(false); } }; // 处理生成完成 const handleGenerateComplete = async () => { if (onGenerated) { onGenerated(generatedQuestions); } handleClose(); }; // 处理关闭对话框 const handleClose = () => { setGeneratedQuestions([]); setError(''); setCount(5); if (onClose) { onClose(); } }; // 处理数量变化 const handleCountChange = event => { const value = parseInt(event.target.value); if (!isNaN(value) && value >= 1 && value <= 100) { setCount(value); } }; return ( {t('distill.generateQuestionsTitle', { tag: tag?.label || t('distill.unknownTag') })} {error && ( {error} )} {t('distill.tagPath')}: {tagPath || tag?.label || t('distill.unknownTag')} {t('distill.questionCount')}: {generatedQuestions.length > 0 && ( {t('distill.generatedQuestions')}: {generatedQuestions.map((question, index) => ( {index > 0 && } ))} )} {generatedQuestions.length > 0 ? ( ) : ( )} ); } ================================================ FILE: components/distill/QuestionListItem.js ================================================ 'use client'; import { useState } from 'react'; import { ListItem, ListItemIcon, ListItemText, Box, Typography, Chip, IconButton, Tooltip, CircularProgress } from '@mui/material'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import DeleteIcon from '@mui/icons-material/Delete'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import ChatIcon from '@mui/icons-material/Chat'; import { useTranslation } from 'react-i18next'; /** * 问题列表项组件 * @param {Object} props * @param {Object} props.question - 问题对象 * @param {number} props.level - 缩进级别 * @param {Function} props.onDelete - 删除问题的回调 * @param {Function} props.onGenerateDataset - 生成数据集的回调 * @param {Function} props.onGenerateMultiTurnDataset - 生成多轮对话数据集的回调 * @param {boolean} props.processing - 是否正在处理 * @param {boolean} props.processingMultiTurn - 是否正在生成多轮对话 */ export default function QuestionListItem({ question, level, onDelete, onGenerateDataset, onGenerateMultiTurnDataset, processing = false, processingMultiTurn = false }) { const { t } = useTranslation(); return ( onGenerateDataset(e)} disabled={processing || processingMultiTurn} > {processing ? : } onGenerateMultiTurnDataset && onGenerateMultiTurnDataset(e)} disabled={processing || processingMultiTurn || !onGenerateMultiTurnDataset} > {processingMultiTurn ? : } onDelete(e)} disabled={processing || processingMultiTurn} > } > {question.question} {question.answered && ( )} } /> ); } ================================================ FILE: components/distill/TagEditDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, CircularProgress, Alert } from '@mui/material'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { toast } from 'sonner'; /** * 标签编辑对话框组件 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Object} props.tag - 要编辑的标签对象 * @param {string} props.projectId - 项目ID * @param {Function} props.onClose - 关闭对话框的回调 * @param {Function} props.onSuccess - 编辑成功的回调 */ export default function TagEditDialog({ open, tag, projectId, onClose, onSuccess }) { const { t } = useTranslation(); const [newLabel, setNewLabel] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); useEffect(() => { if (open && tag) { setNewLabel(tag.label); setError(''); } }, [open, tag]); const handleConfirm = async () => { if (!newLabel.trim()) { setError(t('distill.labelRequired')); return; } if (newLabel === tag.label) { onClose(); return; } try { setLoading(true); setError(''); const response = await axios.put(`/api/projects/${projectId}/distill/tags/${tag.id}`, { label: newLabel.trim() }); if (response.status === 200) { toast.success(t('distill.tagUpdateSuccess')); onSuccess?.(response.data); onClose(); } } catch (err) { console.error('更新标签失败:', err); setError(err.response?.data?.error || t('distill.tagUpdateFailed')); toast.error(err.response?.data?.error || t('distill.tagUpdateFailed')); } finally { setLoading(false); } }; const handleClose = () => { if (!loading) { onClose(); } }; return ( {t('distill.editTagTitle')} {error && ( {error} )} setNewLabel(e.target.value)} disabled={loading} autoFocus onKeyPress={e => { if (e.key === 'Enter' && !loading) { handleConfirm(); } }} /> ); } ================================================ FILE: components/distill/TagGenerationDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography, Box, CircularProgress, Alert, Chip, Paper, IconButton } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import axios from 'axios'; import i18n from '@/lib/i18n'; /** * 标签生成对话框组件 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调函数 * @param {Function} props.onGenerated - 标签生成完成的回调函数 * @param {string} props.projectId - 项目ID * @param {Object} props.parentTag - 父标签对象,为null时表示生成根标签 * @param {string} props.tagPath - 标签链路 * @param {Object} props.model - 选择的模型配置 */ export default function TagGenerationDialog({ open, onClose, onGenerated, projectId, parentTag, tagPath, model }) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [count, setCount] = useState(5); const [generatedTags, setGeneratedTags] = useState([]); const [parentTagName, setParentTagName] = useState(''); const [project, setProject] = useState(null); // 获取项目信息,如果是顶级标签,默认填写项目名称 useEffect(() => { if (projectId && !parentTag) { axios .get(`/api/projects/${projectId}`) .then(response => { setProject(response.data); setParentTagName(response.data.name || ''); }) .catch(error => { console.error('获取项目信息失败:', error); }); } else if (parentTag) { setParentTagName(parentTag.label || ''); } }, [projectId, parentTag]); // 处理生成标签 const handleGenerateTags = async () => { try { setLoading(true); setError(''); const response = await axios.post(`/api/projects/${projectId}/distill/tags`, { parentTag: parentTagName, parentTagId: parentTag ? parentTag.id : null, tagPath: tagPath || parentTagName, count, model, language: i18n.language }); setGeneratedTags(response.data); } catch (error) { console.error('生成标签失败:', error); setError(error.response?.data?.error || t('distill.generateTagsError')); } finally { setLoading(false); } }; // 处理生成完成 const handleGenerateComplete = async () => { if (onGenerated) { onGenerated(generatedTags); } handleClose(); }; // 处理关闭对话框 const handleClose = () => { setGeneratedTags([]); setError(''); setCount(5); if (onClose) { onClose(); } }; // 处理数量变化 const handleCountChange = event => { const value = parseInt(event.target.value); if (!isNaN(value) && value >= 1 && value <= 100) { setCount(value); } }; return ( {parentTag ? t('distill.generateSubTagsTitle', { parentTag: parentTag.label }) : t('distill.generateRootTagsTitle')} {error && ( {error} )} {/* 标签路径显示 */} {parentTag && tagPath && ( {t('distill.tagPath')}: {tagPath || parentTag.label} )} {t('distill.parentTag')}: setParentTagName(e.target.value)} placeholder={t('distill.parentTagPlaceholder')} disabled={loading || !parentTag} // 如果是顶级标签,设置为只读 InputProps={{ readOnly: !parentTag }} // 显示适当的帮助文本 helperText={ !parentTag ? t('distill.rootTopicHelperText', { defaultValue: '使用项目名称作为顶级主题' }) : t('distill.parentTagHelp') } /> {t('distill.tagCount')}: {generatedTags.length > 0 && ( {t('distill.generatedTags')}: {generatedTags.map((tag, index) => ( ))} )} {generatedTags.length > 0 ? ( ) : ( )} ); } ================================================ FILE: components/distill/TagMenu.js ================================================ 'use client'; import { Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import { useTranslation } from 'react-i18next'; /** * 标签操作菜单组件 * @param {Object} props * @param {HTMLElement} props.anchorEl - 菜单锚点元素 * @param {boolean} props.open - 菜单是否打开 * @param {Function} props.onClose - 关闭菜单的回调 * @param {Function} props.onEdit - 编辑操作的回调 * @param {Function} props.onDelete - 删除操作的回调 */ export default function TagMenu({ anchorEl, open, onClose, onEdit, onDelete }) { const { t } = useTranslation(); const handleEdit = () => { onEdit?.(); onClose(); }; const handleDelete = () => { onDelete?.(); onClose(); }; return ( {t('common.edit')} {t('common.delete')} ); } ================================================ FILE: components/distill/TagTreeItem.js ================================================ 'use client'; import { useState } from 'react'; import { Box, Typography, ListItem, ListItemButton, ListItemIcon, ListItemText, IconButton, Collapse, Chip, Tooltip, List, CircularProgress } from '@mui/material'; import FolderIcon from '@mui/icons-material/Folder'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import AddIcon from '@mui/icons-material/Add'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useTranslation } from 'react-i18next'; import QuestionListItem from './QuestionListItem'; /** * 标签树项组件 * @param {Object} props * @param {Object} props.tag - 标签对象 * @param {number} props.level - 缩进级别 * @param {boolean} props.expanded - 是否展开 * @param {Function} props.onToggle - 切换展开/折叠的回调 * @param {Function} props.onMenuOpen - 打开菜单的回调 * @param {Function} props.onGenerateQuestions - 生成问题的回调 * @param {Function} props.onGenerateSubTags - 生成子标签的回调 * @param {Array} props.questions - 标签下的问题列表 * @param {boolean} props.loadingQuestions - 是否正在加载问题 * @param {Object} props.processingQuestions - 正在处理的问题ID映射 * @param {Function} props.onDeleteQuestion - 删除问题的回调 * @param {Function} props.onGenerateDataset - 生成数据集的回调 * @param {Function} props.onGenerateMultiTurnDataset - 生成多轮对话数据集的回调 * @param {Object} props.processingMultiTurnQuestions - 正在生成多轮对话的问题ID映射 * @param {Array} props.allQuestions - 所有问题列表(用于计算问题数量) * @param {Object} props.tagQuestions - 标签问题映射 * @param {React.ReactNode} props.children - 子标签内容 */ export default function TagTreeItem({ tag, level = 0, expanded = false, onToggle, onMenuOpen, onGenerateQuestions, onGenerateSubTags, questions = [], loadingQuestions = false, processingQuestions = {}, onDeleteQuestion, onGenerateDataset, onGenerateMultiTurnDataset, processingMultiTurnQuestions = {}, allQuestions = [], tagQuestions = {}, children }) { const { t } = useTranslation(); // 递归计算所有层级的子标签数量 const getTotalSubTagsCount = childrenTags => { let count = childrenTags.length; childrenTags.forEach(childTag => { if (childTag.children && childTag.children.length > 0) { count += getTotalSubTagsCount(childTag.children); } }); return count; }; // 递归获取所有子标签的问题数量 const getChildrenQuestionsCount = childrenTags => { let count = 0; childrenTags.forEach(childTag => { // 子标签的问题 if (tagQuestions[childTag.id] && tagQuestions[childTag.id].length > 0) { count += tagQuestions[childTag.id].length; } else { count += allQuestions.filter(q => q.label === childTag.label).length; } // 子标签的子标签的问题 if (childTag.children && childTag.children.length > 0) { count += getChildrenQuestionsCount(childTag.children); } }); return count; }; // 计算当前标签的问题数量 const getCurrentTagQuestionsCount = () => { let currentTagQuestions = 0; if (tagQuestions[tag.id] && tagQuestions[tag.id].length > 0) { currentTagQuestions = tagQuestions[tag.id].length; } else { currentTagQuestions = allQuestions.filter(q => q.label === tag.label).length; } return currentTagQuestions; }; // 总问题数量 = 当前标签的问题 + 所有子标签的问题 const totalQuestions = getCurrentTagQuestionsCount() + (tag.children ? getChildrenQuestionsCount(tag.children || []) : 0); return ( 0 ? '1px dashed rgba(0, 0, 0, 0.1)' : 'none', ml: level > 0 ? 2 : 0 }} > onToggle(tag.id)} sx={{ borderRadius: 1, py: 0.5 }}> {tag.label} {tag.children && tag.children.length > 0 && ( )} {totalQuestions > 0 && ( )} } primaryTypographyProps={{ component: 'div' }} /> { e.stopPropagation(); onGenerateQuestions(tag); }} > { e.stopPropagation(); onGenerateSubTags(tag); }} > onMenuOpen(e, tag)}> {tag.children && tag.children.length > 0 ? ( expanded ? ( ) : ( ) ) : null} {/* 子标签 */} {tag.children && tag.children.length > 0 && ( {children} )} {/* 标签下的问题 */} {expanded && ( {loadingQuestions ? ( {t('common.loading')} ) : questions && questions.length > 0 ? ( questions.map(question => ( onDeleteQuestion(question.id, e)} onGenerateDataset={e => onGenerateDataset(question.id, question.question, e)} onGenerateMultiTurnDataset={ onGenerateMultiTurnDataset ? e => onGenerateMultiTurnDataset(question.id, question, e) : undefined } /> )) ) : ( {t('distill.noQuestions')} )} )} ); } ================================================ FILE: components/distill/utils.js ================================================ 'use client'; /** * 按照标签前面的序号对标签进行排序 * @param {Array} tags - 标签数组 * @returns {Array} 排序后的标签数组 */ export const sortTagsByNumber = tags => { return [...tags].sort((a, b) => { // 提取标签前面的序号 const getNumberPrefix = label => { // 匹配形如 1, 1.1, 1.1.2 的序号 const match = label.match(/^([\d.]+)\s/); if (match) { return match[1]; // 返回完整的序号字符串,如 "1.10" } return null; // 没有序号 }; const aPrefix = getNumberPrefix(a.label); const bPrefix = getNumberPrefix(b.label); // 如果两个标签都有序号,按序号比较 if (aPrefix && bPrefix) { // 将序号分解为数组,然后按数值比较 const aParts = aPrefix.split('.').map(num => parseInt(num, 10)); const bParts = bPrefix.split('.').map(num => parseInt(num, 10)); // 比较序号数组 for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) { if (aParts[i] !== bParts[i]) { return aParts[i] - bParts[i]; // 数值比较,确保 1.2 排在 1.10 前面 } } // 如果前面的数字都相同,则较短的序号在前 return aParts.length - bParts.length; } // 如果只有一个标签有序号,则有序号的在前 else if (aPrefix) { return -1; } else if (bPrefix) { return 1; } // 如果都没有序号,则按原来的字母序排序 else { return a.label.localeCompare(b.label, 'zh-CN'); } }); }; /** * 获取标签的完整路径 * @param {Object} tag - 标签对象 * @param {Array} allTags - 所有标签数组 * @returns {string} 标签路径,如 "标签1 > 标签2 > 标签3" */ export const getTagPath = (tag, allTags) => { if (!tag) return ''; const findPath = (currentTag, path = []) => { const newPath = [currentTag.label, ...path]; if (!currentTag.parentId) return newPath; const parentTag = allTags.find(t => t.id === currentTag.parentId); if (!parentTag) return newPath; return findPath(parentTag, newPath); }; return findPath(tag).join(' > '); }; ================================================ FILE: components/export/HuggingFaceTab.js ================================================ // HuggingFaceTab.js 组件 import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Typography, Box, TextField, Button, FormControlLabel, Checkbox, Alert, CircularProgress, Divider, Paper, Grid, Tooltip, IconButton, Link } from '@mui/material'; import InfoIcon from '@mui/icons-material/Info'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; const HuggingFaceTab = ({ projectId, systemPrompt, reasoningLanguage, confirmedOnly, includeCOT, formatType, fileFormat, customFields, handleSystemPromptChange, handleReasoningLanguageChange, handleConfirmedOnlyChange, handleIncludeCOTChange }) => { const { t } = useTranslation(); const [token, setToken] = useState(''); const [datasetName, setDatasetName] = useState(''); const [isPrivate, setIsPrivate] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [datasetUrl, setDatasetUrl] = useState(''); const [hasToken, setHasToken] = useState(false); const [loading, setLoading] = useState(true); // 从配置中获取 huggingfaceToken useEffect(() => { if (projectId) { setLoading(true); fetch(`/api/projects/${projectId}/config`) .then(res => res.json()) .then(data => { if (data.huggingfaceToken) { setToken(data.huggingfaceToken); setHasToken(true); } setLoading(false); }) .catch(err => { console.error('获取 HuggingFace Token 失败:', err); setLoading(false); }); } }, [projectId]); // 处理上传数据集到 HuggingFace const handleUpload = async () => { if (!hasToken) { return; } if (!datasetName) { setError('请输入数据集名称'); return; } try { setUploading(true); setError(''); setSuccess(false); const response = await fetch(`/api/projects/${projectId}/huggingface/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, datasetName, isPrivate, formatType, systemPrompt, reasoningLanguage, confirmedOnly, includeCOT, fileFormat, customFields: formatType === 'custom' ? customFields : undefined }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || '上传失败'); } setSuccess(true); setDatasetUrl(data.url); } catch (err) { setError(err.message); } finally { setUploading(false); } }; return ( {error && ( {error} )} {success && ( }> {t('export.uploadSuccess')} {datasetUrl && ( {t('export.viewOnHuggingFace')} )} )} {!hasToken ? ( {t('export.noTokenWarning')} ) : null} {t('export.datasetSettings')} setDatasetName(e.target.value)} helperText={t('export.datasetNameHelp')} sx={{ mb: 2 }} /> setIsPrivate(e.target.checked)} />} label={t('export.privateDataset')} /> {t('export.exportOptions')} {t('export.systemPrompt')} {/* Reasoning language – only for multilingual‑thinking */} {formatType === 'multilingualthinking' && ( {t('export.reasoningLanguage')} )} } label={t('export.onlyConfirmed')} /> } label={t('export.includeCOT')} /> ); }; export default HuggingFaceTab; ================================================ FILE: components/export/LlamaFactoryTab.js ================================================ // LlamaFactoryTab.js 组件 import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, FormControlLabel, Checkbox, Typography, Box, TextField, Alert, CircularProgress, IconButton, Tooltip } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import CheckIcon from '@mui/icons-material/Check'; const LlamaFactoryTab = ({ projectId, systemPrompt, reasoningLanguage, confirmedOnly, includeCOT, formatType, handleSystemPromptChange, handleReasoningLanguageChange, handleConfirmedOnlyChange, handleIncludeCOTChange }) => { const { t } = useTranslation(); const [configExists, setConfigExists] = useState(false); const [configPath, setConfigPath] = useState(''); const [generating, setGenerating] = useState(false); const [error, setError] = useState(''); const [copied, setCopied] = useState(false); // 检查配置文件是否存在 useEffect(() => { if (projectId) { fetch(`/api/projects/${projectId}/llamaFactory/checkConfig`) .then(res => res.json()) .then(data => { setConfigExists(data.exists); if (data.exists) { setConfigPath(data.configPath); } }) .catch(err => { setError(err.message); }); } }, [projectId, configExists]); // 复制路径到剪贴板 const handleCopyPath = () => { const path = configPath.replace('dataset_info.json', ''); navigator.clipboard.writeText(path).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; // 处理生成 Llama Factory 配置 const handleGenerateConfig = async () => { try { setGenerating(true); setError(''); const response = await fetch(`/api/projects/${projectId}/llamaFactory/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ formatType, systemPrompt, reasoningLanguage, confirmedOnly, includeCOT }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error); } setConfigExists(true); } catch (err) { setError(err.message); } finally { setGenerating(false); } }; return ( {error && ( {error} )} {t('export.systemPrompt')} {/* Reasoning language – only for multilingual‑thinking */} {formatType === 'multilingualthinking' && ( {t('export.reasoningLanguage')} )} } label={t('export.onlyConfirmed')} /> } label={t('export.includeCOT')} /> {configExists ? ( <> {t('export.configExists')} {t('export.configPath')}: {configPath.replace('dataset_info.json', '')} {copied ? : } ) : ( {t('export.noConfig')} )} ); }; export default LlamaFactoryTab; ================================================ FILE: components/export/LocalExportTab.js ================================================ // LocalExportTab.js 组件 import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, FormControl, FormControlLabel, RadioGroup, Radio, TextField, Checkbox, Typography, Box, Paper, useTheme, Grid, Table, TableRow, TableHead, TableBody, TableCell, TableContainer, Dialog, DialogTitle, DialogContent, DialogActions, Chip, Alert, CircularProgress } from '@mui/material'; const LocalExportTab = ({ fileFormat, formatType, systemPrompt, confirmedOnly, includeCOT, customFields, alpacaFieldType, customInstruction, reasoningLanguage, handleFileFormatChange, handleFormatChange, handleSystemPromptChange, handleReasoningLanguageChange, handleConfirmedOnlyChange, handleIncludeCOTChange, handleCustomFieldChange, handleIncludeLabelsChange, handleIncludeChunkChange, handleQuestionOnlyChange, handleAlpacaFieldTypeChange, handleCustomInstructionChange, handleExport, projectId }) => { const theme = useTheme(); const { t } = useTranslation(); // Balance export related state const [balanceDialogOpen, setBalanceDialogOpen] = useState(false); const [tagStats, setTagStats] = useState([]); const [balanceConfig, setBalanceConfig] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [totalCount, setTotalCount] = useState(0); // Get label statistics (changed to GET + query parameters) const fetchTagStats = async () => { try { setLoading(true); const url = `/api/projects/${projectId}/datasets/export?confirmed=${confirmedOnly ? 'true' : 'false'}`; const response = await fetch(url, { method: 'GET' }); if (!response.ok) { throw new Error(t('errors.getTagStatsFailed')); } const stats = await response.json(); setTagStats(stats); // 初始化平衡配置 const initialConfig = stats.map(stat => ({ tagLabel: stat.tagLabel, maxCount: Math.min(stat.datasetCount, 100), // 默认最多100条 availableCount: stat.datasetCount })); setBalanceConfig(initialConfig); // 计算总数 const total = initialConfig.reduce((sum, config) => sum + config.maxCount, 0); setTotalCount(total); } catch (err) { setError(err.message); } finally { setLoading(false); } }; // 打开平衡导出对话框 const handleOpenBalanceDialog = () => { setBalanceDialogOpen(true); fetchTagStats(); }; // 更新单个标签的数量配置 const updateBalanceConfig = (tagLabel, newCount) => { const newConfig = balanceConfig.map(config => { if (config.tagLabel === tagLabel) { const count = Math.min(Math.max(0, parseInt(newCount) || 0), config.availableCount); return { ...config, maxCount: count }; } return config; }); setBalanceConfig(newConfig); // 重新计算总数 const total = newConfig.reduce((sum, config) => sum + config.maxCount, 0); setTotalCount(total); }; // 一键设置所有标签为相同数量 const setAllToSameCount = count => { const newConfig = balanceConfig.map(config => ({ ...config, maxCount: Math.min(Math.max(0, parseInt(count) || 0), config.availableCount) })); setBalanceConfig(newConfig); const total = newConfig.reduce((sum, config) => sum + config.maxCount, 0); setTotalCount(total); }; // 处理平衡导出 const handleBalancedExport = () => { // 过滤出数量大于0的配置 const validConfig = balanceConfig.filter(config => config.maxCount > 0); if (validConfig.length === 0) { setError(t('export.balancedExport.atLeastOneTag', '请至少为一个标签设置大于0的数量')); return; } // 调用原有的导出函数,但传递平衡配置 handleExport({ balanceMode: true, balanceConfig: validConfig, formatType, systemPrompt, reasoningLanguage, confirmedOnly, fileFormat, includeCOT, alpacaFieldType, customInstruction, customFields: formatType === 'custom' ? customFields : undefined }); setBalanceDialogOpen(false); }; // 自定义格式的示例 const getCustomFormatExample = () => { const { questionField, answerField, cotField, includeLabels, includeChunk } = customFields; const example = { [questionField]: t('sampleData.questionContent'), [answerField]: t('sampleData.answerContent') }; // 如果包含思维链字段,添加到示例中 if (includeCOT) { example[cotField] = t('sampleData.cotContent'); } if (includeLabels) { example.labels = [t('sampleData.domainLabel')]; } if (includeChunk) { example.chunk = t('sampleData.textChunk'); } return fileFormat === 'json' ? JSON.stringify([example], null, 2) : JSON.stringify(example); }; // CSV 自定义格式化示例 const getPreviewData = () => { if (formatType === 'alpaca') { // 根据选择的字段类型生成不同的示例 if (alpacaFieldType === 'instruction') { return { headers: ['instruction', 'input', 'output', 'system'], rows: [ { instruction: t('export.sampleInstruction', '人类指令(必填)'), input: '', output: t('export.sampleOutput', '模型回答(必填)'), system: t('export.sampleSystem', '系统提示词(选填)') }, { instruction: t('export.sampleInstruction2', '第二个指令'), input: '', output: t('export.sampleOutput2', '第二个回答'), system: t('export.sampleSystemShort', '系统提示词') } ] }; } else { // input return { headers: ['instruction', 'input', 'output', 'system'], rows: [ { instruction: customInstruction || t('export.fixedInstruction', '固定的指令内容'), input: t('export.sampleInput', '人类问题(必填)'), output: t('export.sampleOutput', '模型回答(必填)'), system: t('export.sampleSystem', '系统提示词(选填)') }, { instruction: customInstruction || t('export.fixedInstruction', '固定的指令内容'), input: t('export.sampleInput2', '第二个问题'), output: t('export.sampleOutput2', '第二个回答'), system: t('export.sampleSystemShort', '系统提示词') } ] }; } } else if (formatType === 'sharegpt') { return { headers: ['messages'], rows: [ { messages: JSON.stringify( [ { messages: [ { role: 'system', content: t('export.sampleSystem', '系统提示词(选填)') }, { role: 'user', content: t('export.sampleUserMessage', '人类指令') // 映射到 question 字段 }, { role: 'assistant', content: t('export.sampleAssistantMessage', '模型回答') // 映射到 cot+answer 字段 } ] } ], null, 2 ) } ] }; } else if (formatType === 'multilingualthinking') { return { headers: 'messages', rows: { messages: JSON.stringify( { reasoning_language: 'English', developer: t('export.sampleSystem', '系统提示词(选填)'), user: t('export.sampleUserMessage', '人类指令'), // 映射到 question 字段 analysis: t('export.sampleAnalysis', '模型的思维链内容'), // 映射到 cot 字段 final: t('export.sampleFinal', '模型回答'), // 映射到 answer 字段 messages: [ { role: 'system', content: '系统提示词(选填)', thinking: 'null' }, { role: 'user', content: '人类指令', // 映射到 question 字段 thinking: 'null' }, { role: 'assistant', content: '模型回答', // 映射到 answer 字段 thinking: '模型的思维链内容' // 映射到 cot 字段 } ] }, null, 2 ) } }; } else if (formatType === 'custom') { // 如果选择仅导出问题,只包含问题字段 if (customFields.questionOnly) { const headers = [customFields.questionField]; if (customFields.includeLabels) headers.push('labels'); if (customFields.includeChunk) headers.push('chunk'); const row = { [customFields.questionField]: t('sampleData.questionContent') }; if (customFields.includeLabels) row.labels = t('sampleData.domainLabel'); if (customFields.includeChunk) row.chunk = t('sampleData.textChunk'); return { headers, rows: [row] }; } else { // 正常的自定义格式 const headers = [customFields.questionField, customFields.answerField]; if (includeCOT) headers.push(customFields.cotField); if (customFields.includeLabels) headers.push('labels'); if (customFields.includeChunk) headers.push('chunk'); const row = { [customFields.questionField]: t('sampleData.questionContent'), [customFields.answerField]: t('sampleData.answerContent') }; if (includeCOT) row[customFields.cotField] = t('sampleData.cotContent'); if (customFields.includeLabels) row.labels = t('sampleData.domainLabel'); if (customFields.includeChunk) row.chunk = t('sampleData.textChunk'); return { headers, rows: [row] }; } } }; return ( <> {t('export.fileFormat')} } label="JSON" /> } label="JSONL" /> {/* } label="CSV" /> */} } label="CSV" /> {/* 数据集风格 */} {t('export.format')} } label="Alpaca" /> } label="ShareGPT" /> {/* NEW: Multilingual‑Thinking format */} } label={t('export.multilingualThinkingFormat') || 'Multilingual‑Thinking'} /> } label={t('export.customFormat')} /> {/* Alpaca 格式特有的设置 */} {formatType === 'alpaca' && ( {t('export.alpacaSettings', 'Alpaca 格式设置')} {t('export.questionFieldType', '问题字段类型')} } label={t('export.useInstruction', '使用 instruction 字段')} /> } label={t('export.useInput', '使用 input 字段')} /> {alpacaFieldType === 'input' && ( )} )} {/* 自定义格式选项 */} {formatType === 'custom' && ( {t('export.customFormatSettings')} {/* 添加思维链字段名输入框 */} } label={t('export.includeLabels')} /> } label={t('export.includeChunk')} /> } label={t('export.questionOnly')} /> )} {t('export.example')} {fileFormat === 'csv' ? ( {(() => { const { headers, rows } = getPreviewData(); const tableKey = `${formatType}-${fileFormat}-${JSON.stringify(customFields)}`; return ( {headers.map(header => ( {header} ))} {rows.map((row, index) => ( {headers.map(header => ( {Array.isArray(row[header]) ? row[header].join(', ') : row[header] || ''} ))} ))}
); })()}
) : (
              {formatType === 'custom'
                ? getCustomFormatExample()
                : formatType === 'multilingualthinking'
                  ? fileFormat === 'json'
                    ? JSON.stringify(
                        {
                          reasoning_language: 'English',
                          developer: '系统提示词(选填)',
                          user: '人类指令', // 映射到 question 字段
                          analysis: '模型的思维链内容', // 映射到 cot 字段
                          final: '模型回答', // 映射到 answer 字段
                          messages: [
                            {
                              content: t('export.sampleSystem', '系统提示词(选填)'),
                              role: 'system',
                              thinking: null
                            },
                            {
                              content: t('export.sampleUserMessage', '人类指令'),
                              role: 'user',
                              thinking: null
                            },
                            {
                              content: t('export.sampleAssistantMessage', '模型回答'),
                              role: 'assistant',
                              thinking: t('export.sampleThinking', '模型的思维链内容')
                            }
                          ]
                        },
                        null,
                        2
                      )
                    : '{"reasoning_language": "English","developer": "系统提示词(选填)", "user": "人类指令", "analysis": "模型的思维链内容", "final": "模型回答", "messages": [{"role": "user", "content": "人类指令", "thinking": "null"}, {"role": "assistant", "content": "模型回答", "thinking": "模型的思维链内容"}]}'
                  : formatType === 'alpaca'
                    ? fileFormat === 'json'
                      ? JSON.stringify(
                          [
                            {
                              instruction: t('export.sampleInstruction', '人类指令(必填)'), // 映射到 question 字段
                              input: t('export.sampleInputOptional', '人类输入(选填)'),
                              output: t('export.sampleOutput', '模型回答(必填)'), // 映射到 cot+answer 字段
                              system: t('export.sampleSystem', '系统提示词(选填)')
                            }
                          ],
                          null,
                          2
                        )
                      : '{"instruction": "人类指令(必填)", "input": "人类输入(选填)", "output": "模型回答(必填)", "system": "系统提示词(选填)"}\n{"instruction": "第二个指令", "input": "", "output": "第二个回答", "system": "系统提示词"}'
                    : fileFormat === 'json'
                      ? JSON.stringify(
                          [
                            {
                              messages: [
                                {
                                  role: 'system',
                                  content: t('export.sampleSystem', '系统提示词(选填)')
                                },
                                {
                                  role: 'user',
                                  content: t('export.sampleUserMessage', '人类指令') // 映射到 question 字段
                                },
                                {
                                  role: 'assistant',
                                  content: t('export.sampleAssistantMessage', '模型回答') // 映射到 cot+answer 字段
                                }
                              ]
                            }
                          ],
                          null,
                          2
                        )
                      : '{"messages": [{"role": "system", "content": "系统提示词(选填)"}, {"role": "user", "content": "人类指令"}, {"role": "assistant", "content": "模型回答"}]}\n{"messages": [{"role": "user", "content": "第二个问题"}, {"role": "assistant", "content": "第二个回答"}]}'}
            
)}
{t('export.systemPrompt')} {/* Reasoning language – only for multilingual‑thinking */} {formatType === 'multilingualthinking' && ( {t('export.Reasoninglanguage')} )} } label={t('export.onlyConfirmed')} /> } label={t('export.includeCOT')} /> {/* 平衡导出对话框 */} setBalanceDialogOpen(false)} maxWidth="md" fullWidth PaperProps={{ sx: { borderRadius: 2 } }} > {t('exportDialog.balancedExportTitle')} {t('exportDialog.balancedExportDescription')} {error && ( {error} )} {loading ? ( ) : ( <> {/* 批量设置 */} {t('exportDialog.quickSettings')} { if (e.key === 'Enter') { setAllToSameCount(e.target.value); e.target.value = ''; } }} /> {/* 标签配置表格 */} {t('exportDialog.tagName')} {t('exportDialog.availableCount')} {t('exportDialog.exportCount')} {t('exportDialog.settings')} {balanceConfig.map(config => ( {config.availableCount} {config.maxCount} updateBalanceConfig(config.tagLabel, e.target.value)} inputProps={{ min: 0, max: config.availableCount, style: { textAlign: 'right' } }} sx={{ width: 80 }} /> ))}
{/* 统计信息 */} {t('exportDialog.totalExportCount')}: {totalCount} {' '} | {t('exportDialog.tagCount')}: {balanceConfig.filter(c => c.maxCount > 0).length} /{' '} {balanceConfig.length} )}
); }; export default LocalExportTab; ================================================ FILE: components/home/CreateProjectDialog.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Box, Typography, useTheme, CircularProgress, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; export default function CreateProjectDialog({ open, onClose }) { const { t } = useTranslation(); const theme = useTheme(); const router = useRouter(); const [loading, setLoading] = useState(false); const [projects, setProjects] = useState([]); const [formData, setFormData] = useState({ name: '', description: '', reuseConfigFrom: '' }); const [error, setError] = useState(null); // 获取项目列表 useEffect(() => { const fetchProjects = async () => { try { const response = await fetch('/api/projects'); if (response.ok) { const data = await response.json(); setProjects(data); } } catch (error) { console.error('获取项目列表失败:', error); } }; fetchProjects(); }, []); const handleChange = e => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); }; const handleSubmit = async e => { e.preventDefault(); setLoading(true); setError(null); try { const response = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) }); if (!response.ok) { throw new Error(t('projects.createFailed')); } const data = await response.json(); router.push(`/projects/${data.id}/settings?tab=model`); } catch (err) { console.error(t('projects.createError'), err); setError(err.message); } finally { setLoading(false); } }; return ( {t('projects.createNew')}
{t('projects.reuseConfig')} {error && ( {error} )}
); } ================================================ FILE: components/home/HeroSection.js ================================================ 'use client'; import { Box, Container, Typography, Button, useMediaQuery } from '@mui/material'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import SearchIcon from '@mui/icons-material/Search'; import { styles } from '@/styles/home'; import { useTheme } from '@mui/material'; import { motion } from 'framer-motion'; import ParticleBackground from './ParticleBackground'; import { useTranslation } from 'react-i18next'; export default function HeroSection({ onCreateProject }) { const { t } = useTranslation(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); return ( {/* 添加粒子背景 */} {t('home.title')} {t('home.subtitle')} ); } ================================================ FILE: components/home/MigrationDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, CircularProgress, List, ListItem, ListItemText, ListItemSecondaryAction, IconButton, Alert, Paper, useTheme, Tooltip } from '@mui/material'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import DeleteIcon from '@mui/icons-material/Delete'; import { useTranslation } from 'react-i18next'; /** * 项目迁移对话框组件 * @param {Object} props - 组件属性 * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调函数 * @param {Array} props.projectIds - 需要迁移的项目ID列表 */ export default function MigrationDialog({ open, onClose, projectIds = [] }) { const { t } = useTranslation(); const theme = useTheme(); const [migrating, setMigrating] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); const [migratedCount, setMigratedCount] = useState(0); const [taskId, setTaskId] = useState(null); const [progress, setProgress] = useState(0); const [statusText, setStatusText] = useState(''); const [processingIds, setProcessingIds] = useState([]); // 打开项目目录 const handleOpenDirectory = async projectId => { try { setProcessingIds(prev => [...prev, projectId]); const response = await fetch('/api/projects/open-directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId }) }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || t('migration.openDirectoryFailed')); } // 成功打开目录,不需要特别处理 } catch (err) { console.error('打开目录错误:', err); setError(err.message); } finally { setProcessingIds(prev => prev.filter(id => id !== projectId)); } }; // 删除项目目录 const handleDeleteDirectory = async projectId => { try { if (!window.confirm(t('migration.confirmDelete'))) { return; } setProcessingIds(prev => [...prev, projectId]); const response = await fetch('/api/projects/delete-directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId }) }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || t('migration.deleteDirectoryFailed')); } // 从列表中移除已删除的项目 const updatedProjectIds = projectIds.filter(id => id !== projectId); // 这里我们不能直接修改 projectIds,因为它是从父组件传入的 // 但我们可以通知用户界面刷新 window.location.reload(); } catch (err) { console.error('删除目录错误:', err); setError(err.message); } finally { setProcessingIds(prev => prev.filter(id => id !== projectId)); } }; // 处理迁移操作 const handleMigration = async () => { try { setMigrating(true); setError(null); setSuccess(false); setProgress(0); setStatusText(t('migration.starting')); // 调用异步迁移接口启动迁移任务 const response = await fetch('/api/projects/migrate', { method: 'POST' }); if (!response.ok) { throw new Error(t('migration.failed')); } const { success, taskId: newTaskId } = await response.json(); if (!success || !newTaskId) { throw new Error(t('migration.startFailed')); } // 保存任务ID setTaskId(newTaskId); setStatusText(t('migration.processing')); // 开始轮询任务状态 await pollMigrationStatus(newTaskId); } catch (err) { console.error('迁移错误:', err); setError(err.message); setMigrating(false); } }; // 轮询迁移任务状态 const pollMigrationStatus = async id => { try { // 定义轮询间隔(毫秒) const pollInterval = 1000; // 发送请求获取任务状态 const response = await fetch(`/api/projects/migrate?taskId=${id}`); if (!response.ok) { throw new Error(t('migration.statusFailed')); } const { success, task } = await response.json(); if (!success || !task) { throw new Error(t('migration.taskNotFound')); } // 更新进度 setProgress(task.progress || 0); // 根据任务状态更新UI if (task.status === 'completed') { // 任务完成 setMigratedCount(task.completed); setSuccess(true); setMigrating(false); setStatusText(t('migration.completed')); // 迁移成功后,延迟关闭对话框并刷新页面 setTimeout(() => { onClose(); window.location.reload(); }, 2000); } else if (task.status === 'failed') { // 任务失败 throw new Error(task.error || t('migration.failed')); } else { // 任务仍在进行中,继续轮询 setTimeout(() => pollMigrationStatus(id), pollInterval); // 更新状态文本 if (task.total > 0) { setStatusText( t('migration.progressStatus', { completed: task.completed || 0, total: task.total }) ); } } } catch (err) { console.error('获取迁移状态错误:', err); setError(err.message); setMigrating(false); } }; return ( {t('migration.title')} {success ? ( {t('migration.success', { count: migratedCount })} ) : error ? ( {error} ) : null} {t('migration.description')} {projectIds.length > 0 && ( {t('migration.projectsList')}: {projectIds.map(id => ( handleOpenDirectory(id)} disabled={processingIds.includes(id)} size="small" > handleDeleteDirectory(id)} disabled={processingIds.includes(id)} size="small" sx={{ ml: 1, color: 'error.main' }} > ))} )} {migrating && ( 0 ? 'determinate' : 'indeterminate'} value={progress} /> {statusText || t('migration.migrating')} {progress > 0 && ( {progress}% )} )} ); } ================================================ FILE: components/home/ParticleBackground.js ================================================ 'use client'; import { useEffect, useRef } from 'react'; import { useTheme } from '@mui/material'; export default function ParticleBackground() { const canvasRef = useRef(null); const theme = useTheme(); useEffect(() => { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); let animationFrameId; let particles = []; let mousePosition = { x: 0, y: 0 }; let hoverRadius = 150; // 增加鼠标影响范围 let mouseSpeed = { x: 0, y: 0 }; // 跟踪鼠标速度 let lastMousePosition = { x: 0, y: 0 }; // 上一帧鼠标位置 // 设置画布大小为窗口大小 const handleResize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; initParticles(); }; // 跟踪鼠标位置和速度 const handleMouseMove = event => { // 计算鼠标速度 mouseSpeed.x = event.clientX - mousePosition.x; mouseSpeed.y = event.clientY - mousePosition.y; // 更新鼠标位置 lastMousePosition.x = mousePosition.x; lastMousePosition.y = mousePosition.y; mousePosition.x = event.clientX; mousePosition.y = event.clientY; }; // 触摸设备支持 const handleTouchMove = event => { if (event.touches.length > 0) { // 计算触摸速度 mouseSpeed.x = event.touches[0].clientX - mousePosition.x; mouseSpeed.y = event.touches[0].clientY - mousePosition.y; // 更新触摸位置 lastMousePosition.x = mousePosition.x; lastMousePosition.y = mousePosition.y; mousePosition.x = event.touches[0].clientX; mousePosition.y = event.touches[0].clientY; } }; // 生成随机颜色 const getRandomColor = () => { // 主题色调 const colors = theme.palette.mode === 'dark' ? [ 'rgba(255, 255, 255, 0.5)', // 白色 'rgba(100, 181, 246, 0.5)', // 蓝色 'rgba(156, 39, 176, 0.4)', // 紫色 'rgba(121, 134, 203, 0.5)' // 靛蓝色 ] : [ 'rgba(42, 92, 170, 0.5)', // 主蓝色 'rgba(66, 165, 245, 0.4)', // 浅蓝色 'rgba(94, 53, 177, 0.3)', // 深紫色 'rgba(3, 169, 244, 0.4)' // 天蓝色 ]; return colors[Math.floor(Math.random() * colors.length)]; }; // 初始化粒子 const initParticles = () => { particles = []; // 增加粒子数量,但保持性能平衡 const particleCount = Math.min(Math.floor(window.innerWidth / 8), 150); for (let i = 0; i < particleCount; i++) { // 创建不同大小和速度的粒子 const size = Math.random(); const speedFactor = Math.max(0.1, size); // 较大的粒子移动较慢 particles.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, // 粒子大小更加多样化 radius: size * 3 + 0.5, // 使用随机颜色 color: getRandomColor(), // 添加发光效果 glow: Math.random() * 10 + 5, // 调整速度范围,使运动更加自然 speedX: (Math.random() * 0.6 - 0.3) * speedFactor, speedY: (Math.random() * 0.6 - 0.3) * speedFactor, originalSpeedX: (Math.random() * 0.6 - 0.3) * speedFactor, originalSpeedY: (Math.random() * 0.6 - 0.3) * speedFactor, // 添加脉动效果 pulseSpeed: Math.random() * 0.02 + 0.01, pulseDirection: Math.random() > 0.5 ? 1 : -1, pulseAmount: 0, // 粒子透明度 opacity: Math.random() * 0.5 + 0.5 }); } }; // 绘制粒子 const drawParticles = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); // 计算鼠标速度衰减 mouseSpeed.x *= 0.95; mouseSpeed.y *= 0.95; // 绘制粒子之间的连线 drawLines(); particles.forEach(particle => { // 计算粒子与鼠标的距离 const dx = mousePosition.x - particle.x; const dy = mousePosition.y - particle.y; const distance = Math.sqrt(dx * dx + dy * dy); // 脉动效果 particle.pulseAmount += particle.pulseSpeed * particle.pulseDirection; if (Math.abs(particle.pulseAmount) > 0.5) { particle.pulseDirection *= -1; } // 如果粒子在鼠标影响范围内,调整其速度 if (distance < hoverRadius) { const angle = Math.atan2(dy, dx); const force = (hoverRadius - distance) / hoverRadius; const mouseFactor = 3; // 增强鼠标影响力度 // 粒子远离鼠标,并受鼠标速度影响 particle.speedX = -Math.cos(angle) * force * mouseFactor + particle.originalSpeedX + mouseSpeed.x * 0.05; particle.speedY = -Math.sin(angle) * force * mouseFactor + particle.originalSpeedY + mouseSpeed.y * 0.05; } else { // 逐渐恢复原始速度 particle.speedX = particle.speedX * 0.95 + particle.originalSpeedX * 0.05; particle.speedY = particle.speedY * 0.95 + particle.originalSpeedY * 0.05; } // 更新粒子位置 particle.x += particle.speedX; particle.y += particle.speedY; // 边界检查 if (particle.x < 0) particle.x = canvas.width; if (particle.x > canvas.width) particle.x = 0; if (particle.y < 0) particle.y = canvas.height; if (particle.y > canvas.height) particle.y = 0; // 应用脉动效果到粒子大小 const currentRadius = particle.radius * (1 + particle.pulseAmount * 0.2); // 绘制发光效果 const gradient = ctx.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, particle.glow); gradient.addColorStop(0, particle.color); gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); // 绘制粒子 ctx.beginPath(); ctx.arc(particle.x, particle.y, currentRadius, 0, Math.PI * 2); ctx.fillStyle = particle.color; ctx.fill(); // 添加发光效果 ctx.beginPath(); ctx.arc(particle.x, particle.y, particle.glow, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.globalAlpha = 0.3 * particle.opacity; ctx.fill(); ctx.globalAlpha = 1.0; }); animationFrameId = requestAnimationFrame(drawParticles); }; // 绘制粒子之间的连线 const drawLines = () => { for (let i = 0; i < particles.length; i++) { for (let j = i + 1; j < particles.length; j++) { const dx = particles[i].x - particles[j].x; const dy = particles[i].y - particles[j].y; const distance = Math.sqrt(dx * dx + dy * dy); // 增加连线的最大距离 const maxDistance = 120; if (distance < maxDistance) { // 只在粒子距离小于maxDistance时绘制连线 ctx.beginPath(); ctx.moveTo(particles[i].x, particles[i].y); ctx.lineTo(particles[j].x, particles[j].y); // 根据距离设置线条透明度 const opacity = 1 - distance / maxDistance; // 根据主题设置线条颜色 const lineColor = theme.palette.mode === 'dark' ? `rgba(255, 255, 255, ${opacity * 0.2})` : `rgba(42, 92, 170, ${opacity * 0.2})`; ctx.strokeStyle = lineColor; ctx.lineWidth = opacity * 1.5; // 根据距离调整线宽 ctx.stroke(); } } } }; // 初始化 handleResize(); window.addEventListener('resize', handleResize); window.addEventListener('mousemove', handleMouseMove); window.addEventListener('touchmove', handleTouchMove); // 开始动画 drawParticles(); // 清理函数 return () => { window.removeEventListener('resize', handleResize); window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('touchmove', handleTouchMove); cancelAnimationFrame(animationFrameId); }; }, [theme.palette.mode]); return ( ); } ================================================ FILE: components/home/ProjectCard.js ================================================ 'use client'; import { Card, Box, CardActionArea, CardContent, Typography, Avatar, Divider, IconButton, Menu, MenuItem, ListItemIcon } from '@mui/material'; import Link from 'next/link'; import { styles } from '@/styles/home'; import { useTheme, alpha } from '@mui/material/styles'; import DataObjectIcon from '@mui/icons-material/DataObject'; import DeleteIcon from '@mui/icons-material/Delete'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import TokenIcon from '@mui/icons-material/Token'; import AssessmentIcon from '@mui/icons-material/Assessment'; import QuizIcon from '@mui/icons-material/Quiz'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; /** * 统计项组件 */ const StatItem = ({ icon: Icon, count, label, color, isToken }) => { const theme = useTheme(); // 格式化数字 const displayCount = isToken ? (count || 0).toLocaleString() : count || 0; return ( {displayCount} {label} ); }; /** * 项目卡片组件 * @param {Object} props - 组件属性 * @param {Object} props.project - 项目数据 * @param {Function} props.onDeleteClick - 删除按钮点击事件处理函数 */ export default function ProjectCard({ project, onDeleteClick }) { const { t } = useTranslation(); const theme = useTheme(); const [processingId, setProcessingId] = useState(false); // 菜单状态 const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); // 打开项目目录 const handleOpenDirectory = async event => { event.stopPropagation(); event.preventDefault(); if (processingId) return; try { setProcessingId(true); const response = await fetch('/api/projects/open-directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: project.id }) }); if (!response.ok) { const data = await response.json(); throw new Error(data.error || t('migration.openDirectoryFailed')); } // 成功打开目录,不需要特别处理 } catch (error) { console.error('打开目录错误:', error); alert(error.message); } finally { setProcessingId(false); } }; // 处理菜单打开 const handleMenuClick = event => { event.stopPropagation(); event.preventDefault(); setAnchorEl(event.currentTarget); }; // 处理菜单关闭 const handleMenuClose = event => { if (event) { event.stopPropagation(); event.preventDefault(); } setAnchorEl(null); }; // 处理打开目录点击 const handleOpenDirectoryClick = event => { handleMenuClose(event); handleOpenDirectory(event); }; // 处理删除点击 const handleDeleteClick = event => { handleMenuClose(event); onDeleteClick(event, project); }; return ( {/* 头部:Avatar + Title + Menu */} {project.name.charAt(0).toUpperCase()} {project.name} ID: {project.id} {/* 描述 */} {project.description || t('projects.noDescription', { defaultValue: '暂无描述' })} {/* 统计数据 */} {/* 操作菜单 */} { e.preventDefault(); e.stopPropagation(); }} transformOrigin={{ horizontal: 'right', vertical: 'top' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} PaperProps={{ elevation: 3, sx: { borderRadius: '12px', minWidth: 160, mt: 0.5 } }} > {t('projects.openDirectory')} {t('common.delete')} ); } ================================================ FILE: components/home/ProjectList.js ================================================ 'use client'; import { Grid, Paper, Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Typography } from '@mui/material'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import ProjectCard from './ProjectCard'; export default function ProjectList({ projects, onCreateProject }) { const { t } = useTranslation(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [projectToDelete, setProjectToDelete] = useState(null); const [loading, setLoading] = useState(false); // 打开删除确认对话框 const handleOpenDeleteDialog = (event, project) => { setProjectToDelete(project); setDeleteDialogOpen(true); }; // 关闭删除确认对话框 const handleCloseDeleteDialog = () => { setDeleteDialogOpen(false); setProjectToDelete(null); }; // 删除项目 const handleDeleteProject = async () => { if (!projectToDelete) return; try { setLoading(true); const response = await fetch(`/api/projects/${projectToDelete.id}`, { method: 'DELETE' }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('projects.deleteFailed')); } // 刷新页面以更新项目列表 window.location.reload(); } catch (error) { console.error('删除项目失败:', error); alert(error.message || t('projects.deleteFailed')); } finally { setLoading(false); handleCloseDeleteDialog(); } }; return ( <> {projects.length === 0 ? ( {t('projects.noProjects')} ) : ( projects.map(project => ( )) )} {/* 删除确认对话框 */} {t('projects.deleteConfirmTitle')} {projectToDelete && ( <> {t('projects.deleteConfirm')}
{projectToDelete.name} )}
); } ================================================ FILE: components/home/StatsCard.js ================================================ 'use client'; import { Paper, Grid, Box, Typography, useMediaQuery, Avatar } from '@mui/material'; import { styles } from '@/styles/home'; import { useTheme } from '@mui/material'; import { motion } from 'framer-motion'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer'; import StorageIcon from '@mui/icons-material/Storage'; import MemoryIcon from '@mui/icons-material/Memory'; // 默认模型列表 const mockModels = [ { id: 'deepseek-r1', provider: 'Ollama', name: 'DeepSeek-R1' }, { id: 'gpt-3.5-turbo-openai', provider: 'OpenAI', name: 'gpt-3.5-turbo' }, { id: 'gpt-3.5-turbo-guiji', provider: 'Guiji', name: 'gpt-3.5-turbo' }, { id: 'glm-4-flash', provider: 'Zhipu AI', name: 'GLM-4-Flash' } ]; export default function StatsCard({ projects }) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); // 统计卡片数据 const statsItems = [ { value: projects.length, label: t('stats.ongoingProjects'), color: 'primary', icon: }, { value: projects.reduce((sum, project) => sum + (project.questionsCount || 0), 0), label: t('stats.questionCount'), color: 'secondary', icon: }, { value: projects.reduce((sum, project) => sum + (project.datasetsCount || 0), 0), label: t('stats.generatedDatasets'), color: 'success', icon: }, { value: mockModels.length, label: t('stats.supportedModels'), color: 'warning', icon: } ]; return ( {statsItems.map((item, index) => ( {item.icon} {item.value} {item.label} ))} ); } ================================================ FILE: components/mga/GaPairsIndicator.js ================================================ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { Box, Chip, Typography, IconButton, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material'; import { Psychology as PsychologyIcon, AutoAwesome as AutoFixIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import GaPairsManager from './GaPairsManager'; /** * GA Pairs Indicator Component - Shows GA pairs status for a file * @param {Object} props * @param {string} props.projectId - Project ID * @param {string} props.fileId - File ID * @param {string} props.fileName - File name for display */ export default function GaPairsIndicator({ projectId, fileId, fileName = '未命名文件' }) { const { t } = useTranslation(); const [gaPairs, setGaPairs] = useState([]); const [loading, setLoading] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); // 获取GA对状态的函数 const fetchGaPairsStatus = useCallback(async () => { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`); if (!response.ok) { if (response.status === 404) { setGaPairs([]); return; } throw new Error(`HTTP ${response.status}: Failed to load GA pairs`); } const result = await response.json(); // 处理响应格式 let newGaPairs = []; if (Array.isArray(result)) { newGaPairs = result; } else if (result?.data) { newGaPairs = result.data; } setGaPairs(newGaPairs); } catch (error) { console.error('获取GA对状态失败:', error); setGaPairs([]); } finally { setLoading(false); } }, [projectId, fileId]); // 初始加载 useEffect(() => { if (projectId && fileId) { fetchGaPairsStatus(); } }, [projectId, fileId, fetchGaPairsStatus]); //监听外部事件 useEffect(() => { const handleRefresh = event => { const { projectId: eventProjectId, fileIds } = event.detail || {}; if (eventProjectId === projectId && fileIds?.includes(String(fileId))) { fetchGaPairsStatus(); } }; window.addEventListener('refreshGaPairsIndicators', handleRefresh); return () => window.removeEventListener('refreshGaPairsIndicators', handleRefresh); }, [projectId, fileId, fetchGaPairsStatus]); // 计算激活的GA对数量 const activePairs = gaPairs.filter(pair => pair.isActive); const hasGaPairs = gaPairs.length > 0; //GA对变化回调处理 const handleGaPairsChange = useCallback(newGaPairs => { setGaPairs(newGaPairs || []); }, []); const handleOpenDialog = useCallback(() => { setDetailsOpen(true); }, []); const handleCloseDialog = useCallback(() => { setDetailsOpen(false); }, []); //加载状态显示 if (loading) { return ( Loading... ); } return ( {hasGaPairs ? ( } label={`${activePairs.length}/${gaPairs.length} GA Pairs`} size="small" color={activePairs.length > 0 ? 'primary' : 'default'} variant={activePairs.length > 0 ? 'filled' : 'outlined'} onClick={handleOpenDialog} /> ) : ( )} {/* Details Dialog */} GA Pairs for {fileName} {detailsOpen && ( )} ); } ================================================ FILE: components/mga/GaPairsManager.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Box, Typography, Button, Card, CardContent, Switch, FormControlLabel, TextField, IconButton, Tooltip, Divider, Alert, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Grid } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon, AutoFixHigh as AutoFixHighIcon, Save as SaveIcon } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import i18n from '@/lib/i18n'; /** * GA Pairs Manager Component * @param {Object} props * @param {string} props.projectId - Project ID * @param {string} props.fileId - File ID * @param {Function} props.onGaPairsChange - Callback when GA pairs change */ export default function GaPairsManager({ projectId, fileId, onGaPairsChange }) { const { t } = useTranslation(); const [gaPairs, setGaPairs] = useState([]); const [backupGaPairs, setBackupGaPairs] = useState([]); // 备份状态 const [loading, setLoading] = useState(false); const [generating, setGenerating] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [addDialogOpen, setAddDialogOpen] = useState(false); const [newGaPair, setNewGaPair] = useState({ genreTitle: '', genreDesc: '', audienceTitle: '', audienceDesc: '', isActive: true }); useEffect(() => { loadGaPairs(); }, [projectId, fileId]); const loadGaPairs = async () => { try { setLoading(true); setError(null); const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`); // 检查响应状态 if (!response.ok) { if (response.status === 404) { console.warn('GA Pairs API not found, using empty data'); setGaPairs([]); setBackupGaPairs([]); return; } throw new Error(`HTTP ${response.status}: Failed to load GA pairs`); } const result = await response.json(); console.log('Load GA pairs result:', result); if (result.success) { const loadedData = result.data || []; setGaPairs(loadedData); setBackupGaPairs([...loadedData]); // 创建备份 onGaPairsChange?.(loadedData); } else { throw new Error(result.error || 'Failed to load GA pairs'); } } catch (error) { console.error('Load GA pairs error:', error); setError(t('gaPairs.loadError', { error: error.message })); } finally { setLoading(false); } }; const generateGaPairs = async () => { try { setGenerating(true); setError(null); console.log('Starting GA pairs generation...'); // Get current language from i18n const currentLanguage = i18n.language === 'en' ? 'en' : '中文'; const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ regenerate: false, appendMode: true, // 新增:启用追加模式 language: currentLanguage }) }); if (!response.ok) { let errorMessage = t('gaPairs.generateError'); if (response.status === 404) { errorMessage = t('gaPairs.serviceNotAvailable'); } else if (response.status === 400) { try { const errorResult = await response.json(); if (errorResult.error?.includes('No active AI model')) { errorMessage = t('gaPairs.noActiveModel'); } else if (errorResult.error?.includes('content might be too short')) { errorMessage = t('gaPairs.contentTooShort'); } else { errorMessage = errorResult.error || errorMessage; } } catch (parseError) { errorMessage = t('gaPairs.requestFailed', { status: response.status }); } } else if (response.status === 500) { try { const errorResult = await response.json(); if (errorResult.error?.includes('model configuration') || errorResult.error?.includes('Module not found')) { errorMessage = t('gaPairs.configError'); } else { errorMessage = errorResult.error || 'Internal server error occurred.'; } } catch (parseError) { console.error('Failed to parse error response:', parseError); errorMessage = errorResult.error || t('gaPairs.internalServerError'); } } throw new Error(errorMessage); } // 处理成功响应 const responseText = await response.text(); if (!responseText || responseText.trim() === '') { throw new Error(t('gaPairs.emptyResponse')); } const result = JSON.parse(responseText); console.log('Generate GA pairs result:', result); if (result.success) { // 在追加模式下,后端只返回新生成的GA对 const newGaPairs = result.data || []; // 将新生成的GA对追加到现有的GA对 const updatedGaPairs = [...gaPairs, ...newGaPairs]; setGaPairs(updatedGaPairs); setBackupGaPairs([...updatedGaPairs]); // 更新备份 onGaPairsChange?.(updatedGaPairs); setSuccess( t('gaPairs.additionalPairsGenerated', { count: newGaPairs.length, total: updatedGaPairs.length }) ); } else { throw new Error(result.error || t('gaPairs.generationFailed')); } } catch (error) { console.error('Generate GA pairs error:', error); setError(error.message); } finally { setGenerating(false); } }; const saveGaPairs = async () => { try { setSaving(true); setError(null); // 验证GA对数据 const validatedGaPairs = gaPairs.map((pair, index) => { // 处理不同的数据格式 let genreTitle, genreDesc, audienceTitle, audienceDesc; if (pair.genre && typeof pair.genre === 'object') { genreTitle = pair.genre.title; genreDesc = pair.genre.description; } else { genreTitle = pair.genreTitle || pair.genre; genreDesc = pair.genreDesc || ''; } if (pair.audience && typeof pair.audience === 'object') { audienceTitle = pair.audience.title; audienceDesc = pair.audience.description; } else { audienceTitle = pair.audienceTitle || pair.audience; audienceDesc = pair.audienceDesc || ''; } // 验证必填字段 if (!genreTitle || !audienceTitle) { throw new Error(t('gaPairs.validationError', { number: index + 1 })); } return { id: pair.id, genreTitle: genreTitle.trim(), genreDesc: genreDesc.trim(), audienceTitle: audienceTitle.trim(), audienceDesc: audienceDesc.trim(), isActive: pair.isActive !== undefined ? pair.isActive : true }; }); console.log('Saving validated GA pairs:', validatedGaPairs); const response = await fetch(`/api/projects/${projectId}/files/${fileId}/ga-pairs`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: validatedGaPairs }) }); if (!response.ok) { let errorMessage = t('gaPairs.saveError'); if (response.status === 404) { errorMessage = 'GA Pairs save service is not available.'; } else { try { const errorResult = await response.json(); errorMessage = errorResult.error || errorMessage; } catch (parseError) { errorMessage = t('gaPairs.serverError', { status: response.status }); } } throw new Error(errorMessage); } const responseText = await response.text(); const result = responseText ? JSON.parse(responseText) : { success: true }; if (result.success) { // 更新本地状态为服务器返回的数据 const savedData = result.data || validatedGaPairs; setGaPairs(savedData); // 根据保存的GA对数量显示不同的成功消息 if (savedData.length === 0) { setSuccess(t('gaPairs.allPairsDeleted')); } else { setSuccess(t('gaPairs.pairsSaved', { count: savedData.length })); } onGaPairsChange?.(savedData); } else { throw new Error(result.error || t('gaPairs.saveOperationFailed')); } } catch (error) { console.error('Save GA pairs error:', error); setError(error.message); } finally { setSaving(false); } }; const handleGaPairChange = (index, field, value) => { const updatedGaPairs = [...gaPairs]; // 确保对象存在 if (!updatedGaPairs[index]) { console.error(`GA pair at index ${index} does not exist`); return; } updatedGaPairs[index] = { ...updatedGaPairs[index], [field]: value }; setGaPairs(updatedGaPairs); // 不立即调用 onGaPairsChange,等用户点击保存时再调用 }; const handleDeleteGaPair = index => { const updatedGaPairs = gaPairs.filter((_, i) => i !== index); setGaPairs(updatedGaPairs); onGaPairsChange?.(updatedGaPairs); }; const handleAddGaPair = () => { // 验证输入 if (!newGaPair.genreTitle?.trim() || !newGaPair.audienceTitle?.trim()) { setError(t('gaPairs.requiredFields')); return; } // 创建新的GA对对象 const newPair = { id: `temp_${Date.now()}`, // 临时ID genreTitle: newGaPair.genreTitle.trim(), genreDesc: newGaPair.genreDesc?.trim() || '', audienceTitle: newGaPair.audienceTitle.trim(), audienceDesc: newGaPair.audienceDesc?.trim() || '', isActive: true }; const updatedGaPairs = [...gaPairs, newPair]; setGaPairs(updatedGaPairs); onGaPairsChange?.(updatedGaPairs); // 重置表单并关闭对话框 setNewGaPair({ genreTitle: '', genreDesc: '', audienceTitle: '', audienceDesc: '', isActive: true }); setAddDialogOpen(false); setError(null); }; const resetMessages = () => { setError(null); setSuccess(null); }; const recoverFromBackup = () => { setGaPairs([...backupGaPairs]); setError(null); setSuccess(t('gaPairs.restoredFromBackup')); }; useEffect(() => { if (error || success) { const timer = setTimeout(resetMessages, 5000); return () => clearTimeout(timer); } }, [error, success]); if (loading) { return ( {t('gaPairs.loading')} ); } return ( {/* Header with action buttons */} {t('gaPairs.title')} {/* 右上角按钮为手动添加GA对 */} {/* Error/Success Messages */} {error && ( 0 && ( ) } onClose={resetMessages} > {error} )} {success && ( {success} )} {/* Generate GA Pairs Section - 只在没有GA对时显示 */} {gaPairs.length === 0 && ( {t('gaPairs.noGaPairsTitle')} {t('gaPairs.noGaPairsDescription')} )} {/* GA Pairs List */} {gaPairs.length > 0 && ( {t('gaPairs.activePairs', { active: gaPairs.filter(pair => pair.isActive).length, total: gaPairs.length })} {gaPairs.map((pair, index) => ( {t('gaPairs.pairNumber', { number: index + 1 })} handleGaPairChange(index, 'isActive', e.target.checked)} size="small" /> } label={t('gaPairs.active')} /> {/* 添加删除按钮 */} handleDeleteGaPair(index)}> handleGaPairChange(index, 'genreTitle', e.target.value)} multiline rows={2} fullWidth disabled={!pair.isActive} /> handleGaPairChange(index, 'genreDesc', e.target.value)} multiline rows={2} fullWidth disabled={!pair.isActive} /> handleGaPairChange(index, 'audienceTitle', e.target.value)} multiline rows={2} fullWidth disabled={!pair.isActive} /> handleGaPairChange(index, 'audienceDesc', e.target.value)} multiline rows={2} fullWidth disabled={!pair.isActive} /> ))} {/* 在GA对列表下方添加生成按钮 */} )} {/* Add GA Pair Dialog */} setAddDialogOpen(false)} maxWidth="md" fullWidth> {t('gaPairs.addDialogTitle')} setNewGaPair({ ...newGaPair, genreTitle: e.target.value })} fullWidth required placeholder={t('gaPairs.genreTitlePlaceholder')} /> setNewGaPair({ ...newGaPair, genreDesc: e.target.value })} multiline rows={3} fullWidth placeholder={t('gaPairs.genreDescPlaceholder')} /> setNewGaPair({ ...newGaPair, audienceTitle: e.target.value })} fullWidth required placeholder={t('gaPairs.audienceTitlePlaceholder')} /> setNewGaPair({ ...newGaPair, audienceDesc: e.target.value })} multiline rows={3} fullWidth placeholder={t('gaPairs.audienceDescPlaceholder')} /> setNewGaPair({ ...newGaPair, isActive: e.target.checked })} /> } label={t('gaPairs.active')} /> ); } ================================================ FILE: components/playground/ChatArea.js ================================================ 'use client'; import React, { useRef, useEffect } from 'react'; import { Box, Typography, Paper, Grid, CircularProgress } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import ChatMessage from './ChatMessage'; import { playgroundStyles } from '@/styles/playground'; import { useTranslation } from 'react-i18next'; const ChatArea = ({ selectedModels, conversations, loading, getModelName }) => { const theme = useTheme(); const styles = playgroundStyles(theme); const { t } = useTranslation(); // 为每个模型创建独立的引用 const chatContainerRefs = { model1: useRef(null), model2: useRef(null), model3: useRef(null) }; // 为每个模型的聊天容器自动滚动到底部 useEffect(() => { Object.values(chatContainerRefs).forEach(ref => { if (ref.current) { ref.current.scrollTop = ref.current.scrollHeight; } }); }, [conversations]); if (selectedModels.length === 0) { return ( {t('playground.selectModelFirst')} ); } return ( {selectedModels.map((modelId, index) => { const modelConversation = conversations[modelId] || []; const isLoading = loading[modelId]; const refKey = `model${index + 1}`; return ( 1 ? 12 / selectedModels.length : 12} key={modelId} style={{ maxHeight: 'calc(100vh - 300px)' }} > {getModelName(modelId)} {isLoading && } {modelConversation.length === 0 ? ( {t('playground.sendFirstMessage')} ) : ( modelConversation.map((message, msgIndex) => ( )) )} ); })} ); }; export default ChatArea; ================================================ FILE: components/playground/ChatMessage.js ================================================ import React, { useState } from 'react'; import { Box, Paper, Typography, Alert, useTheme, IconButton, Collapse } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import PsychologyIcon from '@mui/icons-material/Psychology'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import { useTranslation } from 'react-i18next'; /** * 聊天消息组件 * @param {Object} props * @param {Object} props.message - 消息对象 * @param {string} props.message.role - 消息角色:'user'、'assistant' 或 'error' * @param {string} props.message.content - 消息内容 * @param {string} props.modelName - 模型名称(仅在 assistant 或 error 类型消息中显示) */ export default function ChatMessage({ message, modelName }) { const theme = useTheme(); const { t } = useTranslation(); // 用户消息 if (message.role === 'user') { return ( {typeof message.content === 'string' ? ( {message.content} ) : ( // 如果是数组类型(用于视觉模型的用户输入) <> {Array.isArray(message.content) && message.content.map((item, i) => { if (item.type === 'text') { return ( {item.text} ); } else if (item.type === 'image_url') { return ( 上传图片 ); } return null; })} )} ); } // 助手消息 if (message.role === 'assistant') { // 处理推理过程的展示状态 const [showThinking, setShowThinking] = useState(message.showThinking || false); const hasThinking = message.thinking && message.thinking.trim().length > 0; return ( {modelName && ( {modelName} )} {/* 推理过程显示区域 */} {hasThinking && ( {message.isStreaming ? ( ) : ( )} {t('playground.reasoningProcess', '推理过程')} setShowThinking(!showThinking)} sx={{ p: 0 }}> {showThinking ? : } {message.thinking} )} {/* 回答内容 */} {typeof message.content === 'string' ? ( <> {message.content} {message.isStreaming && |} ) : ( // 如果是数组类型(用于视觉模型的响应) <> {Array.isArray(message.content) && message.content.map((item, i) => { if (item.type === 'text') { return {item.text}; } else if (item.type === 'image_url') { return ( 图片 ); } return null; })} {message.isStreaming && |} )} ); } // 错误消息 if (message.role === 'error') { return ( {modelName && ( {modelName} )} {message.content} ); } return null; } ================================================ FILE: components/playground/MessageInput.js ================================================ 'use client'; import React, { useState } from 'react'; import { Box, TextField, Button, IconButton, Badge, Tooltip } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; import ImageIcon from '@mui/icons-material/Image'; import CancelIcon from '@mui/icons-material/Cancel'; import { useTheme } from '@mui/material/styles'; import { playgroundStyles } from '@/styles/playground'; import { useTranslation } from 'react-i18next'; const MessageInput = ({ userInput, handleInputChange, handleSendMessage, loading, selectedModels, uploadedImage, handleImageUpload, handleRemoveImage, availableModels }) => { const theme = useTheme(); const styles = playgroundStyles(theme); const { t } = useTranslation(); const isDisabled = Object.values(loading).some(value => value) || selectedModels.length === 0; const isSendDisabled = isDisabled || (!userInput.trim() && !uploadedImage); // 检查是否有视觉模型被选中 const hasVisionModel = selectedModels.some(modelId => { const model = availableModels.find(m => m.id === modelId); return model && model.type === 'vision'; }); return ( {uploadedImage && ( } sx={{ width: '100%' }} overlap="rectangular" anchorOrigin={{ vertical: 'top', horizontal: 'right' }} > 上传图片 )} { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }} multiline maxRows={4} /> {hasVisionModel && ( )} ); }; export default MessageInput; ================================================ FILE: components/playground/ModelSelector.js ================================================ import React from 'react'; import { FormControl, InputLabel, Select, MenuItem, OutlinedInput, Box, Chip, Checkbox, ListItemText } from '@mui/material'; import { useTranslation } from 'react-i18next'; const ITEM_HEIGHT = 48; const ITEM_PADDING_TOP = 8; const MenuProps = { PaperProps: { style: { maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, width: 250 } } }; /** * 模型选择组件 * @param {Object} props * @param {Array} props.models - 可用模型列表 * @param {Array} props.selectedModels - 已选择的模型ID列表 * @param {Function} props.onChange - 选择改变时的回调函数 */ export default function ModelSelector({ models, selectedModels, onChange }) { // 获取模型名称 const getModelName = modelId => { const model = models.find(m => m.id === modelId); return model ? `${model.providerName}: ${model.modelName}` : modelId; }; const { t } = useTranslation(); return ( {t('playground.selectModelMax3')} ); } ================================================ FILE: components/playground/PlaygroundHeader.js ================================================ 'use client'; import React from 'react'; import { Grid, Button, Divider, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import { useTheme } from '@mui/material/styles'; import ModelSelector from './ModelSelector'; import { playgroundStyles } from '@/styles/playground'; import { useTranslation } from 'react-i18next'; const PlaygroundHeader = ({ availableModels, selectedModels, handleModelSelection, handleClearConversations, conversations, outputMode, handleOutputModeChange }) => { const theme = useTheme(); const styles = playgroundStyles(theme); const { t } = useTranslation(); const isClearDisabled = selectedModels.length === 0 || Object.values(conversations).every(conv => conv.length === 0); return ( <> {t('playground.outputMode')} ); }; export default PlaygroundHeader; ================================================ FILE: components/questions/QuestionListView.js ================================================ 'use client'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Typography, Checkbox, IconButton, Chip, Tooltip, Pagination, Divider, Paper, CircularProgress, TextField } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import EditIcon from '@mui/icons-material/Edit'; import ChatIcon from '@mui/icons-material/Chat'; import { useGenerateDataset } from '@/hooks/useGenerateDataset'; import { toast } from 'sonner'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; export default function QuestionListView({ questions = [], currentPage, totalQuestions = 0, handlePageChange, selectedQuestions = [], onSelectQuestion, onDeleteQuestion, projectId, onEditQuestion, refreshQuestions }) { const { t } = useTranslation(); // 处理状态 const [processingQuestions, setProcessingQuestions] = useState({}); const { generateSingleDataset } = useGenerateDataset(); // 获取当前选中的模型 const selectedModelInfo = useAtomValue(selectedModelInfoAtom); // 获取文本块的标题 const getChunkTitle = content => { const firstLine = content ? content.split('\n')[0].trim() : ''; if (firstLine.startsWith('# ')) { return firstLine.substring(2); } else if (firstLine.length > 0) { return firstLine.length > 200 ? firstLine.substring(0, 200) + '...' : firstLine; } return ''; }; // 检查问题是否被选中 const isQuestionSelected = questionId => { return selectedQuestions.includes(questionId); }; // 处理生成数据集 const handleGenerateDataset = async (questionId, questionInfo, imageId, imageName) => { // 设置处理状态 setProcessingQuestions(prev => ({ ...prev, [questionId]: true })); await generateSingleDataset({ projectId, questionId, questionInfo, imageId, imageName }); // 重置处理状态 setProcessingQuestions(prev => ({ ...prev, [questionId]: false })); refreshQuestions(); }; // 处理生成多轮对话数据集 const handleGenerateMultiTurnDataset = async (questionId, questionInfo) => { try { // 设置处理状态 setProcessingQuestions(prev => ({ ...prev, [`${questionId}_multi`]: true })); // 首先检查项目是否配置了多轮对话设置 const configResponse = await fetch(`/api/projects/${projectId}/tasks`); if (!configResponse.ok) { throw new Error('获取项目配置失败'); } const config = await configResponse.json(); const multiTurnConfig = { systemPrompt: config.multiTurnSystemPrompt, scenario: config.multiTurnScenario, rounds: config.multiTurnRounds, roleA: config.multiTurnRoleA, roleB: config.multiTurnRoleB }; console.log('multiTurnConfig:', multiTurnConfig); // 检查是否已配置必要的多轮对话设置 // 系统提示词是可选的,但场景、角色A、角色B和轮数是必需的 if ( !multiTurnConfig.scenario || !multiTurnConfig.roleA || !multiTurnConfig.roleB || !multiTurnConfig.rounds || multiTurnConfig.rounds < 1 ) { toast.error(t('questions.multiTurnNotConfigured', '请先在项目设置中配置多轮对话相关参数')); return; } // 检查是否选中了模型 if (!selectedModelInfo) { toast.error(t('datasets.selectModelFirst', '请先选择模型')); return; } // 调用多轮对话生成API const response = await fetch(`/api/projects/${projectId}/dataset-conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ questionId, ...multiTurnConfig, model: selectedModelInfo }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || '生成多轮对话数据集失败'); } const result = await response.json(); toast.success(t('questions.multiTurnGenerated', '多轮对话数据集生成成功!')); } catch (error) { console.error('生成多轮对话数据集失败:', error); toast.error(error.message || '生成多轮对话数据集失败'); } finally { // 重置处理状态 setProcessingQuestions(prev => ({ ...prev, [`${questionId}_multi`]: false })); } }; return ( {/* 问题列表 */} {t('datasets.question')} {t('common.label')} {t('common.dataSource')} {t('common.actions')} {questions.map((question, index) => { const isSelected = isQuestionSelected(question.id); const questionKey = question.id; return ( { onSelectQuestion(questionKey); }} size="small" /> {question.question} {question.datasetCount > 0 ? ( ) : null} {question.label || t('datasets.noTag')} • ID: {(question.question || '').substring(0, 8)} {question.label ? ( ) : ( {t('datasets.noTag')} )} onEditQuestion(question)} disabled={processingQuestions[questionKey]} > handleGenerateDataset(question.id, question.question, question.imageId, question.imageName) } disabled={processingQuestions[questionKey]} > {processingQuestions[questionKey] ? ( ) : ( )} {!question.imageId && ( handleGenerateMultiTurnDataset(question.id, question.question)} disabled={processingQuestions[`${questionKey}_multi`]} > {processingQuestions[`${questionKey}_multi`] ? ( ) : ( )} )} onDeleteQuestion(question.id)} disabled={processingQuestions[questionKey]} > {index < questions.length - 1 && } ); })} {/* 分页 */} {totalQuestions > 1 && ( {t('common.jumpTo')}: { if (e.key === 'Enter') { const pageNum = parseInt(e.target.value, 10); if (pageNum >= 1 && pageNum <= totalQuestions) { handlePageChange(null, pageNum); e.target.value = ''; } } }} /> )} ); } ================================================ FILE: components/questions/QuestionTreeView.js ================================================ 'use client'; import { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Typography, Paper, List, ListItem, ListItemText, Checkbox, IconButton, Collapse, Chip, Tooltip, Divider, CircularProgress } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import DeleteIcon from '@mui/icons-material/Delete'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import EditIcon from '@mui/icons-material/Edit'; import FolderIcon from '@mui/icons-material/Folder'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import { useGenerateDataset } from '@/hooks/useGenerateDataset'; import axios from 'axios'; /** * 问题树视图组件 * @param {Object} props * @param {Array} props.tags - 标签树 * @param {Array} props.selectedQuestions - 已选择的问题ID列表 * @param {Function} props.onSelectQuestion - 选择问题的回调函数 * @param {Function} props.onDeleteQuestion - 删除问题的回调函数 */ export default function QuestionTreeView({ tags = [], selectedQuestions = [], onSelectQuestion, onDeleteQuestion, onEditQuestion, projectId, searchTerm }) { const { t } = useTranslation(); const [expandedTags, setExpandedTags] = useState({}); const [questionsByTag, setQuestionsByTag] = useState({}); const [processingQuestions, setProcessingQuestions] = useState({}); const { generateSingleDataset } = useGenerateDataset(); const [questions, setQuestions] = useState([]); const [loadedTags, setLoadedTags] = useState({}); // 初始化时,将所有标签设置为收起状态(而不是展开状态) useEffect(() => { async function fetchTagsInfo() { try { // 获取标签信息,仅用于标签统计 const response = await axios.get(`/api/projects/${projectId}/questions/tree?tagsOnly=true&input=${searchTerm}`); setQuestions(response.data); // 设置数据仅用于标签统计 // 当搜索条件变化时,重新加载已展开标签的问题数据 const expandedTagLabels = Object.entries(expandedTags) .filter(([_, isExpanded]) => isExpanded) .map(([label]) => label); // 重新加载已展开标签的数据 for (const label of expandedTagLabels) { fetchTagQuestions(label); } } catch (error) { console.error('获取标签信息失败:', error); } } if (projectId) { fetchTagsInfo(); } const initialExpandedState = {}; const processTag = tag => { // 将默认状态改为 false(收起)而不是 true(展开) initialExpandedState[tag.label] = false; if (tag.child && tag.child.length > 0) { tag.child.forEach(processTag); } }; tags.forEach(processTag); // 未分类问题也默认收起 initialExpandedState['uncategorized'] = false; setExpandedTags(initialExpandedState); }, [tags]); // 根据标签对问题进行分类 useEffect(() => { const taggedQuestions = {}; // 初始化标签映射 const initTagMap = tag => { taggedQuestions[tag.label] = []; if (tag.child && tag.child.length > 0) { tag.child.forEach(initTagMap); } }; tags.forEach(initTagMap); // 将问题分配到对应的标签下 questions.forEach(question => { // 如果问题没有标签,添加到"未分类" if (!question.label) { if (!taggedQuestions['uncategorized']) { taggedQuestions['uncategorized'] = []; } taggedQuestions['uncategorized'].push(question); return; } // 将问题添加到匹配的标签下 const questionLabel = question.label; // 查找最精确匹配的标签 // 使用一个数组来存储所有匹配的标签路径,以便找到最精确的匹配 const findAllMatchingTags = (tag, path = []) => { const currentPath = [...path, tag.label]; // 存储所有匹配结果 const matches = []; // 精确匹配当前标签 if (tag.label === questionLabel) { matches.push({ label: tag.label, depth: currentPath.length }); } // 检查子标签 if (tag.child && tag.child.length > 0) { for (const childTag of tag.child) { const childMatches = findAllMatchingTags(childTag, currentPath); matches.push(...childMatches); } } return matches; }; // 在所有根标签中查找所有匹配 let allMatches = []; for (const rootTag of tags) { const matches = findAllMatchingTags(rootTag); allMatches.push(...matches); } // 找到深度最大的匹配(最精确的匹配) let matchedTagLabel = null; if (allMatches.length > 0) { // 按深度排序,深度最大的是最精确的匹配 allMatches.sort((a, b) => b.depth - a.depth); matchedTagLabel = allMatches[0].label; } if (matchedTagLabel) { // 如果找到匹配的标签,将问题添加到该标签下 if (!taggedQuestions[matchedTagLabel]) { taggedQuestions[matchedTagLabel] = []; } taggedQuestions[matchedTagLabel].push(question); } else { // 如果找不到匹配的标签,添加到"未分类" if (!taggedQuestions['uncategorized']) { taggedQuestions['uncategorized'] = []; } taggedQuestions['uncategorized'].push(question); } }); setQuestionsByTag(taggedQuestions); }, [questions, tags]); // 处理展开/折叠标签 - 使用 useCallback 优化 const handleToggleExpand = useCallback( tagLabel => { // 检查是否需要加载此标签的问题数据 const shouldExpand = !expandedTags[tagLabel]; if (shouldExpand && !loadedTags[tagLabel]) { // 如果要展开且尚未加载数据,则加载数据 fetchTagQuestions(tagLabel); } setExpandedTags(prev => ({ ...prev, [tagLabel]: shouldExpand })); }, [expandedTags, loadedTags, projectId] ); // 获取特定标签的问题数据 const fetchTagQuestions = useCallback( async tagLabel => { try { const response = await axios.get( `/api/projects/${projectId}/questions/tree?tag=${encodeURIComponent(tagLabel)}${searchTerm ? `&input=${searchTerm}` : ''}` ); // 更新问题数据,合并新获取的数据 setQuestions(prev => { // 创建一个新数组,包含现有数据 const updatedQuestions = [...prev]; // 添加新获取的问题数据 response.data.forEach(newQuestion => { // 检查是否已存在相同 ID 的问题 const existingIndex = updatedQuestions.findIndex(q => q.id === newQuestion.id); if (existingIndex === -1) { // 如果不存在,添加到数组 updatedQuestions.push(newQuestion); } else { // 如果已存在,更新数据 updatedQuestions[existingIndex] = newQuestion; } }); return updatedQuestions; }); // 标记该标签已加载数据 setLoadedTags(prev => ({ ...prev, [tagLabel]: true })); } catch (error) { console.error(`获取标签 "${tagLabel}" 的问题失败:`, error); } }, [projectId, searchTerm, expandedTags] ); // 检查问题是否被选中 - 使用 useCallback 优化 const isQuestionSelected = useCallback( questionKey => { return selectedQuestions.includes(questionKey); }, [selectedQuestions] ); // 处理生成数据集 - 使用 useCallback 优化 const handleGenerateDataset = async (questionId, questionInfo) => { // 设置处理状态 setProcessingQuestions(prev => ({ ...prev, [questionId]: true })); await generateSingleDataset({ projectId, questionId, questionInfo }); // 重置处理状态 setProcessingQuestions(prev => ({ ...prev, [questionId]: false })); }; // 渲染单个问题项 - 使用 useCallback 优化 const renderQuestionItem = useCallback( (question, index, total) => { const questionKey = question.id; return ( ); }, [isQuestionSelected, onSelectQuestion, onDeleteQuestion, handleGenerateDataset, processingQuestions, t] ); // 计算标签及其子标签下的所有问题数量 - 使用 useMemo 缓存计算结果 const tagQuestionCounts = useMemo(() => { const counts = {}; const countQuestions = tag => { const directQuestions = questionsByTag[tag.label] || []; let total = directQuestions.length; if (tag.child && tag.child.length > 0) { for (const childTag of tag.child) { total += countQuestions(childTag); } } counts[tag.label] = total; return total; }; tags.forEach(countQuestions); return counts; }, [questionsByTag, tags]); // 递归渲染标签树 - 使用 useCallback 优化 const renderTagTree = useCallback( (tag, level = 0) => { const questions = questionsByTag[tag.label] || []; const hasQuestions = questions.length > 0; const hasChildren = tag.child && tag.child.length > 0; const isExpanded = expandedTags[tag.label]; const totalQuestions = tagQuestionCounts[tag.label] || 0; return ( {/* 只有当标签展开时才渲染子内容,减少不必要的渲染 */} {isExpanded && ( {hasChildren && ( {tag.child.map(childTag => renderTagTree(childTag, level + 1))} )} {hasQuestions && ( {questions.map((question, index) => renderQuestionItem(question, index, questions.length))} )} )} ); }, [questionsByTag, expandedTags, tagQuestionCounts, handleToggleExpand, renderQuestionItem, t] ); // 渲染未分类问题 const renderUncategorizedQuestions = () => { const uncategorizedQuestions = questionsByTag['uncategorized'] || []; if (uncategorizedQuestions.length === 0) return null; return ( handleToggleExpand('uncategorized')} sx={{ py: 1, bgcolor: 'primary.light', color: 'primary.contrastText', '&:hover': { bgcolor: 'primary.main' }, borderRadius: '4px', mb: 0.5, pr: 1 }} > {t('datasets.uncategorized')} } /> {expandedTags['uncategorized'] ? : } {uncategorizedQuestions.map((question, index) => renderQuestionItem(question, index, uncategorizedQuestions.length) )}
); }; // 如果没有标签和问题,显示空状态 if (tags.length === 0 && Object.keys(questionsByTag).length === 0) { return ( {t('datasets.noTagsAndQuestions')} ); } return ( {renderUncategorizedQuestions()} {tags.map(tag => renderTagTree(tag))} ); } // 使用 memo 优化问题项渲染 const QuestionItem = memo( ({ question, index, total, isSelected, onSelect, onDelete, onGenerate, onEdit, isProcessing, t }) => { const questionKey = question.id; return ( onSelect(questionKey)} size="small" /> {question.question} {question.dataSites && question.dataSites.length > 0 && ( )} } secondary={ {t('datasets.source')}: {question.chunk?.name || question.chunkId || t('common.unknown')} } /> onEdit({ question: question.question, chunkId: question.chunkId, label: question.label || 'other' }) } disabled={isProcessing} > onGenerate(question.id, question.question)} disabled={isProcessing} > {isProcessing ? : } onDelete(question.id)}> {index < total - 1 && } ); } ); // 使用 memo 优化标签项渲染 const TagItem = memo(({ tag, level, isExpanded, totalQuestions, onToggle, t }) => { return ( onToggle(tag.label)} sx={{ pl: level * 2 + 1, py: 1, bgcolor: level === 0 ? 'primary.light' : 'background.paper', color: level === 0 ? 'primary.contrastText' : 'inherit', '&:hover': { bgcolor: level === 0 ? 'primary.main' : 'action.hover' }, borderRadius: '4px', mb: 0.5, pr: 1 }} > {/* 内部内容保持不变 */} {tag.label} {totalQuestions > 0 && ( )}
} /> {isExpanded ? : } ); }); ================================================ FILE: components/settings/BasicSettings.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Typography, Box, Button, TextField, Grid, Card, CardContent, Alert, Snackbar } from '@mui/material'; import SaveIcon from '@mui/icons-material/Save'; import { useTranslation } from 'react-i18next'; export default function BasicSettings({ projectId }) { const { t } = useTranslation(); const [projectInfo, setProjectInfo] = useState({ id: '', name: '', description: '' }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); useEffect(() => { async function fetchProjectInfo() { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}`); if (!response.ok) { throw new Error(t('projects.fetchFailed')); } const data = await response.json(); setProjectInfo(data); } catch (error) { console.error('获取项目信息出错:', error); setError(error.message); } finally { setLoading(false); } } fetchProjectInfo(); }, [projectId, t]); // 处理项目信息变更 const handleProjectInfoChange = e => { const { name, value } = e.target; setProjectInfo(prev => ({ ...prev, [name]: value })); }; // 保存项目信息 const handleSaveProjectInfo = async () => { try { const response = await fetch(`/api/projects/${projectId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: projectInfo.name, description: projectInfo.description }) }); if (!response.ok) { throw new Error(t('projects.saveFailed')); } setSuccess(true); } catch (error) { console.error('保存项目信息出错:', error); setError(error.message); } }; const handleCloseSnackbar = () => { setSuccess(false); setError(null); }; if (loading) { return {t('common.loading')}; } return ( {t('settings.basicInfo')} {t('settings.saveSuccess')} {error} ); } ================================================ FILE: components/settings/ModelSettings.js ================================================ 'use client'; import { useState, useEffect, useMemo } from 'react'; import { Typography, Box, Button, TextField, Grid, Card, CardContent, Dialog, DialogTitle, DialogContent, DialogActions, FormControl, Autocomplete, Slider, InputLabel, Select, MenuItem, Stack, Paper, Tooltip, IconButton, Chip, Divider, CircularProgress } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorIcon from '@mui/icons-material/Error'; import { DEFAULT_MODEL_SETTINGS } from '@/constant/model'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { toast } from 'sonner'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ScienceIcon from '@mui/icons-material/Science'; import HealthAndSafetyIcon from '@mui/icons-material/HealthAndSafety'; import { useRouter } from 'next/navigation'; import { useAtom } from 'jotai'; import { modelConfigListAtom, selectedModelInfoAtom } from '@/lib/store'; import { getProviderLogo, sortProvidersByPriority } from '@/lib/util/providerLogo'; export default function ModelSettings({ projectId }) { const { t } = useTranslation(); const router = useRouter(); // 展示端点的最大长度 const MAX_ENDPOINT_DISPLAY = 80; const MAX_GENERATION_TOKENS = 131072; // 模型对话框状态 const [openModelDialog, setOpenModelDialog] = useState(false); const [editingModel, setEditingModel] = useState(null); const [loading, setLoading] = useState(true); const [providerList, setProviderList] = useState([]); const [providerOptions, setProviderOptions] = useState([]); const [selectedProvider, setSelectedProvider] = useState({}); const [models, setModels] = useState([]); const [modelConfigList, setModelConfigList] = useAtom(modelConfigListAtom); const [selectedModelInfo, setSelectedModelInfo] = useAtom(selectedModelInfoAtom); const orderedModelConfigList = useMemo( () => sortProvidersByPriority(modelConfigList, item => item.providerId), [modelConfigList] ); const [modelConfigForm, setModelConfigForm] = useState({ id: '', providerId: '', providerName: '', endpoint: '', apiKey: '', modelId: '', modelName: '', type: 'text', temperature: 0.0, maxTokens: DEFAULT_MODEL_SETTINGS.maxTokens, topP: 0, topK: 0, status: 1 }); const [healthStatusMap, setHealthStatusMap] = useState({}); const [batchCheckingHealth, setBatchCheckingHealth] = useState(false); const isModelConfigured = model => { if (!model) return false; const hasEndpoint = Boolean(String(model.endpoint || '').trim()); const hasModel = Boolean(String(model.modelId || model.modelName || '').trim()); const providerId = String(model.providerId || '').toLowerCase(); if (providerId === 'ollama') { return hasEndpoint && hasModel; } const hasApiKey = Boolean(String(model.apiKey || '').trim()); return hasEndpoint && hasApiKey && hasModel; }; const configuredModelList = useMemo(() => orderedModelConfigList.filter(isModelConfigured), [orderedModelConfigList]); const unconfiguredModelList = useMemo( () => orderedModelConfigList.filter(model => !isModelConfigured(model)), [orderedModelConfigList] ); const normalizePositiveInteger = value => { const parsedValue = Number(value); if (!Number.isInteger(parsedValue) || parsedValue < 1) { return null; } return parsedValue; }; const getSafeMaxTokensValue = value => { return normalizePositiveInteger(value) ?? DEFAULT_MODEL_SETTINGS.maxTokens; }; useEffect(() => { getProvidersList(); getModelConfigList(); }, []); // 获取提供商列表 const getProvidersList = () => { axios.get('/api/llm/providers').then(response => { console.log('获取的模型列表', response.data); const sortedProviders = sortProvidersByPriority(response.data, item => item.id); setProviderList(sortedProviders); const providerOptions = sortedProviders.map(provider => ({ id: provider.id, label: provider.name })); if (sortedProviders.length > 0) { setSelectedProvider(sortedProviders[0]); getProviderModels(sortedProviders[0].id); } setProviderOptions(providerOptions); }); }; // 裁剪端点展示长度(不改变实际值,仅用于 UI 展示) const formatEndpoint = model => { if (!model?.endpoint) return ''; const base = model.endpoint.replace(/^https?:\/\//, ''); if (base.length > MAX_ENDPOINT_DISPLAY) { return base.slice(0, MAX_ENDPOINT_DISPLAY) + '...'; } return base; }; // 获取模型配置列表 const getModelConfigList = () => { axios .get(`/api/projects/${projectId}/model-config`) .then(response => { setModelConfigList(sortProvidersByPriority(response.data.data, item => item.providerId)); setLoading(false); }) .catch(error => { setLoading(false); toast.error('Fetch model list Error'); }); }; const onChangeProvider = (event, newValue) => { console.log('选择提供商', newValue, typeof newValue); if (typeof newValue === 'string') { // 用户手动输入了自定义提供商 setModelConfigForm(prev => ({ ...prev, providerId: 'custom', endpoint: '', providerName: '' })); } else if (newValue && newValue.id) { // 用户从下拉列表中选择了一个提供商 const selectedProvider = providerList.find(p => p.id === newValue.id); if (selectedProvider) { setSelectedProvider(selectedProvider); setModelConfigForm(prev => ({ ...prev, providerId: selectedProvider.id, endpoint: selectedProvider.apiUrl, providerName: selectedProvider.name, modelName: '' })); getProviderModels(newValue.id); } } }; // 获取提供商的模型列表(DB) const getProviderModels = providerId => { axios .get(`/api/llm/model?providerId=${providerId}`) .then(response => { setModels(response.data); }) .catch(error => { toast.error('Get Models Error'); }); }; // 同步模型列表 const refreshProviderModels = async () => { let data = await getNewModels(); if (!data) return; if (data.length > 0) { setModels(data); toast.success('Refresh Success'); const newModelsData = await axios.post('/api/llm/model', { newModels: data, providerId: selectedProvider.id }); if (newModelsData.status === 200) { toast.success('Get Model Success'); } } else { toast.info('No Models Need Refresh'); } }; // 获取最新模型列表 async function getNewModels() { try { if (!modelConfigForm || !modelConfigForm.endpoint) { return null; } const providerId = modelConfigForm.providerId; console.log(providerId, 'getNewModels providerId'); // 使用后端 API 代理请求 const res = await axios.post('/api/llm/fetch-models', { endpoint: modelConfigForm.endpoint, providerId: providerId, apiKey: modelConfigForm.apiKey }); return res.data; } catch (err) { if (err.response && err.response.status === 401) { toast.error('API Key Invalid'); } else { toast.error('Get Model List Error'); } return null; } } const getHealthCheckErrorMessage = error => { if (error?.response?.data?.error) return String(error.response.data.error); if (error?.response?.data?.message) return String(error.response.data.message); if (error?.message) return String(error.message); return t('models.endpointCheckFailed', { defaultValue: 'Endpoint check failed' }); }; const checkModelEndpointHealth = async (model, { silent = false } = {}) => { if (!model?.id) return false; const endpoint = String(model.endpoint || '').trim(); if (!endpoint) { setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'error', message: t('models.endpointMissing', { defaultValue: 'Endpoint is empty' }) } })); if (!silent) { toast.error(t('models.endpointMissing', { defaultValue: 'Endpoint is empty' })); } return false; } setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'checking', message: t('models.checking', { defaultValue: 'Checking...' }) } })); try { const response = await axios.post('/api/llm/fetch-models', { endpoint, providerId: model.providerId, apiKey: model.apiKey }); const resultList = Array.isArray(response.data) ? response.data : []; const currentModelId = String(model.modelId || model.modelName || '').trim(); const hasMatchedModel = !currentModelId || resultList.some(item => { return item?.modelId === currentModelId || item?.modelName === currentModelId; }); if (!hasMatchedModel) { setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'warning', message: t('models.endpointReachableModelMissing', { defaultValue: 'Endpoint reachable, but current model is not in the returned model list' }), checkedAt: Date.now() } })); if (!silent) { toast.warning( t('models.endpointReachableModelMissing', { defaultValue: 'Endpoint reachable, but current model is not in the returned model list' }) ); } return true; } setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'success', message: t('models.endpointHealthy', { defaultValue: 'Endpoint is healthy' }), checkedAt: Date.now() } })); if (!silent) { toast.success(t('models.endpointHealthy', { defaultValue: 'Endpoint is healthy' })); } return true; } catch (error) { const message = getHealthCheckErrorMessage(error); setHealthStatusMap(prev => ({ ...prev, [model.id]: { status: 'error', message, checkedAt: Date.now() } })); if (!silent) { toast.error(message); } return false; } }; const checkAllConfiguredModelHealth = async () => { if (configuredModelList.length === 0) { toast.info(t('models.noConfiguredModels', { defaultValue: 'No configured models to check' })); return; } setBatchCheckingHealth(true); let okCount = 0; let failCount = 0; for (const model of configuredModelList) { const isHealthy = await checkModelEndpointHealth(model, { silent: true }); if (isHealthy) { okCount += 1; } else { failCount += 1; } } setBatchCheckingHealth(false); toast.success( t('models.healthCheckSummary', { defaultValue: `Health check completed: ${okCount} healthy, ${failCount} failed`, okCount, failCount }) ); }; const getHealthStatusInfo = model => { const status = healthStatusMap[model.id]?.status || 'idle'; const message = healthStatusMap[model.id]?.message; if (status === 'checking') { return { color: 'default', icon: , label: t('models.checking', { defaultValue: 'Checking...' }), message }; } if (status === 'success') { return { color: 'success', icon: , label: t('models.healthy', { defaultValue: 'Healthy' }), message }; } if (status === 'warning') { return { color: 'warning', icon: , label: t('models.reachable', { defaultValue: 'Reachable' }), message }; } if (status === 'error') { return { color: 'error', icon: , label: t('models.unhealthy', { defaultValue: 'Unhealthy' }), message }; } return { color: 'default', icon: , label: t('models.notChecked', { defaultValue: 'Not checked' }), message: t('models.notChecked', { defaultValue: 'Not checked' }) }; }; // 打开模型对话框 const handleOpenModelDialog = (model = null) => { if (model) { setEditingModel(model); console.log('handleOpenModelDialog', model); // 兼容逻辑:如果 modelId 为空,则用 modelName 作为 modelId const initialForm = { ...model }; if (!initialForm.modelId && initialForm.modelName) { initialForm.modelId = initialForm.modelName; } // 编辑现有模型时,为未设置的参数应用默认值 setModelConfigForm({ ...initialForm, temperature: model.temperature !== undefined ? model.temperature : DEFAULT_MODEL_SETTINGS.temperature, maxTokens: model.maxTokens !== undefined ? model.maxTokens : DEFAULT_MODEL_SETTINGS.maxTokens, topP: model.topP !== undefined && model.topP !== 0 ? model.topP : DEFAULT_MODEL_SETTINGS.topP }); getProviderModels(model.providerId); } else { setEditingModel(null); // 添加新模型时,完全重置表单 setModelConfigForm({ providerId: selectedProvider?.id || '', providerName: selectedProvider?.name || '', endpoint: selectedProvider?.apiUrl || '', apiKey: '', modelId: '', modelName: '', type: 'text', ...DEFAULT_MODEL_SETTINGS, id: '' }); if (selectedProvider?.id) { getProviderModels(selectedProvider.id); } } setOpenModelDialog(true); }; // 关闭模型对话框 const handleCloseModelDialog = () => { setEditingModel(null); setOpenModelDialog(false); }; // 处理模型表单变更 const handleModelFormChange = e => { const { name, value } = e.target; console.log('handleModelFormChange', name, value); setModelConfigForm(prev => ({ ...prev, [name]: value })); }; const handleMaxTokensSliderChange = (event, newValue) => { const value = Array.isArray(newValue) ? newValue[0] : newValue; const normalizedValue = normalizePositiveInteger(value); if (normalizedValue === null) { return; } setModelConfigForm(prev => ({ ...prev, maxTokens: normalizedValue })); }; const handleMaxTokensInputChange = e => { const { value } = e.target; if (value === '') { setModelConfigForm(prev => ({ ...prev, maxTokens: '' })); return; } const normalizedValue = normalizePositiveInteger(value); if (normalizedValue === null) { return; } setModelConfigForm(prev => ({ ...prev, maxTokens: normalizedValue })); }; const handleMaxTokensInputBlur = () => { const normalizedValue = normalizePositiveInteger(modelConfigForm.maxTokens); if (normalizedValue !== null) { return; } setModelConfigForm(prev => ({ ...prev, maxTokens: DEFAULT_MODEL_SETTINGS.maxTokens })); }; // 保存模型 const handleSaveModel = () => { // 确保有模型 ID const normalizedModelId = String(modelConfigForm.modelId || '').trim(); const normalizedModelName = String(modelConfigForm.modelName || '').trim(); const isEditingExistingModel = Boolean(modelConfigForm.id || editingModel?.id); if (!isEditingExistingModel && !normalizedModelId) { toast.error(t('models.modelIdPlaceholder')); return; } const normalizedMaxTokens = normalizePositiveInteger(modelConfigForm.maxTokens); if (normalizedMaxTokens === null) { toast.error(t('models.maxTokensPositiveError', { defaultValue: 'Max Tokens must be a positive integer' })); return; } // 如果模型名称为空,则默认为模型 ID const dataToSave = { ...modelConfigForm, modelId: normalizedModelId, maxTokens: normalizedMaxTokens, modelName: normalizedModelName || normalizedModelId }; axios .post(`/api/projects/${projectId}/model-config`, dataToSave) .then(response => { if (selectedModelInfo && selectedModelInfo.id === response.data.id) { setSelectedModelInfo(response.data); } toast.success(t('settings.saveSuccess')); getModelConfigList(); handleCloseModelDialog(); }) .catch(error => { toast.error(t('settings.saveFailed')); console.error(error); }); }; // 删除模型 const handleDeleteModel = id => { axios .delete(`/api/projects/${projectId}/model-config/${id}`) .then(response => { toast.success(t('settings.deleteSuccess')); getModelConfigList(); }) .catch(error => { toast.error(t('settings.deleteFailed')); }); }; // 获取模型状态图标和颜色 const getModelStatusInfo = model => { const providerId = String(model?.providerId || '').toLowerCase(); if (providerId === 'ollama') { return { icon: , color: 'success', text: t('models.localModel') }; } else if (model.apiKey) { return { icon: , color: 'success', text: t('models.apiKeyConfigured') }; } else { return { icon: , color: 'warning', text: t('models.apiKeyNotConfigured') }; } }; const renderModelCard = model => { const modelStatus = getModelStatusInfo(model); const healthStatus = getHealthStatusInfo(model); const providerId = String(model?.providerId || '').toLowerCase(); const endpointLabel = `${formatEndpoint(model)}${ providerId !== 'ollama' && !model.apiKey ? ' (' + t('models.unconfiguredAPIKey') + ')' : '' }`; return ( { e.target.src = '/imgs/models/default.svg'; }} /> {model.modelName ? model.modelName : t('models.unselectedModel')} {model.providerName} checkModelEndpointHealth(model)} disabled={healthStatusMap[model.id]?.status === 'checking'} > router.push(`/projects/${projectId}/playground?modelId=${model.id}`)} color="secondary" > handleOpenModelDialog(model)} color="primary"> handleDeleteModel(model.id)} disabled={modelConfigList.length <= 1} color="error" > ); }; if (loading) { return {t('textSplit.loading')}; } return ( {t('settings.modelConfig')} {t('models.configuredModels', { defaultValue: 'Configured Models' })} {configuredModelList.map(renderModelCard)} {configuredModelList.length === 0 && ( {t('models.noConfiguredModels', { defaultValue: 'No configured models' })} )} {t('models.unconfiguredModels', { defaultValue: 'Unconfigured Models' })} {unconfiguredModelList.map(renderModelCard)} {unconfiguredModelList.length === 0 && ( {t('models.noUnconfiguredModels', { defaultValue: 'No unconfigured models' })} )} {/* 模型表单对话框 */} {editingModel ? t('models.edit') : t('models.add')} {/* provider */} option.label} value={ providerOptions.find(p => p.id === modelConfigForm.providerId) || { id: 'custom', label: modelConfigForm.providerName || '' } } onChange={onChangeProvider} renderInput={params => ( { // 当用户手动输入时,更新 provider 字段 setModelConfigForm(prev => ({ ...prev, providerId: 'custom', providerName: e.target.value })); }} /> )} renderOption={(props, option) => { return (
{ e.target.src = '/imgs/models/default.svg'; }} /> {option.label}
); }} />
{/* 接口地址 */} {/* API Key */} {/* 模型 ID */} model && model.modelId) .map(model => ({ label: `${model.modelName} (${model.modelId})`, modelName: model.modelName, modelId: model.modelId, providerId: model.providerId }))} value={modelConfigForm.modelId} onChange={(event, newValue) => { console.log('newValue', newValue); const newId = newValue?.modelId || newValue || ''; const newName = newValue?.modelName || newValue?.modelId || newValue || ''; setModelConfigForm(prev => ({ ...prev, modelId: newId, // 如果当前名称为空或与旧 ID 一致,则同步更新名称 modelName: !prev.modelName || prev.modelName === prev.modelId ? newName : prev.modelName })); }} renderInput={params => ( { setModelConfigForm(prev => ({ ...prev, modelId: e.target.value })); }} /> )} /> {/* 模型名称 */} {/* 新增:视觉模型选择项 */} {t('models.type')} {t('models.temperature')} {modelConfigForm.temperature} {t('models.maxTokens')} {t('models.maxTokensInputTip', { defaultValue: `Slider range: 1-${MAX_GENERATION_TOKENS}. You can also input any positive integer.` })} {t('models.topP', { defaultValue: 'Top P' })} {modelConfigForm.topP}
); } ================================================ FILE: components/settings/TaskSettings.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Typography, Box, Button, TextField, Grid, Card, CardContent, Slider, InputAdornment, Alert, Snackbar, FormControl, Select, InputLabel, MenuItem, Chip, FormHelperText } from '@mui/material'; import { useTranslation } from 'react-i18next'; import SaveIcon from '@mui/icons-material/Save'; import useTaskSettings from '@/hooks/useTaskSettings'; export default function TaskSettings({ projectId }) { const { t } = useTranslation(); const { taskSettings, setTaskSettings, loading, error, success, setSuccess } = useTaskSettings(projectId); // 确保 multiTurnRounds 有正确的初始值 useEffect(() => { if ( !loading && taskSettings && (taskSettings.multiTurnRounds === undefined || taskSettings.multiTurnRounds === null) ) { setTaskSettings(prev => ({ ...prev, multiTurnRounds: 3 // 默认值 })); } }, [loading, taskSettings, setTaskSettings]); // 处理设置变更 const handleSettingChange = e => { const { name, value } = e.target; setTaskSettings(prev => ({ ...prev, [name]: value })); }; // 处理滑块变更 const handleSliderChange = name => (event, newValue) => { setTaskSettings(prev => ({ ...prev, [name]: newValue })); }; // 保存任务配置 const handleSaveTaskSettings = async () => { try { // 确保数组类型的数据被正确处理 const settingsToSave = { ...taskSettings }; // 确保递归分块的分隔符数组存在 if (settingsToSave.splitType === 'recursive' && settingsToSave.separatorsInput) { if (!settingsToSave.separators || !Array.isArray(settingsToSave.separators)) { settingsToSave.separators = settingsToSave.separatorsInput.split(',').map(item => item.trim()); } } console.log('Saving settings:', settingsToSave); const response = await fetch(`/api/projects/${projectId}/tasks`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settingsToSave) }); if (!response.ok) { throw new Error(t('settings.saveTasksFailed')); } setSuccess(true); } catch (error) { console.error('保存任务配置出错:', error); //setError(error.message); } }; const handleCloseSnackbar = () => { setSuccess(false); //setError(null); }; if (loading) { return {t('common.loading')}; } return ( {' '} {/* 添加底部填充,为固定按钮留出空间 */} {t('settings.textSplitSettings')} {/* 分块策略选择 */} {t('settings.splitType')} {/* Markdown模式设置 */} {(!taskSettings.splitType || taskSettings.splitType === 'markdown') && ( <> {t('settings.minLength')}: {taskSettings.textSplitMinLength} {t('settings.maxLength')}: {taskSettings.textSplitMaxLength} )} {/* 通用 LangChain 参数设置 */} {taskSettings.splitType && taskSettings.splitType !== 'markdown' && ( <> {t('settings.chunkSize')}: {taskSettings.chunkSize || 3000} {t('settings.chunkOverlap')}: {taskSettings.chunkOverlap || 200} )} {/* Text 分块器特殊设置 */} {taskSettings.splitType === 'text' && ( )} {/* 自定义符号分块器特殊设置 */} {taskSettings.splitType === 'custom' && ( )} {/* Code 分块器特殊设置 */} {taskSettings.splitType === 'code' && ( {t('settings.codeLanguage')} {t('settings.codeLanguageHelper')} )} {/* Recursive 分块器特殊设置 */} {taskSettings.splitType === 'recursive' && ( {t('settings.separators')} ,-'} onChange={e => { const value = e.target.value; // 同时更新输入框值和分隔符数组 setTaskSettings(prev => ({ ...prev, separatorsInput: value, separators: value.split(',').map(item => item.trim()) })); }} helperText={t('settings.separatorsHelper')} /> {(taskSettings.separators || ['|', '##', '>', '-']).map((sep, index) => ( ))} )} {t('settings.textSplitDescription')} {t('settings.questionGenSettings')} {t('settings.questionGenLength', { length: taskSettings.questionGenerationLength })} {t('settings.questionGenDescription')} {t('settings.questionMaskRemovingProbability', { probability: taskSettings.questionMaskRemovingProbability })} {t('settings.pdfSettings')} {/* 多轮对话数据集设置 */} {t('settings.multiTurnSettings')} {/* 系统提示词 */} {/* 对话场景 */} {/* 对话轮数 */} {t('settings.multiTurnRounds', { rounds: taskSettings.multiTurnRounds || 3 })} {/* 角色A设定 */} {/* 角色B设定 */} {t('settings.multiTurnDescription')} {/* 测试集生成设置 */} {t('settings.evalQuestionSettings')} {t('settings.evalQuestionSettingsDescription')} { const value = Math.max(0, parseInt(e.target.value) || 0); setTaskSettings(prev => ({ ...prev, evalQuestionTypeRatios: { ...prev.evalQuestionTypeRatios, true_false: value } })); }} InputProps={{ inputProps: { min: 0 } }} /> {/* 单选题 */} { const value = Math.max(0, parseInt(e.target.value) || 0); setTaskSettings(prev => ({ ...prev, evalQuestionTypeRatios: { ...prev.evalQuestionTypeRatios, single_choice: value } })); }} InputProps={{ inputProps: { min: 0 } }} /> {/* 多选题 */} { const value = Math.max(0, parseInt(e.target.value) || 0); setTaskSettings(prev => ({ ...prev, evalQuestionTypeRatios: { ...prev.evalQuestionTypeRatios, multiple_choice: value } })); }} InputProps={{ inputProps: { min: 0 } }} /> {/* 固定短答案 */} { const value = Math.max(0, parseInt(e.target.value) || 0); setTaskSettings(prev => ({ ...prev, evalQuestionTypeRatios: { ...prev.evalQuestionTypeRatios, short_answer: value } })); }} InputProps={{ inputProps: { min: 0 } }} /> {/* 开放式回答 */} { const value = Math.max(0, parseInt(e.target.value) || 0); setTaskSettings(prev => ({ ...prev, evalQuestionTypeRatios: { ...prev.evalQuestionTypeRatios, open_ended: value } })); }} InputProps={{ inputProps: { min: 0 } }} /> {t('settings.evalQuestionRatioHelper')} {t('settings.huggingfaceSettings')} {t('settings.saveSuccess')} {error} {/* 吸底保存按钮 */} ); } ================================================ FILE: components/tasks/TaskActions.js ================================================ 'use client'; import React from 'react'; import { IconButton, Tooltip } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import StopCircleIcon from '@mui/icons-material/StopCircle'; import { useTranslation } from 'react-i18next'; // 任务操作组件 export default function TaskActions({ task, onAbort, onDelete }) { const { t } = useTranslation(); // 处理中的任务显示中断按钮,其他状态显示删除按钮 return task.status === 0 ? ( onAbort(task.id)}> ) : ( onDelete(task.id)}> ); } ================================================ FILE: components/tasks/TaskFilters.js ================================================ 'use client'; import React from 'react'; import { Box, FormControl, InputLabel, Select, MenuItem, OutlinedInput, IconButton, Tooltip, CircularProgress } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; import { useTranslation } from 'react-i18next'; export default function TaskFilters({ statusFilter, setStatusFilter, typeFilter, setTypeFilter, loading, onRefresh }) { const { t } = useTranslation(); const taskTypeOptions = [ 'text-processing', 'file-processing', 'pdf-processing', 'question-generation', 'answer-generation', 'data-cleaning', 'data-distillation', 'eval-generation', 'multi-turn-generation', 'image-question-generation' ]; return ( {t('tasks.filters.status')} {t('tasks.filters.type')} {loading ? : } ); } ================================================ FILE: components/tasks/TaskProgress.js ================================================ 'use client'; import React from 'react'; import { Stack, LinearProgress, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; // 任务进度组件 export default function TaskProgress({ task }) { const { t } = useTranslation(); // 如果没有总数,则不显示进度条 if (task.totalCount === 0) return '-'; // 计算进度百分比 const progress = (task.completedCount / task.totalCount) * 100; return ( {task.completedCount} / {task.totalCount} ({Math.round(progress)}%) ); } ================================================ FILE: components/tasks/TaskStatusChip.js ================================================ 'use client'; import React from 'react'; import { Chip, CircularProgress, Box } from '@mui/material'; import { useTranslation } from 'react-i18next'; // 任务状态显示组件 export default function TaskStatusChip({ status }) { const { t } = useTranslation(); // 状态映射配置 const STATUS_CONFIG = { 0: { label: t('tasks.status.processing'), color: 'warning', loading: true }, 1: { label: t('tasks.status.completed'), color: 'success' }, 2: { label: t('tasks.status.failed'), color: 'error' }, 3: { label: t('tasks.status.aborted'), color: 'default' } }; const statusInfo = STATUS_CONFIG[status] || { label: t('tasks.status.unknown'), color: 'default' }; // 处理中状态显示加载动画 if (status === 0) { return ( ); } return ; } ================================================ FILE: components/tasks/TasksTable.js ================================================ 'use client'; import React from 'react'; import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Typography, CircularProgress, Box, TablePagination, Tooltip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { formatDistanceToNow } from 'date-fns'; import { zhCN, enUS } from 'date-fns/locale'; import TaskStatusChip from './TaskStatusChip'; import TaskProgress from './TaskProgress'; import TaskActions from './TaskActions'; export default function TasksTable({ tasks, loading, handleAbortTask, handleDeleteTask, page, rowsPerPage, handleChangePage, handleChangeRowsPerPage, totalCount }) { const { t, i18n } = useTranslation(); const formatDate = dateString => { if (!dateString) return '-'; const date = new Date(dateString); return formatDistanceToNow(date, { addSuffix: true, locale: i18n.language === 'zh-CN' ? zhCN : enUS }); }; const calculateDuration = (startTimeStr, endTimeStr) => { if (!startTimeStr || !endTimeStr) return '-'; try { const startTime = new Date(startTimeStr); const endTime = new Date(endTimeStr); const duration = endTime - startTime; const seconds = Math.floor(duration / 1000); if (seconds < 60) { return t('tasks.duration.seconds', { seconds }); } if (seconds < 3600) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return t('tasks.duration.minutes', { minutes, seconds: remainingSeconds }); } const hours = Math.floor(seconds / 3600); const remainingMinutes = Math.floor((seconds % 3600) / 60); return t('tasks.duration.hours', { hours, minutes: remainingMinutes }); } catch (error) { console.error('Failed to calculate duration:', error); return '-'; } }; const parseModelInfo = modelInfoString => { let modelInfo = ''; try { const parsedModel = JSON.parse(modelInfoString); modelInfo = parsedModel.modelName || parsedModel.name || '-'; } catch { modelInfo = modelInfoString || '-'; } return modelInfo; }; const toTaskTypeLabel = taskType => { if (!taskType) return '-'; return String(taskType) .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); }; const getLocalizedTaskType = taskType => { return t(`tasks.types.${taskType}`, { defaultValue: toTaskTypeLabel(taskType) }); }; const parseJsonSafely = input => { if (!input || typeof input !== 'string') return null; try { return JSON.parse(input); } catch { return null; } }; const formatTaskNote = task => { const note = String(task?.note || '').trim(); if (!note) return '-'; const noteJson = parseJsonSafely(note); if (noteJson) { if (Array.isArray(noteJson.chunkIds)) { return t('tasks.notes.selectedChunks', { count: noteJson.chunkIds.length }); } if (Array.isArray(noteJson.fileList)) { return t('tasks.notes.fileBatch', { count: noteJson.fileList.length, strategy: noteJson.strategy || '-' }); } return t('tasks.notes.jsonParams'); } if (note === 'No chunks require question generation' || note.startsWith('No chunks require question gen')) { return t('tasks.notes.noChunksQuestion'); } if (note === 'No chunks require cleaning' || note.startsWith('No chunks require clean')) { return t('tasks.notes.noChunksCleaning'); } if (note.startsWith('Processing failed:')) { return t('tasks.notes.processingFailed', { error: note.replace('Processing failed:', '').trim() }); } const summaryMatch = note.match(/Processed:\s*(\d+)\/(\d+),\s*succeeded:\s*(\d+),\s*failed:\s*(\d+)/i); if (summaryMatch) { const [, processed, total, succeeded, failed] = summaryMatch; const questionMatch = note.match(/questions generated:\s*(\d+)/i); if (questionMatch) { return t('tasks.notes.questionSummary', { processed, total, succeeded, failed, generated: questionMatch[1] }); } const datasetMatch = note.match(/datasets generated:\s*(\d+)/i); if (datasetMatch) { return t('tasks.notes.datasetSummary', { processed, total, succeeded, failed, generated: datasetMatch[1] }); } const cleaningMatch = note.match(/total original length:\s*(\d+),\s*total cleaned length:\s*(\d+)/i); if (cleaningMatch) { return t('tasks.notes.cleaningSummary', { processed, total, succeeded, failed, original: cleaningMatch[1], cleaned: cleaningMatch[2] }); } return t('tasks.notes.genericSummary', { processed, total, succeeded, failed }); } return note; }; const truncateNote = (note, maxLength = 48) => { if (!note) return '-'; if (note.length <= maxLength) return note; return `${note.substring(0, maxLength)}...`; }; return ( {t('tasks.table.type')} {t('tasks.table.status')} {t('tasks.table.progress')} {t('tasks.table.createTime')} {t('tasks.table.duration')} {t('tasks.table.model')} {t('tasks.table.note')} {t('tasks.table.actions')} {loading && tasks.length === 0 ? ( {t('tasks.loading')} ) : tasks.length === 0 ? ( {t('tasks.empty')} ) : ( tasks.map(task => { const noteText = formatTaskNote(task); return ( {getLocalizedTaskType(task.taskType)} {formatDate(task.createAt)} {task.endTime ? calculateDuration(task.startTime, task.endTime) : '-'} {parseModelInfo(task.modelInfo)} {noteText !== '-' ? ( {truncateNote(noteText)} ) : ( '-' )} ); }) )}
{tasks.length > 0 && ( { const calculatedFrom = page * rowsPerPage + 1; const calculatedTo = Math.min((page + 1) * rowsPerPage, count); return t('datasets.pagination', { from: calculatedFrom, to: calculatedTo, count }); }} /> )}
); } ================================================ FILE: components/text-split/BatchEditChunkDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, RadioGroup, FormControlLabel, Radio, FormControl, FormLabel, Box, Typography, Alert, CircularProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; /** * 批量编辑文本块对话框 * @param {Object} props * @param {boolean} props.open - 对话框是否打开 * @param {Function} props.onClose - 关闭对话框的回调 * @param {Function} props.onConfirm - 确认编辑的回调 * @param {Array} props.selectedChunks - 选中的文本块ID数组 * @param {number} props.totalChunks - 文本块总数 * @param {boolean} props.loading - 是否正在处理 */ export default function BatchEditChunksDialog({ open, onClose, onConfirm, selectedChunks = [], totalChunks = 0, loading = false }) { const { t } = useTranslation(); const [position, setPosition] = useState('start'); // 'start' 或 'end' const [content, setContent] = useState(''); const [error, setError] = useState(''); // 处理位置变更 const handlePositionChange = event => { setPosition(event.target.value); }; // 处理内容变更 const handleContentChange = event => { setContent(event.target.value); if (error) setError(''); }; // 处理确认 const handleConfirm = () => { if (!content.trim()) { setError(t('batchEdit.contentRequired')); return; } onConfirm({ position, content: content.trim(), chunkIds: selectedChunks }); }; // 处理关闭 const handleClose = () => { if (!loading) { setContent(''); setError(''); setPosition('start'); onClose(); } }; return ( {t('batchEdit.title')} {/* 选择提示 */} {selectedChunks.length === totalChunks ? t('batchEdit.allChunksSelected', { count: totalChunks }) : t('batchEdit.selectedChunks', { selected: selectedChunks.length, total: totalChunks })} {/* 位置选择 */} {t('batchEdit.position')} } label={t('batchEdit.atBeginning')} /> } label={t('batchEdit.atEnd')} /> {/* 内容输入 */} {/* 预览示例 */} {content.trim() && ( {t('batchEdit.preview')}: {position === 'start' ? ( <> {content} {'\n\n[原始文本块内容...]'} ) : ( <> {'[原始文本块内容...]\n\n'} {content} )} )} ); } ================================================ FILE: components/text-split/ChunkBatchDeleteDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, CircularProgress } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function ChunkBatchDeleteDialog({ open, onClose, onConfirm, loading, count }) { const { t } = useTranslation(); return ( {t('textSplit.batchDeleteChunksConfirmTitle', { defaultValue: '确认批量删除' })} {t('textSplit.batchDeleteChunksConfirmMessage', { count, defaultValue: `您确定要删除选中的 ${count} 个文本块吗?此操作不可恢复。` })} ); } ================================================ FILE: components/text-split/ChunkCard.js ================================================ 'use client'; import { useRouter } from 'next/navigation'; import { useState, useEffect } from 'react'; import { Box, Typography, IconButton, Chip, Checkbox, Tooltip, Card, CardContent, CardActions, Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, CircularProgress } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import VisibilityIcon from '@mui/icons-material/Visibility'; import QuizIcon from '@mui/icons-material/Quiz'; import EditIcon from '@mui/icons-material/Edit'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; import AssignmentIcon from '@mui/icons-material/Assignment'; import { useTheme } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; // 编辑文本块对话框组件 const EditChunkDialog = ({ open, chunk, onClose, onSave }) => { const [content, setContent] = useState(chunk?.content || ''); const { t } = useTranslation(); // 当文本块变化时更新内容 useEffect(() => { if (chunk?.content) { setContent(chunk.content); } }, [chunk]); const handleSave = () => { onSave(content); onClose(); }; return ( {t('textSplit.editChunk', { chunkId: chunk?.name })} setContent(e.target.value)} variant="outlined" sx={{ mt: 1 }} /> ); }; export default function ChunkCard({ chunk, selected, onSelect, onView, onDelete, onGenerateQuestions, onDataCleaning, onEdit, onGenerateEvalQuestions, // 新增:生成测评题目的回调 projectId, selectedModel // 添加selectedModel参数 }) { const theme = useTheme(); const { t } = useTranslation(); const router = useRouter(); const [editDialogOpen, setEditDialogOpen] = useState(false); const [chunkForEdit, setChunkForEdit] = useState(null); const [generatingQuestions, setGeneratingQuestions] = useState(false); const [generatingEval, setGeneratingEval] = useState(false); // 获取文本预览 const getTextPreview = (content, maxLength = 150) => { if (!content) return ''; return content.length > maxLength ? `${content.substring(0, maxLength)}...` : content; }; // 检查是否有已生成的问题 const hasQuestions = chunk.questions && chunk.questions.length > 0; // 处理编辑按钮点击 const handleEditClick = async () => { try { // 显示加载状态 console.log('正在获取文本块完整内容...'); console.log('projectId:', projectId, 'chunkId:', chunk.id); // 先获取完整的文本块内容,使用从外部传入的 projectId const response = await fetch(`/api/projects/${projectId}/chunks/${encodeURIComponent(chunk.id)}`); if (!response.ok) { throw new Error(t('textSplit.fetchChunkFailed')); } const data = await response.json(); console.log('获取文本块完整内容成功:', data); // 先设置完整数据,再打开对话框(与 ChunkList.js 中的实现一致) setChunkForEdit(data); setEditDialogOpen(true); } catch (error) { console.error(t('textSplit.fetchChunkError'), error); // 如果出错,使用原始预览数据 alert(t('textSplit.fetchChunkError')); } }; // 处理保存编辑内容 const handleSaveEdit = newContent => { if (onEdit) { onEdit(chunk.id, newContent); } }; // 处理生成单个问题 - 后台执行,不阻塞UI const handleGenerateQuestionsClick = async () => { setGeneratingQuestions(true); try { await onGenerateQuestions([chunk.id]); } finally { // Always release loading state, even when generation fails. setTimeout(() => { setGeneratingQuestions(false); }, 500); } }; // 处理生成测评题目 const handleGenerateEvalQuestionsClick = async () => { if (!onGenerateEvalQuestions) return; setGeneratingEval(true); try { await onGenerateEvalQuestions(chunk.id); } finally { // 延迟关闭加载状态 setTimeout(() => { setGeneratingEval(false); }, 500); } }; return ( <> {chunk.name} {chunk.Questions.length > 0 && ( {chunk.Questions.map((q, index) => ( {index + 1}. {q.question} ))} } arrow placement="top" > { if (!projectId) return; router.push(`/projects/${projectId}/questions`); }} /> )} {chunk.EvalDatasets && chunk.EvalDatasets.length > 0 && ( { if (!projectId) return; router.push(`/projects/${projectId}/eval-datasets`); }} /> )} {getTextPreview(chunk.content)} {generatingQuestions ? : } {generatingEval ? : } {/* 编辑文本块对话框 */} { setEditDialogOpen(false); setChunkForEdit(null); }} onSave={handleSaveEdit} /> ); } ================================================ FILE: components/text-split/ChunkDeleteDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function ChunkDeleteDialog({ open, onClose, onConfirm }) { const { t } = useTranslation(); return ( {t('common.confirmDelete')}? {t('common.confirmDelete')}? ); } ================================================ FILE: components/text-split/ChunkFilterDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, TextField, Typography, Slider, FormControlLabel, Checkbox } from '@mui/material'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; export default function ChunkFilterDialog({ open, onClose, onApply, initialFilters = {} }) { const { t } = useTranslation(); const [contentKeyword, setContentKeyword] = useState(initialFilters.contentKeyword || ''); const [sizeRange, setSizeRange] = useState(initialFilters.sizeRange || [0, 10000]); const [hasQuestions, setHasQuestions] = useState(initialFilters.hasQuestions || null); // 重置筛选条件 const handleReset = () => { setContentKeyword(''); setSizeRange([0, 10000]); setHasQuestions(null); }; // 应用筛选 const handleApply = () => { onApply({ contentKeyword, sizeRange, hasQuestions }); onClose(); }; // 处理大小范围变化 const handleSizeRangeChange = (event, newValue) => { setSizeRange(newValue); }; return ( {t('datasets.moreFilters', { defaultValue: '更多筛选' })} {/* 文本块内容筛选 */} {t('textSplit.contentKeyword', { defaultValue: '文本块内容' })} setContentKeyword(e.target.value)} variant="outlined" /> {/* 字数范围筛选 */} {t('textSplit.characterRange', { defaultValue: '字数范围' })} {sizeRange[0]} - {sizeRange[1]} {/* 是否有问题的筛选 */} {t('textSplit.questionStatus', { defaultValue: '问题状态' })} setHasQuestions(null)} />} label={t('textSplit.allChunks', { defaultValue: '全部' })} /> setHasQuestions(true)} />} label={t('textSplit.generatedQuestions2', { defaultValue: '已生成问题' })} /> setHasQuestions(false)} />} label={t('textSplit.ungeneratedQuestions', { defaultValue: '未生成问题' })} /> ); } ================================================ FILE: components/text-split/ChunkList.js ================================================ 'use client'; import { useState, useEffect, useMemo } from 'react'; import { Box, Paper, Typography, CircularProgress, Pagination, Grid } from '@mui/material'; import ChunkListHeader from './ChunkListHeader'; import ChunkCard from './ChunkCard'; import ChunkViewDialog from './ChunkViewDialog'; import ChunkDeleteDialog from './ChunkDeleteDialog'; import BatchEditChunksDialog from './BatchEditChunkDialog'; import ChunkBatchDeleteDialog from './ChunkBatchDeleteDialog'; import { useTheme } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; /** * Chunk list component * @param {Object} props * @param {string} props.projectId - Project ID * @param {Array} props.chunks - Chunk array * @param {Function} props.onDelete - Delete callback * @param {Function} props.onEdit - Edit callback * @param {Function} props.onGenerateQuestions - Generate questions callback * @param {Function} props.onDataCleaning - Data cleaning callback * @param {string} props.questionFilter - Question filter * @param {Function} props.onQuestionFilterChange - Question filter change callback * @param {Object} props.selectedModel - 閫変腑鐨勬ā鍨嬩俊鎭? */ export default function ChunkList({ projectId, chunks = [], onDelete, onEdit, onGenerateQuestions, onGenerateEvalQuestions, onDataCleaning, loading = false, questionFilter, setQuestionFilter, selectedModel, onChunksUpdate }) { const theme = useTheme(); const [page, setPage] = useState(1); const [selectedChunks, setSelectedChunks] = useState([]); const [viewChunk, setViewChunk] = useState(null); const [viewDialogOpen, setViewDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [chunkToDelete, setChunkToDelete] = useState(null); const [batchEditDialogOpen, setBatchEditDialogOpen] = useState(false); const [batchEditLoading, setBatchEditLoading] = useState(false); const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false); const [batchDeleteLoading, setBatchDeleteLoading] = useState(false); // 娣诲姞楂樼骇绛涢€夌姸鎬? const [advancedFilters, setAdvancedFilters] = useState({ contentKeyword: '', sizeRange: [0, 10000], hasQuestions: null }); // 璁$畻娲昏穬绛涢€夋潯浠舵暟 const activeFilterCount = useMemo(() => { let count = 0; if (advancedFilters.contentKeyword) count++; if (advancedFilters.sizeRange[0] > 0 || advancedFilters.sizeRange[1] < 10000) count++; if (advancedFilters.hasQuestions !== null) count++; return count; }, [advancedFilters]); const sortedChunks = useMemo( () => [...chunks].sort((a, b) => { if (a.fileId !== b.fileId) { return a.fileId.localeCompare(b.fileId); } const getPartNumber = name => { const match = name.match(/part-(\d+)/); return match ? parseInt(match[1], 10) : 0; }; const numA = getPartNumber(a.name); const numB = getPartNumber(b.name); return numA - numB; }), [chunks] ); const filteredChunks = useMemo(() => { return sortedChunks.filter(chunk => { if (advancedFilters.contentKeyword) { const keyword = advancedFilters.contentKeyword.toLowerCase(); if (!chunk.content?.toLowerCase().includes(keyword)) { return false; } } const size = chunk.size || 0; if (size < advancedFilters.sizeRange[0] || size > advancedFilters.sizeRange[1]) { return false; } if (advancedFilters.hasQuestions !== null) { const hasQuestions = chunk.Questions && chunk.Questions.length > 0; if (advancedFilters.hasQuestions !== hasQuestions) { return false; } } return true; }); }, [sortedChunks, advancedFilters]); // 褰撶瓫閫夋潯浠跺彉鍖栨椂锛屾竻闄や笉鍦ㄧ瓫閫夌粨鏋滀腑鐨勯€変腑椤? useEffect(() => { const filteredChunkIds = filteredChunks.map(chunk => chunk.id); setSelectedChunks(prev => prev.filter(id => filteredChunkIds.includes(id))); }, [filteredChunks]); const itemsPerPage = 5; const displayedChunks = useMemo(() => { const startIndex = (page - 1) * itemsPerPage; return filteredChunks.slice(startIndex, startIndex + itemsPerPage); }, [filteredChunks, page]); const totalPages = useMemo(() => Math.ceil(filteredChunks.length / itemsPerPage), [filteredChunks.length]); const { t } = useTranslation(); const handlePageChange = (event, value) => { setPage(value); }; const handleViewChunk = async chunkId => { try { const response = await fetch(`/api/projects/${projectId}/chunks/${chunkId}`); if (!response.ok) { throw new Error(t('textSplit.fetchChunksFailed')); } const data = await response.json(); setViewChunk(data); setViewDialogOpen(true); } catch (error) { console.error(t('textSplit.fetchChunksError'), error); } }; const handleCloseViewDialog = () => { setViewDialogOpen(false); }; const handleOpenDeleteDialog = chunkId => { setChunkToDelete(chunkId); setDeleteDialogOpen(true); }; const handleCloseDeleteDialog = () => { setDeleteDialogOpen(false); setChunkToDelete(null); }; const handleConfirmDelete = () => { if (chunkToDelete && onDelete) { onDelete(chunkToDelete); } handleCloseDeleteDialog(); }; // 澶勭悊缂栬緫鏂囨湰鍧? const handleEditChunk = async (chunkId, newContent) => { if (onEdit) { onEdit(chunkId, newContent); onChunksUpdate(); } }; // 澶勭悊閫夋嫨鏂囨湰鍧? const handleSelectChunk = chunkId => { setSelectedChunks(prev => { if (prev.includes(chunkId)) { return prev.filter(id => id !== chunkId); } else { return [...prev, chunkId]; } }); }; const handleSelectAll = () => { if (selectedChunks.length === filteredChunks.length) { setSelectedChunks([]); } else { setSelectedChunks(filteredChunks.map(chunk => chunk.id)); } }; const handleBatchGenerateQuestions = () => { if (onGenerateQuestions && selectedChunks.length > 0) { onGenerateQuestions(selectedChunks); } }; const handleBatchEdit = async editData => { try { setBatchEditLoading(true); // 璋冪敤鎵归噺缂栬緫API const response = await fetch(`/api/projects/${projectId}/chunks/batch-edit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ position: editData.position, content: editData.content, chunkIds: editData.chunkIds }) }); if (!response.ok) { throw new Error('鎵归噺缂栬緫澶辫触'); } const result = await response.json(); if (result.success) { // 缂栬緫鎴愬姛鍚庯紝鍒锋柊鏂囨湰鍧楁暟鎹? if (onChunksUpdate) { onChunksUpdate(); } // 娓呯┖閫変腑鐘舵€? setSelectedChunks([]); // 鍏抽棴瀵硅瘽妗? setBatchEditDialogOpen(false); // 鏄剧ず鎴愬姛娑堟伅 console.log(`鎴愬姛鏇存柊浜?${result.updatedCount} 涓枃鏈潡`); } else { throw new Error(result.message || '鎵归噺缂栬緫澶辫触'); } } catch (error) { console.error('鎵归噺缂栬緫澶辫触:', error); // 杩欓噷鍙互娣诲姞閿欒鎻愮ず } finally { setBatchEditLoading(false); } }; // 鎵撳紑鎵归噺缂栬緫瀵硅瘽妗? const handleOpenBatchEdit = () => { setBatchEditDialogOpen(true); }; // 鍏抽棴鎵归噺缂栬緫瀵硅瘽妗? const handleCloseBatchEdit = () => { setBatchEditDialogOpen(false); }; if (loading) { return ( ); } // 澶勭悊绛涢€夊彉鍖? const handleFilterChange = filters => { setAdvancedFilters(filters); setPage(1); // 閲嶇疆鍒扮涓€椤? }; // 鎵撳紑鎵归噺鍒犻櫎瀵硅瘽妗? const handleOpenBatchDelete = () => { setBatchDeleteDialogOpen(true); }; // 鍏抽棴鎵归噺鍒犻櫎瀵硅瘽妗? const handleCloseBatchDelete = () => { setBatchDeleteDialogOpen(false); }; // 纭鎵归噺鍒犻櫎 const handleConfirmBatchDelete = async () => { if (selectedChunks.length === 0) return; try { setBatchDeleteLoading(true); let successCount = 0; let failCount = 0; // 寰幆璋冪敤鍗曚釜鍒犻櫎鎺ュ彛 for (const chunkId of selectedChunks) { try { await onDelete(chunkId); successCount++; } catch (error) { console.error(`鍒犻櫎鏂囨湰鍧?${chunkId} 澶辫触:`, error); failCount++; } } // 鏄剧ず鍒犻櫎缁撴灉 if (failCount === 0) { console.log(`鎴愬姛鍒犻櫎 ${successCount} 涓枃鏈潡`); } else { console.log(`删除完成:成功 ${successCount} 个,失败 ${failCount} 个`); } // 娓呯┖閫変腑鐘舵€? setSelectedChunks([]); // 鍒锋柊鏁版嵁 if (onChunksUpdate) { onChunksUpdate(); } // 鍏抽棴瀵硅瘽妗? setBatchDeleteDialogOpen(false); } catch (error) { console.error('鎵归噺鍒犻櫎澶辫触:', error); } finally { setBatchDeleteLoading(false); } }; return ( setQuestionFilter(event.target.value)} chunks={chunks} selectedModel={selectedModel} onFilterChange={handleFilterChange} activeFilterCount={activeFilterCount} /> {displayedChunks.map(chunk => ( handleSelectChunk(chunk.id)} onView={() => handleViewChunk(chunk.id)} onDelete={() => handleOpenDeleteDialog(chunk.id)} onEdit={handleEditChunk} onGenerateQuestions={() => onGenerateQuestions && onGenerateQuestions([chunk.id])} onGenerateEvalQuestions={() => onGenerateEvalQuestions && onGenerateEvalQuestions(chunk.id)} onDataCleaning={() => onDataCleaning && onDataCleaning([chunk.id])} projectId={projectId} selectedModel={selectedModel} /> ))} {chunks.length === 0 && ( {t('textSplit.noChunks')} )} {totalPages > 1 && ( )} {/* 鏂囨湰鍧楄鎯呭璇濇 */} {/* 鍒犻櫎纭瀵硅瘽妗?*/} {/* 鎵归噺缂栬緫瀵硅瘽妗?*/} {/* 鎵归噺鍒犻櫎纭瀵硅瘽妗?*/} ); } ================================================ FILE: components/text-split/ChunkListHeader.js ================================================ 'use client'; import { Box, Typography, Checkbox, Button, Select, MenuItem, Tooltip, Menu, IconButton, Badge } from '@mui/material'; import QuizIcon from '@mui/icons-material/Quiz'; import DownloadIcon from '@mui/icons-material/Download'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; import AssessmentIcon from '@mui/icons-material/Assessment'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import FilterListIcon from '@mui/icons-material/FilterList'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import axios from 'axios'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import ChunkFilterDialog from './ChunkFilterDialog'; export default function ChunkListHeader({ projectId, totalChunks, selectedChunks, onSelectAll, onBatchGenerateQuestions, onBatchEditChunks, onBatchDeleteChunks, questionFilter, setQuestionFilter, chunks = [], // 添加chunks参数,用于导出文本块 selectedModel = {}, onFilterChange = null, activeFilterCount = 0 }) { const { t, i18n } = useTranslation(); // 添加更多菜单的状态和锚点 const [moreMenuAnchorEl, setMoreMenuAnchorEl] = useState(null); const isMoreMenuOpen = Boolean(moreMenuAnchorEl); // 添加筛选对话框状态 const [filterDialogOpen, setFilterDialogOpen] = useState(false); // 自动任务菜单状态 const [autoTasksMenuAnchorEl, setAutoTasksMenuAnchorEl] = useState(null); const isAutoTasksMenuOpen = Boolean(autoTasksMenuAnchorEl); const handleAutoTasksClick = event => { setAutoTasksMenuAnchorEl(event.currentTarget); }; const handleAutoTasksClose = () => { setAutoTasksMenuAnchorEl(null); }; // 打开更多菜单 const handleMoreMenuClick = event => { setMoreMenuAnchorEl(event.currentTarget); }; // 关闭更多菜单 const handleMoreMenuClose = () => { setMoreMenuAnchorEl(null); }; // 处理批量编辑,关闭菜单并调用原有函数 const handleBatchEdit = () => { handleMoreMenuClose(); onBatchEditChunks(); }; // 处理批量删除,关闭菜单并调用原有函数 const handleBatchDelete = () => { handleMoreMenuClose(); onBatchDeleteChunks(); }; // 处理导出文本块,关闭菜单并调用原有函数 const handleExport = () => { handleMoreMenuClose(); handleExportChunks(); }; // 创建自动提取问题任务 const handleCreateAutoQuestionTask = async () => { if (!projectId || !selectedModel?.id) { toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' })); return; } try { // 调用创建任务接口 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'question-generation', modelInfo: selectedModel, language: i18n.language, detail: '批量生成问题任务' }); if (response.data?.code === 0) { toast.success(t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理未生成问题的文本块' })); } else { toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message); } } catch (error) { console.error('创建自动提取问题任务失败:', error); toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message); } }; // 创建自动数据清洗任务 const handleCreateAutoDataCleaningTask = async () => { if (!projectId || !selectedModel?.id) { toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' })); return; } try { // 调用创建任务接口 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'data-cleaning', modelInfo: selectedModel, language: i18n.language, detail: '批量数据清洗任务' }); if (response.data?.code === 0) { toast.success( t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动处理所有文本块进行数据清洗' }) ); } else { toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message); } } catch (error) { console.error('创建自动数据清洗任务失败:', error); toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message); } }; // 创建自动生成评估数据集任务 const handleCreateAutoEvalGenerationTask = async () => { if (!projectId || !selectedModel?.id) { toast.error(t('textSplit.selectModelFirst', { defaultValue: '请先选择模型' })); return; } try { // 调用创建任务接口 const response = await axios.post(`/api/projects/${projectId}/tasks`, { taskType: 'eval-generation', modelInfo: selectedModel, language: i18n.language, detail: '批量生成评估数据集任务' }); if (response.data?.code === 0) { toast.success( t('tasks.createSuccess', { defaultValue: '后台任务已创建,系统将自动为所有未生成评估题目的文本块生成评估数据集' }) ); } else { toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + response.data?.message); } } catch (error) { console.error('创建自动生成评估数据集任务失败:', error); toast.error(t('tasks.createFailed', { defaultValue: '创建任务失败' }) + ': ' + error.message); } }; // 导出文本块为JSON文件的函数 const handleExportChunks = () => { if (!chunks || chunks.length === 0) return; // 创建要导出的数据对象 const exportData = chunks.map(chunk => ({ name: chunk.name, projectId: chunk.projectId, fileName: chunk.fileName, content: chunk.content, summary: chunk.summary, size: chunk.size })); // 将数据转换为JSON字符串 const jsonString = JSON.stringify(exportData, null, 2); // 创建Blob对象 const blob = new Blob([jsonString], { type: 'application/json' }); // 创建下载链接 const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `text-chunks-export-${new Date().toISOString().split('T')[0]}.json`; // 触发下载 document.body.appendChild(a); a.click(); // 清理 document.body.removeChild(a); URL.revokeObjectURL(url); }; return ( 0 && selectedChunks.length < totalChunks} onChange={onSelectAll} /> {t('textSplit.selectedCount', { count: selectedChunks.length })} {/* 更多筛选按钮 */} {/* 自动任务下拉菜单 */} { handleCreateAutoQuestionTask(); handleAutoTasksClose(); }} > {t('textSplit.autoGenerateQuestions')} { handleCreateAutoEvalGenerationTask(); handleAutoTasksClose(); }} > {t('textSplit.autoEvalGeneration', { defaultValue: '自动生成评估集' })} { handleCreateAutoDataCleaningTask(); handleAutoTasksClose(); }} > {t('textSplit.autoDataCleaning', { defaultValue: '自动数据清洗' })} {/* 更多菜单按钮 */} {/* 更多操作下拉菜单 */} {t('batchEdit.batchEdit', { defaultValue: '批量编辑' })} {t('textSplit.batchDeleteChunks', { defaultValue: '批量删除' })} {t('textSplit.exportChunks', { defaultValue: '导出文本块' })} {/* 筛选对话框 */} setFilterDialogOpen(false)} onApply={onFilterChange} /> ); } ================================================ FILE: components/text-split/ChunkViewDialog.js ================================================ 'use client'; import { Box, Button, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress } from '@mui/material'; import ReactMarkdown from 'react-markdown'; import { useTranslation } from 'react-i18next'; import 'github-markdown-css/github-markdown-light.css'; export default function ChunkViewDialog({ open, chunk, onClose }) { const { t } = useTranslation(); return ( {t('textSplit.chunkDetails', { chunkId: chunk?.name })} {chunk ? (
{chunk.content}
) : ( )}
); } ================================================ FILE: components/text-split/DomainAnalysis.js ================================================ 'use client'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Paper, Typography, Divider, CircularProgress, Tabs, Tab, List, ListItem, ListItemText, Collapse, IconButton, TextField, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Tooltip, Menu, MenuItem } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import TabPanel from './components/TabPanel'; import ReactMarkdown from 'react-markdown'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import axios from 'axios'; import { toast } from 'sonner'; import 'github-markdown-css/github-markdown-light.css'; /** * 领域分析组件 * @param {Object} props * @param {string} props.projectId - 项目ID * @param {Array} props.toc - 目录结构数组 * @param {Array} props.tags - 标签树数组 * @param {boolean} props.loading - 是否加载中 * @param {Function} props.onTagsUpdate - 标签更新回调 */ // 领域树节点组件 function TreeNode({ node, level = 0, onEdit, onDelete, onAddChild }) { const [open, setOpen] = useState(true); const theme = useTheme(); const hasChildren = node.child && node.child.length > 0; const [anchorEl, setAnchorEl] = useState(null); const menuOpen = Boolean(anchorEl); const { t } = useTranslation(); const handleClick = () => { if (hasChildren) { setOpen(!open); } }; const handleMenuOpen = event => { event.stopPropagation(); setAnchorEl(event.currentTarget); }; const handleMenuClose = event => { if (event) event.stopPropagation(); setAnchorEl(null); }; const handleEdit = event => { event.stopPropagation(); onEdit(node); handleMenuClose(); }; const handleDelete = event => { event.stopPropagation(); onDelete(node); handleMenuClose(); }; const handleAddChild = event => { event.stopPropagation(); onAddChild(node); handleMenuClose(); }; return ( <> {hasChildren && (open ? : )} e.stopPropagation()}> {t('textSplit.editTag')} {t('textSplit.deleteTag')} {level === 0 && ( {t('textSplit.addTag')} )} {hasChildren && ( {node.child.map((childNode, index) => ( ))} )} ); } // 领域树组件 function DomainTree({ tags, onEdit, onDelete, onAddChild }) { return ( {tags.map((node, index) => ( ))} ); } export default function DomainAnalysis({ projectId, toc = '', loading = false }) { const theme = useTheme(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [currentNode, setCurrentNode] = useState(null); const [parentNode, setParentNode] = useState(''); const [dialogMode, setDialogMode] = useState('add'); const [labelValue, setLabelValue] = useState({}); const [saving, setSaving] = useState(false); const [tags, setTags] = useState([]); const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }); const handleCloseSnackbar = () => { setSnackbar(prev => ({ ...prev, open: false })); }; useEffect(() => { getTags(); }, []); const getTags = async () => { const response = await axios.get(`/api/projects/${projectId}/tags`); setTags(response.data.tags); }; // 处理标签切换 const handleTabChange = (event, newValue) => { setActiveTab(newValue); }; // 打开添加标签对话框 const handleAddTag = () => { setDialogMode('add'); setCurrentNode(null); setParentNode(null); setLabelValue({}); setDialogOpen(true); }; // 打开编辑标签对话框 const handleEditTag = node => { setDialogMode('edit'); setCurrentNode({ id: node.id, label: node.label }); setLabelValue({ id: node.id, label: node.label }); setDialogOpen(true); }; // 打开添加子标签对话框 const handleAddChildTag = parentNode => { setDialogMode('addChild'); setParentNode(parentNode.label); setLabelValue({ parentId: parentNode.id }); setDialogOpen(true); }; // 打开删除标签对话框 const handleDeleteTag = node => { setCurrentNode(node); setDeleteDialogOpen(true); }; // 关闭对话框 const handleCloseDialog = () => { setDialogOpen(false); setDeleteDialogOpen(false); }; // 查找并更新节点 const findAndUpdateNode = (nodes, targetNode, newLabel) => { return nodes.map(node => { if (node === targetNode) { return { ...node, label: newLabel }; } if (node.child && node.child.length > 0) { return { ...node, child: findAndUpdateNode(node.child, targetNode, newLabel) }; } return node; }); }; // 查找并删除节点 const findAndDeleteNode = (nodes, targetNode) => { return nodes .filter(node => node !== targetNode) .map(node => { if (node.child && node.child.length > 0) { return { ...node, child: findAndDeleteNode(node.child, targetNode) }; } return node; }); }; // 查找并添加子节点 const findAndAddChildNode = (nodes, parentNode, childLabel) => { return nodes.map(node => { if (node === parentNode) { const childArray = node.child || []; return { ...node, child: [...childArray, { label: childLabel, child: [] }] }; } if (node.child && node.child.length > 0) { return { ...node, child: findAndAddChildNode(node.child, parentNode, childLabel) }; } return node; }); }; // 保存标签更改 const saveTagChanges = async updatedTags => { console.log('保存标签更改:', updatedTags); setSaving(true); try { const response = await fetch(`/api/projects/${projectId}/tags`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: updatedTags }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('domain.errors.saveFailed')); } getTags(); setSnackbar({ open: true, message: t('domain.messages.updateSuccess'), severity: 'success' }); } catch (error) { console.error('保存标签失败:', error); setSnackbar({ open: true, message: error.message || '保存标签失败', severity: 'error' }); } finally { setSaving(false); } }; // 提交表单 const handleSubmit = async () => { if (!labelValue.label.trim()) { setSnackbar({ open: true, message: '标签名称不能为空', severity: 'error' }); return; } await saveTagChanges(labelValue); handleCloseDialog(); }; const handleConfirmDelete = async () => { if (!currentNode) return; const res = await axios.delete(`/api/projects/${projectId}/tags?id=${currentNode.id}`); if (res.status === 200) { toast.success('删除成功'); getTags(); } setDeleteDialogOpen(false); }; if (loading) { return ( ); } if (toc.length === 0) { return ( {t('domain.noToc')} ); } return ( {t('domain.tabs.tree')} {tags && tags.length > 0 ? ( ) : ( {t('domain.noTags')} )} {t('domain.docStructure')}
(
{children}
) }} > {toc}
{/* 添加/编辑标签对话框 */} {dialogMode === 'add' ? t('domain.dialog.addTitle') : dialogMode === 'edit' ? t('domain.dialog.editTitle') : t('domain.dialog.addChildTitle')} {dialogMode === 'add' ? t('domain.dialog.inputRoot') : dialogMode === 'edit' ? t('domain.dialog.inputEdit') : t('domain.dialog.inputChild', { label: parentNode })} setLabelValue({ ...labelValue, label: e.target.value })} /> {/* 删除确认对话框 */} {t('common.confirmDelete')} {t('domain.dialog.deleteConfirm', { label: currentNode?.label })} {currentNode?.child && currentNode.child.length > 0 && t('domain.dialog.deleteWarning')}
); } ================================================ FILE: components/text-split/FileUploader.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Paper, Grid } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; import UploadArea from './components/UploadArea'; import FileList from './components/FileList'; import DeleteConfirmDialog from './components/DeleteConfirmDialog'; import PdfProcessingDialog from './components/PdfProcessingDialog'; import DomainTreeActionDialog from './components/DomainTreeActionDialog'; import FileLoadingProgress from './components/FileLoadingProgress'; import { fileApi, taskApi } from '@/lib/api'; import { getContent, checkMaxSize, checkInvalidFiles, getvalidFiles } from '@/lib/file/file-process'; import { toast } from 'sonner'; export default function FileUploader({ projectId, onUploadSuccess, onFileDeleted, sendToPages, setPdfStrategy, pdfStrategy, selectedViosnModel, setSelectedViosnModel, setPageLoading, taskFileProcessing, fileTask }) { const theme = useTheme(); const { t } = useTranslation(); const [files, setFiles] = useState([]); const [pdfFiles, setPdfFiles] = useState([]); const [uploadedFiles, setUploadedFiles] = useState({}); const selectedModelInfo = useAtomValue(selectedModelInfoAtom); const [uploading, setUploading] = useState(false); const [loading, setLoading] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [pdfProcessConfirmOpen, setpdfProcessConfirmOpen] = useState(false); const [fileToDelete, setFileToDelete] = useState({}); const [domainTreeActionOpen, setDomainTreeActionOpen] = useState(false); const [domainTreeAction, setDomainTreeAction] = useState(''); const [isFirstUpload, setIsFirstUpload] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [taskSettings, setTaskSettings] = useState(null); const [visionModels, setVisionModels] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [pageSize] = useState(10); const [searchFileName, setSearchFileName] = useState(''); useEffect(() => { fetchUploadedFiles(); }, [currentPage, searchFileName]); /** * 处理 PDF 处理方式选择 */ const handleRadioChange = event => { const modelId = event.target.selectedVision; setPdfStrategy(event.target.value); if (event.target.value === 'mineru') { toast.success(t('textSplit.mineruSelected')); } else if (event.target.value === 'mineru-local') { toast.success(t('textSplit.mineruLocalSelected')); } else if (event.target.value === 'vision') { const model = visionModels.find(item => item.id === modelId); toast.success( t('textSplit.customVisionModelSelected', { name: model.modelName, provider: model.projectName }) ); } else { toast.success(t('textSplit.defaultSelected')); } }; /** * 获取上传的文件列表 * @param {*} page * @param {*} size * @param {*} fileName */ const fetchUploadedFiles = async (page = currentPage, size = pageSize, fileName = searchFileName) => { try { setLoading(true); const data = await fileApi.getFiles({ projectId, page, size, fileName, t }); setUploadedFiles(data); setIsFirstUpload(data.total === 0); const taskData = await taskApi.getProjectTasks(projectId); setTaskSettings(taskData); //使用Jotai会出现数据获取的延迟,导致这里模型获取不到,改用localStorage获取模型信息 const model = JSON.parse(localStorage.getItem('modelConfigList')); //过滤出视觉模型 const visionItems = model.filter(item => item.type === 'vision'); //先默认选择第一个配置的视觉模型 if (visionItems.length > 0) { setSelectedViosnModel(visionItems[0].id); } setVisionModels(visionItems); } catch (error) { toast.error(error.message); } finally { setLoading(false); } }; /** * 处理文件选择 */ const handleFileSelect = event => { const selectedFiles = Array.from(event.target.files); checkMaxSize(selectedFiles); checkInvalidFiles(selectedFiles); const validFiles = getvalidFiles(selectedFiles); if (validFiles.length > 0) { setFiles(prev => [...prev, ...validFiles]); } const hasPdfFiles = selectedFiles.filter(file => file.name.endsWith('.pdf')); if (hasPdfFiles.length > 0) { setpdfProcessConfirmOpen(true); setPdfFiles(hasPdfFiles); } }; /** * 从待上传文件列表中移除文件 */ const removeFile = index => { const fileToRemove = files[index]; setFiles(prev => prev.filter((_, i) => i !== index)); if (fileToRemove && fileToRemove.name.toLowerCase().endsWith('.pdf')) { setPdfFiles(prevPdfFiles => prevPdfFiles.filter(pdfFile => pdfFile.name !== fileToRemove.name)); } }; /** * 上传文件 */ const uploadFiles = async () => { if (files.length === 0) return; // 如果是第一次上传,直接走默认逻辑 if (isFirstUpload) { handleStartUpload('rebuild'); return; } // 否则打开领域树操作选择对话框 setDomainTreeAction('upload'); setPendingAction({ type: 'upload' }); setDomainTreeActionOpen(true); }; /** * 处理领域树操作选择 */ const handleDomainTreeAction = action => { setDomainTreeActionOpen(false); // 执行挂起的操作 if (pendingAction && pendingAction.type === 'upload') { handleStartUpload(action); } else if (pendingAction && pendingAction.type === 'delete') { handleDeleteFile(action); } // 清除挂起的操作 setPendingAction(null); }; /** * 开始上传文件 */ const handleStartUpload = async domainTreeActionType => { setUploading(true); try { const uploadedFileInfos = []; for (const file of files) { const { fileContent, fileName } = await getContent(file); const data = await fileApi.uploadFile({ file, projectId, fileContent, fileName, t }); uploadedFileInfos.push({ fileName: data.fileName, fileId: data.fileId }); } toast.success(t('textSplit.uploadSuccess', { count: files.length })); setFiles([]); setCurrentPage(1); await fetchUploadedFiles(); if (onUploadSuccess) { await onUploadSuccess(uploadedFileInfos, pdfFiles, domainTreeActionType); } } catch (err) { toast.error(err.message || t('textSplit.uploadFailed')); } finally { setUploading(false); } }; // 打开删除确认对话框 const openDeleteConfirm = (fileId, fileName) => { setFileToDelete({ fileId, fileName }); setDeleteConfirmOpen(true); }; // 关闭删除确认对话框 const closeDeleteConfirm = () => { setDeleteConfirmOpen(false); setFileToDelete(null); }; // 删除文件前确认领域树操作 const confirmDeleteFile = () => { setDeleteConfirmOpen(false); // 如果没有其他文件了(删除后会变为空),直接删除 if (uploadedFiles.total <= 1) { handleDeleteFile('keep'); return; } // 否则打开领域树操作选择对话框 setDomainTreeAction('delete'); setPendingAction({ type: 'delete' }); setDomainTreeActionOpen(true); }; // 处理删除文件 const handleDeleteFile = async domainTreeActionType => { if (!fileToDelete) return; try { setLoading(true); closeDeleteConfirm(); await fileApi.deleteFile({ fileToDelete, projectId, domainTreeActionType, modelInfo: selectedModelInfo || {}, t }); await fetchUploadedFiles(); if (onFileDeleted) { const filesLength = uploadedFiles.total; onFileDeleted(fileToDelete, filesLength); } if (uploadedFiles.data && uploadedFiles.data.length <= 1 && currentPage > 1) { setCurrentPage(1); } toast.success(t('textSplit.deleteSuccess', { fileName: fileToDelete.fileName })); } catch (error) { console.error('Error deleting file:', error); toast.error(error.message); } finally { setLoading(false); setFileToDelete(null); } }; return ( {taskFileProcessing ? ( ) : ( <> {/* 左侧:上传文件区域 */} {/* 右侧:已上传文件列表 */} sendToPages(array)} onDeleteFile={openDeleteConfirm} projectId={projectId} currentPage={currentPage} onPageChange={(page, fileName) => { if (fileName !== undefined) { // 搜索时更新搜索关键词和页码 setSearchFileName(fileName); setCurrentPage(page); } else { // 翻页时只更新页码 setCurrentPage(page); } }} onRefresh={fetchUploadedFiles} // 传递刷新函数 /> {/* 领域树操作选择对话框 */} setDomainTreeActionOpen(false)} onConfirm={handleDomainTreeAction} isFirstUpload={isFirstUpload} action={domainTreeAction} /> {/* 检测到pdf的处理框 */} setpdfProcessConfirmOpen(false)} onRadioChange={handleRadioChange} value={pdfStrategy} projectId={projectId} taskSettings={taskSettings} visionModels={visionModels} selectedViosnModel={selectedViosnModel} setSelectedViosnModel={setSelectedViosnModel} /> )} ); } ================================================ FILE: components/text-split/LoadingBackdrop.js ================================================ 'use client'; import { Backdrop, Paper, CircularProgress, Typography, Box, LinearProgress } from '@mui/material'; export default function LoadingBackdrop({ open, title, description, progress = null }) { return ( theme.zIndex.drawer + 1, position: 'fixed', backdropFilter: 'blur(5px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} open={open} > theme.palette.primary.main }} /> {title} {description} {progress && progress.total > 0 && ( {progress.completed}/{progress.total} ({progress.percentage}%) {progress.questionCount > 0 && ( 已生成问题数: {progress.questionCount} )} )} ); } ================================================ FILE: components/text-split/MarkdownViewDialog.js ================================================ 'use client'; import React, { useState, useEffect, useRef } from 'react'; import { Box, Button, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress, Typography, Divider, Chip, Switch, FormControlLabel, Alert, DialogContentText } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import SaveIcon from '@mui/icons-material/Save'; import ReactMarkdown from 'react-markdown'; import { useTranslation } from 'react-i18next'; import 'github-markdown-css/github-markdown-light.css'; export default function MarkdownViewDialog({ open, text, onClose, projectId, onSaveSuccess }) { const { t } = useTranslation(); const [customSplitMode, setCustomSplitMode] = useState(false); const [splitPoints, setSplitPoints] = useState([]); const [selectedText, setSelectedText] = useState(''); const [savedMessage, setSavedMessage] = useState(''); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const contentRef = useRef(null); const [chunksPreview, setChunksPreview] = useState([]); // 根据分块点计算每个块的字数 const calculateChunksPreview = points => { if (!text || !text.content) return []; const content = text.content; const sortedPoints = [...points].sort((a, b) => a.position - b.position); const chunks = []; let startPos = 0; // 计算每个分块 for (let i = 0; i < sortedPoints.length; i++) { const endPos = sortedPoints[i].position; const chunkContent = content.substring(startPos, endPos); if (chunkContent.trim().length > 0) { chunks.push({ index: i + 1, length: chunkContent.length, preview: chunkContent.substring(0, 20) + (chunkContent.length > 20 ? '...' : '') }); } startPos = endPos; } // 添加最后一个分块 const lastChunkContent = content.substring(startPos); if (lastChunkContent.trim().length > 0) { chunks.push({ index: chunks.length + 1, length: lastChunkContent.length, preview: lastChunkContent.substring(0, 20) + (lastChunkContent.length > 20 ? '...' : '') }); } return chunks; }; // 重置组件状态 useEffect(() => { if (!open) { setSplitPoints([]); setCustomSplitMode(false); setSelectedText(''); setSavedMessage(''); } }, [open]); // 当分块点变化时更新预览 useEffect(() => { if (splitPoints.length > 0 && text?.content) { const preview = calculateChunksPreview(splitPoints); setChunksPreview(preview); } else { setChunksPreview([]); } }, [splitPoints, text?.content]); // 处理用户选择文本事件 const handleTextSelection = () => { if (!customSplitMode) return; const selection = window.getSelection(); if (!selection.toString().trim()) return; // 获取选择的文本内容和位置 const selectedContent = selection.toString(); // 计算选择位置在文档中的偏移量 const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(contentRef.current); preCaretRange.setEnd(range.endContainer, range.endOffset); const position = preCaretRange.toString().length; // 添加到分割点列表 const newPoint = { id: Date.now(), position, preview: selectedContent.substring(0, 40) + (selectedContent.length > 40 ? '...' : '') }; setSplitPoints(prev => [...prev, newPoint].sort((a, b) => a.position - b.position)); setSelectedText(''); }; // 删除分割点 const handleDeletePoint = id => { setSplitPoints(prev => prev.filter(point => point.id !== id)); }; // 弹出确认对话框 const handleConfirmSave = () => { setConfirmDialogOpen(true); }; // 取消保存 const handleCancelSave = () => { setConfirmDialogOpen(false); }; // 确认并执行保存 const handleSavePoints = async () => { // 输出调试信息 console.log('保存分块点时的数据:', { projectId, text: text ? { fileId: text.fileId, fileName: text.fileName, contentLength: text.content ? text.content.length : 0 } : null, splitPointsCount: splitPoints.length }); if (!text) { setError(t('textSplit.missingRequiredData') + ': text 为空'); return; } if (!text.fileId) { setError(t('textSplit.missingRequiredData') + ': fileId 不存在'); return; } if (!text.fileName) { setError(t('textSplit.missingRequiredData') + ': fileName 不存在'); return; } if (!text.content) { setError(t('textSplit.missingRequiredData') + ': content 不存在'); return; } if (!projectId) { setError(t('textSplit.missingRequiredData') + ': projectId 不存在'); return; } setConfirmDialogOpen(false); setSaving(true); setError(''); try { // 准备要发送的数据 const customSplitData = { fileId: text.fileId, fileName: text.fileName, content: text.content, splitPoints: splitPoints.map(point => ({ position: point.position, preview: point.preview })) }; // 发送请求到待创建的API接口 const response = await fetch(`/api/projects/${projectId}/custom-split`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(customSplitData) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || t('textSplit.customSplitFailed')); } // 保存成功 setSavedMessage(t('textSplit.customSplitSuccess')); // 短暂显示成功消息后关闭对话框并刷新列表 setTimeout(() => { setSavedMessage(''); // 关闭对话框 onClose(); // 调用父组件的刷新方法(如果提供了) if (typeof onSaveSuccess === 'function') { onSaveSuccess(); } }, 1500); } catch (err) { console.error('保存自定义分块出错:', err); setError(err.message || t('textSplit.customSplitFailed')); } finally { setSaving(false); } }; return ( {text ? text.fileName : ''} setCustomSplitMode(e.target.checked)} color="primary" /> } label={t('textSplit.customSplitMode')} sx={{ ml: 2 }} /> {customSplitMode && ( {t('textSplit.customSplitInstructions')} {/* 分割点列表 */} {splitPoints.length > 0 && ( {t('textSplit.splitPointsList')} ({splitPoints.length}): {splitPoints.map((point, index) => ( handleDeletePoint(point.id)} deleteIcon={} color="primary" variant="outlined" /> ))} {/* 文本块字数预览 */} {chunksPreview.length > 0 && ( {t('textSplit.chunksPreview')} {chunksPreview.map(chunk => ( ))} )} )} {/* 保存按钮 */} {/* 提示消息 */} {savedMessage && ( {savedMessage} )} {error && ( {error} )} )} {text ? ( {/* 渲染带有分割点标记的内容 */} {customSplitMode && splitPoints.length > 0 ? (
                  {text.content.split('').map((char, index) => {
                    const isSplitPoint = splitPoints.some(point => point.position === index);
                    const splitPointIndex = splitPoints.findIndex(point => point.position === index);

                    if (isSplitPoint) {
                      return (
                        
                          
                            
                              {splitPointIndex + 1}
                            
                          
                          {char}
                        
                      );
                    }
                    return char;
                  })}
                
) : (
{text.content}
)}
) : ( )}
{/* 确认对话框 */} {t('textSplit.confirmCustomSplitTitle')} {t('textSplit.confirmCustomSplitMessage')}
); } ================================================ FILE: components/text-split/PdfSettings.js ================================================ 'use client'; import { Box, Select, MenuItem, Typography, FormControl, InputLabel } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function PdfSettings({ pdfStrategy, setPdfStrategy, selectedViosnModel, setSelectedViosnModel }) { const { t } = useTranslation(); return ( {t('textSplit.pdfStrategy')} {pdfStrategy === 'vision' && ( {t('textSplit.visionModel')} )} ); } ================================================ FILE: components/text-split/components/DeleteConfirmDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, Typography, Box, Alert } from '@mui/material'; import { useTranslation } from 'react-i18next'; export default function DeleteConfirmDialog({ open, fileName, onClose, onConfirm }) { const { t } = useTranslation(); return ( {t('common.confirmDelete')}「{fileName}」? {t('common.confirmDeleteDescription')} {t('textSplit.deleteFileWarning')} • {t('textSplit.deleteFileWarningChunks')} • {t('textSplit.deleteFileWarningQuestions')} • {t('textSplit.deleteFileWarningDatasets')} ); } ================================================ FILE: components/text-split/components/DirectoryView.js ================================================ 'use client'; import { Box, List, ListItem, ListItemIcon, ListItemText, Collapse, IconButton } from '@mui/material'; import FolderIcon from '@mui/icons-material/Folder'; import ArticleIcon from '@mui/icons-material/Article'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import { useTheme } from '@mui/material/styles'; /** * 目录结构组件 * @param {Object} props * @param {Array} props.items - 目录项数组 * @param {Object} props.expandedItems - 展开状态对象 * @param {Function} props.onToggleItem - 展开/折叠回调 * @param {number} props.level - 当前层级 * @param {string} props.parentId - 父级ID */ export default function DirectoryView({ items, expandedItems, onToggleItem, level = 0, parentId = '' }) { const theme = useTheme(); if (!items || items.length === 0) return null; return ( 0 ? 2 : 0 }}> {items.map((item, index) => { const itemId = `${parentId}-${index}`; const hasChildren = item.children && item.children.length > 0; const isExpanded = expandedItems[itemId] || false; return ( 0 ? `1px solid ${theme.palette.divider}` : 'none', ml: level > 0 ? 1 : 0 }} > {hasChildren ? : } {hasChildren && ( onToggleItem(itemId)}> {isExpanded ? : } )} {hasChildren && ( )} ); })} ); } ================================================ FILE: components/text-split/components/DomainTreeActionDialog.js ================================================ 'use client'; import { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Radio, RadioGroup, FormControlLabel, FormControl, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; /** * 领域树操作选择对话框 * 提供三种选项:修订领域树、重建领域树、不更改领域树 */ export default function DomainTreeActionDialog({ open, onClose, onConfirm, isFirstUpload, action }) { const { t } = useTranslation(); const [value, setValue] = useState(isFirstUpload ? 'rebuild' : 'revise'); // 处理选项变更 const handleChange = event => { setValue(event.target.value); }; // 确认选择 const handleConfirm = () => { onConfirm(value); }; // 获取对话框标题 const getDialogTitle = () => { if (isFirstUpload) { return t('textSplit.domainTree.firstUploadTitle'); } return action === 'upload' ? t('textSplit.domainTree.uploadTitle') : t('textSplit.domainTree.deleteTitle'); }; return ( {getDialogTitle()} {!isFirstUpload && ( } label={ <> {t('textSplit.domainTree.reviseOption')} {t('textSplit.domainTree.reviseDesc')} } /> )} } label={ <> {t('textSplit.domainTree.rebuildOption')} {t('textSplit.domainTree.rebuildDesc')} } /> {!isFirstUpload && ( } label={ <> {t('textSplit.domainTree.keepOption')} {t('textSplit.domainTree.keepDesc')} } /> )} ); } ================================================ FILE: components/text-split/components/DomainTreeView.js ================================================ 'use client'; import { Box } from '@mui/material'; import { TreeView, TreeItem } from '@mui/lab'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; /** * 领域知识树组件 * @param {Object} props * @param {Array} props.nodes - 树节点数组 */ export default function DomainTreeView({ nodes = [] }) { if (!nodes || nodes.length === 0) return null; const renderTreeItems = nodes => { return nodes.map((node, index) => ( {node.children && node.children.length > 0 && renderTreeItems(node.children)} )); }; return ( } defaultExpandIcon={} sx={{ flexGrow: 1, overflowY: 'auto' }} > {renderTreeItems(nodes)} ); } ================================================ FILE: components/text-split/components/FileList.js ================================================ 'use client'; import { Box, Typography, List, ListItem, ListItemText, IconButton, Tooltip, Divider, CircularProgress, Checkbox, Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, FormControlLabel, Switch, Pagination, TextField, InputAdornment, Grid, Alert } from '@mui/material'; import { Visibility as VisibilityIcon, Download, Delete as DeleteIcon, FilePresent as FileIcon, Psychology as PsychologyIcon, CheckBox as SelectAllIcon, CheckBoxOutlineBlank as DeselectAllIcon, Search as SearchIcon, Clear as ClearIcon } from '@mui/icons-material'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { selectedModelInfoAtom } from '@/lib/store'; import MarkdownViewDialog from '../MarkdownViewDialog'; import GaPairsIndicator from '../../mga/GaPairsIndicator'; import DomainTreeActionDialog from './DomainTreeActionDialog'; import i18n from '@/lib/i18n'; import { toast } from 'sonner'; export default function FileList({ theme, files = {}, loading = false, onDeleteFile, sendToFileUploader, projectId, setPageLoading, currentPage = 1, onPageChange, onRefresh, // 新增:刷新文件列表的回调函数 isFullscreen = false // 新增参数,用于控制是否处于全屏状态 }) { const { t } = useTranslation(); // 现有的状态 const [array, setArray] = useState([]); const [viewDialogOpen, setViewDialogOpen] = useState(false); const [viewContent, setViewContent] = useState(''); // 新增的批量生成GA对相关状态 const [batchGenDialogOpen, setBatchGenDialogOpen] = useState(false); const [generating, setGenerating] = useState(false); const [genError, setGenError] = useState(null); const [genResult, setGenResult] = useState(null); const [projectModel, setProjectModel] = useState(null); const [loadingModel, setLoadingModel] = useState(false); const [appendMode, setAppendMode] = useState(false); const [generationMode, setGenerationMode] = useState('ai'); // 'ai' 或 'manual' const [manualGaPair, setManualGaPair] = useState({ genreTitle: '', genreDesc: '', audienceTitle: '', audienceDesc: '' }); // 批量删除相关状态 const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false); const [domainTreeActionOpen, setDomainTreeActionOpen] = useState(false); const [deleting, setDeleting] = useState(false); // 搜索相关状态 const [searchTerm, setSearchTerm] = useState(''); const [searchLoading, setSearchLoading] = useState(false); // 获取当前选中的模型信息 const selectedModelInfo = useAtomValue(selectedModelInfoAtom); // 后端搜索功能 const handleSearch = async searchValue => { if (typeof onPageChange === 'function') { setSearchLoading(true); try { // 调用父组件的页面变更函数,传递搜索参数 await onPageChange(1, searchValue); // 搜索时重置到第一页 } catch (error) { console.error('搜索失败:', error); } finally { setSearchLoading(false); } } }; // 防抖搜索 useEffect(() => { const timer = setTimeout(() => { handleSearch(searchTerm); }, 500); // 500ms 防抖 return () => clearTimeout(timer); }, [searchTerm]); // 清空搜索 const handleClearSearch = () => { setSearchTerm(''); // 清空搜索时立即触发搜索 handleSearch(''); }; const handleCheckboxChange = (fileId, isChecked) => { setArray(prevArray => { let newArray; const stringFileId = String(fileId); if (isChecked) { newArray = prevArray.includes(stringFileId) ? prevArray : [...prevArray, stringFileId]; } else { newArray = prevArray.filter(item => item !== stringFileId); } if (typeof sendToFileUploader === 'function') { sendToFileUploader(newArray); } return newArray; }); }; // 全选文件(包括所有页面的文件) const handleSelectAll = async () => { try { // 获取项目中所有文件的ID const response = await fetch(`/api/projects/${projectId}/files?getAllIds=true`); if (!response.ok) { throw new Error('获取文件列表失败'); } const data = await response.json(); const allFileIds = data.allFileIds || []; setArray(allFileIds); if (typeof sendToFileUploader === 'function') { sendToFileUploader(allFileIds); } } catch (error) { console.error('全选文件失败:', error); // 如果API调用失败,回退到选择当前页面的文件 if (files?.data?.length > 0) { const currentPageFileIds = files.data.map(file => String(file.id)); setArray(currentPageFileIds); if (typeof sendToFileUploader === 'function') { sendToFileUploader(currentPageFileIds); } } } }; // 取消全选 const handleDeselectAll = () => { setArray([]); if (typeof sendToFileUploader === 'function') { sendToFileUploader([]); } }; const handleCloseViewDialog = () => { setViewDialogOpen(false); }; // 刷新文本块列表 const refreshTextChunks = () => { if (typeof setPageLoading === 'function') { setPageLoading(true); setTimeout(() => { // 可能需要调用父组件的刷新方法 sendToFileUploader(array); setPageLoading(false); }, 500); } }; const handleViewContent = async fileId => { getFileContent(fileId); setViewDialogOpen(true); }; const handleDownload = async (fileId, fileName) => { setPageLoading(true); const text = await getFileContent(fileId); // Modify the filename if it ends with .pdf let downloadName = fileName || 'download.txt'; if (downloadName.toLowerCase().endsWith('.pdf')) { downloadName = downloadName.slice(0, -4) + '.md'; } const blob = new Blob([text.content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = downloadName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setPageLoading(false); }; const getFileContent = async fileId => { try { const response = await fetch(`/api/projects/${projectId}/preview/${fileId}`); if (!response.ok) { throw new Error(t('textSplit.fetchChunksFailed')); } const data = await response.json(); setViewContent(data); return data; } catch (error) { console.error(t('textSplit.fetchChunksError'), error); } }; const formatFileSize = size => { if (size < 1024) { return size + 'B'; } else if (size < 1024 * 1024) { return (size / 1024).toFixed(2) + 'KB'; } else if (size < 1024 * 1024 * 1024) { return (size / 1024 / 1024).toFixed(2) + 'MB'; } else { return (size / 1024 / 1024 / 1024).toFixed(2) + 'GB'; } }; // 新增:获取项目特定的默认模型信息 const fetchProjectModel = async () => { try { setLoadingModel(true); // 首先获取项目信息 const response = await fetch(`/api/projects/${projectId}`); if (!response.ok) { throw new Error(t('gaPairs.fetchProjectInfoFailed', { status: response.status })); } const projectData = await response.json(); // 获取模型配置 const modelResponse = await fetch(`/api/projects/${projectId}/model-config`); if (!modelResponse.ok) { throw new Error(t('gaPairs.fetchModelConfigFailed', { status: modelResponse.status })); } const modelConfigData = await modelResponse.json(); if (modelConfigData.data && Array.isArray(modelConfigData.data)) { // 优先使用项目默认模型 let targetModel = null; if (projectData.defaultModelConfigId) { targetModel = modelConfigData.data.find(model => model.id === projectData.defaultModelConfigId); } // 如果没有默认模型,使用第一个可用的模型 if (!targetModel) { targetModel = modelConfigData.data.find( m => m.modelName && m.endpoint && (m.providerId === 'ollama' || m.apiKey) ); } if (targetModel) { setProjectModel(targetModel); } } } catch (error) { console.error(t('gaPairs.fetchProjectModelError'), error); } finally { setLoadingModel(false); } }; // 新增:批量生成GA对的处理函数 const handleBatchGenerateGAPairs = async () => { if (array.length === 0) { setGenError(t('gaPairs.selectAtLeastOneFile')); return; } // 如果是手动添加模式,验证手动输入的 GA 对 if (generationMode === 'manual') { if (!manualGaPair.genreTitle || !manualGaPair.audienceTitle) { setGenError(t('gaPairs.manualGaPairRequired')); return; } try { setGenerating(true); setGenError(null); setGenResult(null); const stringFileIds = array.map(id => String(id)); const requestData = { fileIds: stringFileIds, gaPair: manualGaPair, appendMode: appendMode }; const response = await fetch(`/api/projects/${projectId}/batch-add-manual-ga`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestData) }); const responseText = await response.text(); if (!response.ok) { const errorData = await response .json() .catch(() => ({ error: t('gaPairs.requestFailed', { status: response.status }) })); throw new Error(errorData.error || t('gaPairs.requestFailed', { status: response.status })); } const result = JSON.parse(responseText); if (result.success) { setGenResult({ total: result.data?.length || 0, success: result.data?.filter(r => r.success).length || 0 }); // 成功后清空选择状态和表单 setArray([]); if (typeof sendToFileUploader === 'function') { sendToFileUploader([]); } setManualGaPair({ genreTitle: '', genreDesc: '', audienceTitle: '', audienceDesc: '' }); // 发送全局刷新事件 const successfulFileIds = result.data?.filter(item => item.success)?.map(item => String(item.fileId)) || []; if (successfulFileIds.length > 0) { window.dispatchEvent( new CustomEvent('refreshGaPairsIndicators', { detail: { projectId, fileIds: successfulFileIds } }) ); } } else { setGenError(result.error || t('gaPairs.generationFailed')); } } catch (error) { console.error(t('gaPairs.batchGenerationFailed'), error); setGenError(t('gaPairs.generationError', { error: error.message || t('common.unknownError') })); } finally { setGenerating(false); } return; } // AI 生成模式 const modelToUse = projectModel || selectedModelInfo; if (!modelToUse || !modelToUse.id) { setGenError(t('gaPairs.noDefaultModel')); return; } // 检查模型配置是否完整 if (!modelToUse.modelName || !modelToUse.endpoint) { setGenError('模型配置不完整,请检查模型设置'); return; } // 检查API密钥(除了ollama模型) if (modelToUse.providerId !== 'ollama' && !modelToUse.apiKey) { setGenError(t('gaPairs.missingApiKey')); return; } try { setGenerating(true); setGenError(null); setGenResult(null); const stringFileIds = array.map(id => String(id)); // 获取当前语言环境 const currentLanguage = i18n.language === 'en' ? 'en' : '中文'; const requestData = { fileIds: stringFileIds, modelConfigId: modelToUse.id, language: currentLanguage, appendMode: appendMode }; const response = await fetch(`/api/projects/${projectId}/batch-generateGA`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestData) }); const responseText = await response.text(); if (!response.ok) { const errorData = await response .json() .catch(() => ({ error: t('gaPairs.requestFailed', { status: response.status }) })); throw new Error(errorData.error || t('gaPairs.requestFailed', { status: response.status })); } const result = JSON.parse(responseText); if (result.success) { setGenResult({ total: result.data?.length || 0, success: result.data?.filter(r => r.success).length || 0 }); // 成功后清空选择状态 setArray([]); if (typeof sendToFileUploader === 'function') { sendToFileUploader([]); } console.log(t('gaPairs.batchGenerationSuccess', { count: result.summary?.success || 0 })); //发送全局刷新事件 const successfulFileIds = result.data?.filter(item => item.success)?.map(item => String(item.fileId)) || []; if (successfulFileIds.length > 0) { window.dispatchEvent( new CustomEvent('refreshGaPairsIndicators', { detail: { projectId, fileIds: successfulFileIds } }) ); } } else { setGenError(result.error || t('gaPairs.generationFailed')); } } catch (error) { console.error(t('gaPairs.batchGenerationFailed'), error); setGenError(t('gaPairs.generationError', { error: error.message || t('common.unknownError') })); } finally { setGenerating(false); } }; // 新增:打开批量生成对话框 const openBatchGenDialog = () => { // 如果没有选中文件,自动选中所有文件 if (array.length === 0 && files?.data?.length > 0) { const allFileIds = files.data.map(file => String(file.id)); setArray(allFileIds); if (typeof sendToFileUploader === 'function') { sendToFileUploader(allFileIds); } } // 获取项目模型配置 fetchProjectModel(); setBatchGenDialogOpen(true); }; // 新增:关闭批量生成对话框 const closeBatchGenDialog = () => { setBatchGenDialogOpen(false); setGenError(null); setGenResult(null); setAppendMode(false); // 重置追加模式 }; // 批量删除处理函数 - 第一步:打开确认对话框 const handleBatchDelete = () => { if (array.length === 0) { return; } setBatchDeleteDialogOpen(true); }; // 确认批量删除 - 第二步:打开领域树选择对话框 const confirmBatchDelete = () => { setBatchDeleteDialogOpen(false); // 检查是否还有其他文件 const remainingFilesCount = files.total - array.length; // 如果删除后没有文件了,直接执行删除(keep 模式) if (remainingFilesCount === 0) { executeBatchDelete('keep'); return; } // 否则打开领域树操作选择对话框 setDomainTreeActionOpen(true); }; // 处理领域树操作选择 const handleDomainTreeAction = action => { setDomainTreeActionOpen(false); executeBatchDelete(action); }; // 执行批量删除 - 第三步:实际删除操作 const executeBatchDelete = async domainTreeAction => { if (array.length === 0) { return; } setDeleting(true); // 设置页面 loading 状态 if (typeof setPageLoading === 'function') { setPageLoading(true); } try { const response = await fetch(`/api/projects/${projectId}/batch-delete-files`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileIds: array, domainTreeAction, model: selectedModelInfo || {}, language: i18n.language === 'en' ? 'English' : '中文' }) }); if (!response.ok) { throw new Error('批量删除失败'); } const result = await response.json(); // 清空选择 setArray([]); if (typeof sendToFileUploader === 'function') { sendToFileUploader([]); } // 刷新文件列表 if (typeof onRefresh === 'function') { await onRefresh(); } else if (typeof onPageChange === 'function') { // 回退方案:如果没有 onRefresh,使用 onPageChange await onPageChange(1); } toast.success( t('textSplit.batchDeleteSuccess', { count: result.deletedCount || array.length, defaultValue: `成功删除 ${result.deletedCount || array.length} 个文件` }) ); } catch (error) { console.error('批量删除文件失败:', error); toast.error(t('textSplit.batchDeleteFailed', { defaultValue: '批量删除失败' })); } finally { setDeleting(false); // 清除页面 loading 状态 if (typeof setPageLoading === 'function') { setPageLoading(false); } } }; // 取消批量删除 const cancelBatchDelete = () => { setBatchDeleteDialogOpen(false); }; return ( {/* 标题和按钮区域 */} {/* 第一行:标题和按钮 */} 0 ? 2 : 0 }} > {t('textSplit.uploadedDocuments', { count: files.total })} {/* 批量操作按钮 */} {files.total > 0 && ( {/* 全选/取消全选按钮 */} {array.length === files.total ? ( ) : ( )} {/* 批量删除按钮 */} {array.length > 0 && ( )} {/* 批量生成GA对按钮 */} )} {/* 第二行:搜索框 - 在全屏展示时显示,或者有搜索内容时显示 */} {isFullscreen && (files.total > 0 || searchTerm) && ( setSearchTerm(e.target.value)} InputProps={{ startAdornment: ( ), endAdornment: searchTerm && ( ) }} sx={{ width: '100%', maxWidth: 400 }} /> {(searchTerm || searchLoading) && ( {searchLoading ? '搜索中...' : searchTerm ? t('textSplit.searchResults', { count: files?.data?.length || 0, total: files.total, defaultValue: `找到 ${files?.data?.length || 0} 个文件(共 ${files.total} 个)` }) : null} )} )} {loading ? ( ) : files.total === 0 ? ( {searchTerm ? // 搜索无结果 t('textSplit.noSearchResults', { searchTerm, defaultValue: `未找到包含 "${searchTerm}" 的文件` }) : // 真的没有上传文件 t('textSplit.noFilesUploaded', { defaultValue: '暂未上传文件' })} ) : !files?.data || files.data.length === 0 ? ( {searchTerm ? // 搜索有结果但当前页没数据 t('textSplit.noResultsOnCurrentPage', { defaultValue: '当前页面没有搜索结果,请返回第一页查看' }) : // 当前页没数据但总数不为0 t('textSplit.noDataOnCurrentPage', { defaultValue: '当前页面没有数据' })} ) : ( <> {files?.data?.map((file, index) => ( {/* 文件信息区域 */} handleViewContent(file.id)} primary={ {file.fileName} } secondary={ {`${formatFileSize(file.size)} · ${new Date(file.createAt).toLocaleString()}`} } /> {/* 操作按钮区域 */} handleCheckboxChange(file.id, e.target.checked)} /> handleDownload(file.id, file.fileName)}> onDeleteFile(file.id, file.fileName)}> {index < files.data.length - 1 && } ))} {/* 分页控件 */} {files.total > 10 && ( onPageChange && onPageChange(page)} color="primary" showFirstButton showLastButton /> )} )} {/* 现有的文本块详情对话框 */} {/* 新增:批量生成GA对对话框 */} {t('gaPairs.batchGenerateTitle')} {!genResult && ( {t('gaPairs.batchGenerateDescription', { count: array.length })} {/* 生成方式选择 */} {t('gaPairs.generationMode')} setGenerationMode(e.target.checked ? 'manual' : 'ai')} color="primary" /> } label={generationMode === 'manual' ? t('gaPairs.manualAddMode') : t('gaPairs.aiGenerateMode')} /> {/* AI 生成模式:显示模型信息 */} {generationMode === 'ai' && ( <> {loadingModel ? ( {t('gaPairs.loadingProjectModel')} ) : projectModel ? ( {t('gaPairs.usingModel')}:{' '} {projectModel.providerName}: {projectModel.modelName} ) : ( {t('gaPairs.noDefaultModel')} )} )} {/* 手动添加模式:显示输入表单 */} {generationMode === 'manual' && ( setManualGaPair({ ...manualGaPair, genreTitle: e.target.value })} required /> setManualGaPair({ ...manualGaPair, genreDesc: e.target.value })} multiline rows={2} /> setManualGaPair({ ...manualGaPair, audienceTitle: e.target.value })} required /> setManualGaPair({ ...manualGaPair, audienceDesc: e.target.value })} multiline rows={2} /> )} {/* 追加模式选择 */} setAppendMode(e.target.checked)} color="primary" /> } label={`${t('gaPairs.appendMode')}(${t('gaPairs.appendModeDescription')})`} /> )} {genError && ( {genError} )} {genResult && ( {t('gaPairs.batchGenCompleted', { success: genResult.success, total: genResult.total })} )} {!genResult && ( )} {/* 批量删除确认对话框 */} {t('textSplit.batchDeleteTitle')} {t('textSplit.batchDeleteConfirm', { count: array.length, defaultValue: `确定要删除选中的 ${array.length} 个文件吗?此操作不可恢复。` })} {t('textSplit.deleteFileWarning')} • {t('textSplit.deleteFileWarningChunks')} • {t('textSplit.deleteFileWarningQuestions')} • {t('textSplit.deleteFileWarningDatasets')} {/* 领域树操作选择对话框 */} setDomainTreeActionOpen(false)} onConfirm={handleDomainTreeAction} isFirstUpload={false} action="delete" /> ); } ================================================ FILE: components/text-split/components/FileLoadingProgress.js ================================================ 'use client'; import { Box, Typography, keyframes, Paper } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { handleLongFileName } from '@/lib/file/file-process'; import { useState, useEffect } from 'react'; // 定义动画效果 const pulse = keyframes` 0% { box-shadow: 0 0 0 0 rgba(32, 76, 255, 0.2); } 70% { box-shadow: 0 0 0 15px rgba(32, 76, 255, 0); } 100% { box-shadow: 0 0 0 0 rgba(32, 76, 255, 0); } `; const rotateAnimation = keyframes` 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } `; const shimmer = keyframes` 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } `; /** * 文件处理进度展示组件 - 美化版 * * @param {Object} props * @param {Object} props.fileTask - 文件处理任务信息 */ export default function FileLoadingProgress({ fileTask }) { const { t } = useTranslation(); const [animationStep, setAnimationStep] = useState(0); // 创建动态效果 useEffect(() => { const interval = setInterval(() => { setAnimationStep(prev => (prev + 1) % 4); }, 600); return () => clearInterval(interval); }, []); if (!fileTask) { return null; } const pageProgress = (fileTask.current.processedPage / fileTask.current.totalPage) * 100; const filesProgress = (fileTask.processedFiles / fileTask.totalFiles) * 100; // 生成进度指示器文本 const getProgressIndicator = () => { const dots = '.'; return dots.repeat(animationStep + 1); }; return ( {/* 背景动画元素 */} {/* 主标题 */} {t('textSplit.pdfProcessingLoading')} {getProgressIndicator()} {/* 处理进度显示区域 */} {/* 当前文件进度 */} {/* 总文件进度 */} ); } /** * 进度条区域组件 */ function ProgressSection({ label, progress, color, mt = 0 }) { return ( {label} {Math.round(progress)}% {/* 自定义进度条 */} ); } ================================================ FILE: components/text-split/components/PdfProcessingDialog.js ================================================ 'use client'; import { Dialog, DialogTitle, DialogContent, Card, CardContent, Typography, Box, Stack, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { styled } from '@mui/material/styles'; import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined'; import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined'; import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; import ChangeCircleOutlinedIcon from '@mui/icons-material/ChangeCircleOutlined'; const StyledCard = styled(Card)(({ theme, disabled }) => ({ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1, transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out', '&:hover': disabled ? {} : { transform: 'translateY(-4px)', boxShadow: theme.shadows[4] } })); const OptionCard = ({ icon, title, description, disabled, onClick, selected, isVisionEnabled, visionModels, selectorName, handleSettingChange, selectedViosnModel }) => ( {icon} {title} {description} {isVisionEnabled && ( {selectorName} )} ); export default function PdfProcessingDialog({ open, onClose, onRadioChange, value, taskSettings, visionModels, selectedViosnModel, setSelectedViosnModel }) { const { t } = useTranslation(); //检查配置中是否启用MinerU const isMinerUEnabled = taskSettings && taskSettings.minerUToken ? true : false; const isMinerULocalEnabled = taskSettings && taskSettings.minerULocalUrl ? true : false; //检查配置中是否启用Vision策略 const isVisionEnabled = visionModels.length > 0 ? true : false; //用于传递到父组件,显示当前选中的模型 let selectedModel = selectedViosnModel; const handleOptionClick = optionValue => { if (optionValue === 'mineru-web') { window.open('https://mineru.net/OpenSourceTools/Extractor', '_blank'); } else { onRadioChange({ target: { value: optionValue, selectedVision: selectedModel } }); onClose(); } }; // 处理设置变更 const handleSettingChange = e => { const { value } = e.target; selectedModel = value; setSelectedViosnModel(value); }; return ( {t('textSplit.pdfProcess')} } title={t('textSplit.basicPdfParsing')} description={t('textSplit.basicPdfParsingDesc')} onClick={() => handleOptionClick('default')} selected={value === 'default'} /> } title="MinerU API" description={isMinerUEnabled ? t('textSplit.mineruApiDesc') : t('textSplit.mineruApiDescDisabled')} disabled={!isMinerUEnabled} onClick={() => handleOptionClick('mineru')} selected={value === 'mineru'} /> } title="MinerU Local" description={isMinerULocalEnabled ? t('textSplit.mineruLocalDesc') : t('textSplit.mineruLocalDisabled')} disabled={!isMinerULocalEnabled} onClick={() => handleOptionClick('mineru-local')} selected={value === 'mineru-local'} /> } title={t('textSplit.mineruWebPlatform')} description={t('textSplit.mineruWebPlatformDesc')} onClick={() => handleOptionClick('mineru-web')} /> } title={t('textSplit.customVisionModel')} description={t('textSplit.customVisionModelDesc')} disabled={!isVisionEnabled} onClick={() => handleOptionClick('vision')} selected={value === 'vision'} isVisionEnabled={isVisionEnabled} visionModels={visionModels} selectorName={t('settings.vision')} selectedViosnModel={selectedViosnModel} handleSettingChange={handleSettingChange} /> ); } ================================================ FILE: components/text-split/components/TabPanel.js ================================================ 'use client'; import { Box } from '@mui/material'; /** * 标签页面板组件 * @param {Object} props * @param {number} props.value - 当前激活的标签索引 * @param {number} props.index - 当前面板对应的索引 * @param {ReactNode} props.children - 子组件 */ export default function TabPanel({ value, index, children }) { return ( ); } ================================================ FILE: components/text-split/components/UploadArea.js ================================================ 'use client'; import { Box, Button, Typography, List, ListItem, ListItemText, Divider, CircularProgress, Tooltip } from '@mui/material'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import DeleteIcon from '@mui/icons-material/Delete'; import { alpha } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import React, { useRef, useState } from 'react'; export default function UploadArea({ theme, files, uploading, uploadedFiles, onFileSelect, onRemoveFile, onUpload, selectedModel }) { const { t } = useTranslation(); const [dragActive, setDragActive] = useState(false); const inputRef = useRef(null); // 拖拽进入 const handleDragOver = e => { e.preventDefault(); e.stopPropagation(); if (!dragActive) setDragActive(true); }; // 拖拽离开 const handleDragLeave = e => { e.preventDefault(); e.stopPropagation(); setDragActive(false); }; // 拖拽释放 const handleDrop = e => { e.preventDefault(); e.stopPropagation(); setDragActive(false); if (!selectedModel?.id || uploading) return; const files = e.dataTransfer.files; if (files && files.length > 0) { // 构造一个模拟的 event 以复用 onFileSelect const event = { target: { files } }; onFileSelect(event); } }; return ( {dragActive && ( {t('textSplit.dragToUpload', { defaultValue: '拖拽文件到此处上传' })} )} {t('textSplit.uploadNewDocument')} {uploadedFiles.total > 0 ? t('textSplit.mutilFileMessage') : t('textSplit.supportedFormats')} {files.length > 0 && ( {t('textSplit.selectedFiles', { count: files.length })} {files.map((file, index) => ( } onClick={() => onRemoveFile(index)} disabled={uploading} > {t('common.delete')} } > {index < files.length - 1 && } ))} )} ); } ================================================ FILE: constant/index.js ================================================ /** * 全局常量 */ export const FILE = { MAX_FILE_SIZE: 300 * 1024 * 1024 // 300MB in bytes }; export const TASK = { STATUS: { PROCESSING: 0, COMPLETED: 1, FAILED: 2 } }; ================================================ FILE: constant/model.js ================================================ export const MODEL_PROVIDERS = [ { id: 'ollama', name: 'Ollama', defaultEndpoint: 'http://127.0.0.1:11434/api', defaultModels: [] }, { id: 'openai', name: 'OpenAI', defaultEndpoint: 'https://api.openai.com/v1/', defaultModels: ['gpt-4o', 'gpt-4o-mini', 'o1-mini'] }, { id: 'siliconcloud', name: '硅基流动', defaultEndpoint: 'https://api.siliconflow.cn/v1/', defaultModels: [ 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3', 'Qwen2.5-7B-Instruct', 'meta-llama/Llama-3.3-70B-Instruct' ] }, { id: 'deepseek', name: 'DeepSeek', defaultEndpoint: 'https://api.deepseek.com/v1/', defaultModels: ['deepseek-chat', 'deepseek-reasoner'] }, { id: '302ai', name: '302.AI', defaultEndpoint: 'https://api.302.ai/v1/', defaultModels: ['Doubao-pro-128k', 'deepseek-r1', 'kimi-latest', 'qwen-max'] }, { id: 'zhipu', name: '智谱AI', defaultEndpoint: 'https://open.bigmodel.cn/api/paas/v4/', defaultModels: ['glm-4-flash', 'glm-4-flashx', 'glm-4-plus', 'glm-4-long'] }, { id: 'Doubao', name: '火山引擎', defaultEndpoint: 'https://ark.cn-beijing.volces.com/api/v3/', defaultModels: [] }, { id: 'groq', name: 'Groq', defaultEndpoint: 'https://api.groq.com/openai', defaultModels: ['Gemma 7B', 'LLaMA3 8B', 'LLaMA3 70B'] }, { id: 'grok', name: 'Grok', defaultEndpoint: 'https://api.x.ai/v1', defaultModels: ['Grok'] }, { id: 'OpenRouter', name: 'OpenRouter', defaultEndpoint: 'https://openrouter.ai/api/v1/', defaultModels: [ 'google/gemma-2-9b-it:free', 'meta-llama/llama-3-8b-instruct:free', 'microsoft/phi-3-mini-128k-instruct:free' ] }, { id: 'alibailian', name: '阿里云百炼', defaultEndpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModels: ['qwen-max-latest', 'qwen-max-2025-01-25'] } ]; export const DEFAULT_MODEL_SETTINGS = { temperature: 0.7, maxTokens: 8192, topP: 0.9 }; ================================================ FILE: constant/setting.js ================================================ // 默认项目任务配置 export const DEFAULT_SETTINGS = { textSplitMinLength: 2500, textSplitMaxLength: 4000, questionGenerationLength: 240, questionMaskRemovingProbability: 60, huggingfaceToken: '', concurrencyLimit: 5, visionConcurrencyLimit: 5, // 多轮对话数据集默认配置 multiTurnSystemPrompt: '', multiTurnScenario: '', multiTurnRounds: 3, multiTurnRoleA: '', multiTurnRoleB: '', // 测试集生成配置 evalQuestionTypeRatios: { true_false: 1, single_choice: 1, multiple_choice: 1, short_answer: 1, open_ended: 1 } }; ================================================ FILE: constant/sites.json ================================================ [ { "name": "HuggingFace开源数据集", "link": "https://huggingface.co/datasets", "image": "/imgs/huggingface.png", "description": "提供了丰富的开源数据集,涵盖多种领域和语言,支持自然语言处理、计算机视觉等多种任务。", "labels": ["热门推荐", "多模态"] }, { "name": "OpenDataLab开源数据集", "link": "https://opendatalab.com/", "image": "/imgs/opendatalab.png", "description": "致力于收集和整理高质量的开源数据集,方便研究人员和开发者使用。", "labels": ["热门推荐"] }, { "name": "谷歌开源数据集", "link": "https://datasetsearch.research.google.com", "image": "/imgs/google.png", "description": "谷歌提供的数据集搜索工具,可帮助用户找到来自不同来源的公开数据集。", "labels": ["热门推荐", "英文资源"] }, { "name": "kaggle开源数据集", "link": "https://www.kaggle.com/datasets", "image": "/imgs/kaggle.png", "description": "Kaggle平台上的开源数据集,涉及各种领域和任务,常用于数据竞赛和实践。", "labels": ["热门推荐", "英文资源"] }, { "name": "ModelScope开源数据集", "link": "https://modelscope.cn/datasets", "image": "/imgs/modelscope.png", "description": "提供了多种开源数据集,支持模型的训练和评估,涵盖多个领域。", "labels": ["中文资源"] }, { "name": "LUGE千言开源数据集", "link": "https://www.luge.ai/", "image": "/imgs/lluga.png", "description": "专注于中文领域的开源数据集,包括自然语言处理、语音识别等方向。", "labels": ["中文资源"] }, { "name": "GitHub开源数据集", "link": "https://github.com/awesomedata/awesome-public-datasets", "image": "/imgs/github.png", "description": "在GitHub上整理的优秀的公开数据集资源,涉及多个领域和方向。", "labels": ["热门推荐"] }, { "name": "AWS亚马逊开源数据集", "link": "https://registry.opendata.aws/", "image": "/imgs/aws.png", "description": "提供了大量的公开数据集,涵盖多个领域,可在亚马逊云服务上直接访问和使用。", "labels": ["英文资源"] }, { "name": "TIANCHI天池开源数据集", "link": "https://tianchi.aliyun.com/dataset/", "description": "阿里云天池平台提供的开源数据集,涵盖多个领域的竞赛数据和公开数据。", "labels": ["中文资源"] }, { "name": "UCI开源数据集", "link": "https://archive.ics.uci.edu/datasets", "description": "加州大学欧文分校提供的开源数据集,涵盖多个领域,常用于机器学习研究。", "labels": ["研究数据", "英文资源"] }, { "name": "计算机视觉开源数据集", "link": "https://visualdata.io/discovery", "description": "专注于计算机视觉领域的开源数据集,支持相关模型的训练和评估。", "labels": ["多模态"] }, { "name": "BAAI开源数据集", "link": "https://data.baai.ac.cn/data", "description": "北京智源人工智能研究院提供的开源数据集,涵盖多个领域,支持大模型的训练。", "labels": ["中文资源", "研究数据"] }, { "name": "百度飞桨开源数据集", "link": "https://aistudio.baidu.com/datasetoverview", "description": "百度飞桨平台提供的开源数据集,支持深度学习模型的训练和评估。", "labels": ["中文资源"] }, { "name": "启智开源数据集", "link": "https://openi.pcl.ac.cn/explore/datasets", "description": "开源平台提供的多种开源数据集,涵盖多个领域,支持模型的训练和研究。", "labels": ["中文资源"] }, { "name": "LAION-2B-en", "link": "https://laion.ai/", "description": "包含25亿张图像和相应的文本描述,适用于多模态模型的训练。", "labels": ["多模态"] }, { "name": "Common Crawl", "link": "https://commoncrawl.org/", "description": "提供了大量的网页爬取数据,可用于语言模型的训练。", "labels": ["英文资源", "研究数据"] }, { "name": "The Pile", "link": "https://github.com/EleutherAI/the-pile", "description": "由多个数据集组成的大型语言模型训练数据集,涵盖多种文本类型。", "labels": ["研究数据", "英文资源"] }, { "name": "MuJoCo", "link": "https://mujoco.org/", "description": "用于物理模拟的机器人交互数据集,适用于强化学习和机器人控制任务。", "labels": ["多模态"] }, { "name": "Robotics Datasets", "link": "https://roboticsdatasets.github.io/", "description": "提供了多种机器人交互数据集,支持机器人学习和控制任务。", "labels": ["多模态"] }, { "name": "Atari Games", "link": "https://www.atari.com/games", "description": "经典的Atari游戏数据集,用于强化学习算法的基准测试。", "labels": ["多模态"] }, { "name": "Web-crawled Interactions", "link": "https://commoncrawl.org/", "description": "从网络平台上爬取的用户行为数据,适用于训练交互式代理。", "labels": ["研究数据"] }, { "name": "AI2 ARC Dataset", "link": "https://allenai.org/data/arc", "description": "用于评估AI常识推理和解决问题能力的多选题数据集。", "labels": ["研究数据"] }, { "name": "Speech Commands Dataset", "link": "https://www.tensorflow.org/datasets/catalog/speech_commands", "description": "包含数千个语音命令的音频数据集,适用于语音识别任务。", "labels": ["多模态"] }, { "name": "Environmental Audio Datasets", "link": "https://www.tensorflow.org/datasets/catalog/audioset", "description": "包含环境声音事件的音频数据集,适用于音频场景分类任务。", "labels": ["多模态"] }, { "name": "COVID-19 Open Research Dataset", "link": "https://www.kaggle.com/allenai/cord-19-research-challenge", "description": "包含45,000篇关于COVID-19的学术文章,适用于医疗AI研究。", "labels": ["研究数据"] }, { "name": "Waymo Open Dataset", "link": "https://waymo.com/open/", "description": "由Waymo发布的最多样化的自动驾驶数据集。", "labels": ["多模态"] }, { "name": "Labelme", "link": "http://labelme.csail.mit.edu/Release3.0/", "description": "包含大量标注图像的数据集,适用于计算机视觉任务。", "labels": ["多模态"] }, { "name": "Stanford Dogs Dataset", "link": "http://vision.stanford.edu/aditya86/ImageNetDogs/", "description": "包含20,500多张不同狗品种的图像数据集。", "labels": ["多模态"] }, { "name": "Flickr Audio Caption Corpus", "link": "https://www.multispeech.org/2018/challenge.html", "description": "包含超过40,000个口语描述的音频数据集。", "labels": ["多模态"] }, { "name": "Data.gov", "link": "https://www.data.gov/", "description": "美国政府开放数据平台,涵盖农业、气候、教育、能源等领域的公开数据集。", "labels": ["政府数据", "英文资源"] }, { "name": "Eurostat", "link": "https://ec.europa.eu/eurostat", "description": "欧盟统计局提供的经济、人口、社会等多领域统计数据。", "labels": ["研究数据", "英文资源"] }, { "name": "ImageNet", "link": "https://www.image-net.org/", "description": "大型图像数据集,包含数百万张标注图像,广泛用于计算机视觉任务。", "labels": ["多模态", "计算机视觉"] }, { "name": "COCO Dataset", "link": "https://cocodataset.org/", "description": "通用物体识别与分割数据集,适用于目标检测和图像分割任务。", "labels": ["多模态"] }, { "name": "World Bank Open Data", "link": "https://data.worldbank.org/", "description": "世界银行提供的全球经济指标、发展数据及统计报告。", "labels": ["研究数据", "英文资源"] }, { "name": "NASA Earth Data", "link": "https://earthdata.nasa.gov/", "description": "NASA地球科学数据,涵盖气候、地质、环境等领域的遥感数据。", "labels": ["研究数据", "地球科学"] }, { "name": "Yelp Open Dataset", "link": "https://www.yelp.com/dataset", "description": "包含商家信息、用户评论和图片数据,适用于商业分析和NLP任务。", "labels": ["商业", "英文资源"] }, { "name": "CIFAR-10/100", "link": "https://www.cs.toronto.edu/~kriz/cifar.html", "description": "经典的小规模图像分类数据集,包含10或100个类别的标注图像。", "labels": ["多模态"] }, { "name": "Global Health Observatory (WHO)", "link": "https://www.who.int/data/gho", "description": "世界卫生组织提供的全球公共卫生统计数据,包括疾病、营养等主题。", "labels": ["医疗健康", "研究数据"] }, { "name": "arXiv Dataset", "link": "https://www.kaggle.com/Cornell-University/arxiv", "description": "包含数百万篇arXiv学术论文的元数据和全文,适用于文本挖掘研究。", "labels": ["研究数据", "英文资源"] }, { "name": "LibriSpeech", "link": "https://www.openslr.org/12", "description": "包含1000小时英语语音数据,适用于语音识别模型训练。", "labels": ["多模态", "语音识别"] }, { "name": "KITTI Vision Benchmark", "link": "http://www.cvlibs.net/datasets/kitti/", "description": "自动驾驶领域经典数据集,包含立体视觉、激光雷达等多模态数据。", "labels": ["多模态", "自动驾驶"] }, { "name": "Cityscapes Dataset", "link": "https://www.cityscapes-dataset.com/", "description": "城市街景语义分割数据集,支持自动驾驶和计算机视觉研究。", "labels": ["多模态"] }, { "name": "CDC Data", "link": "https://data.cdc.gov/", "description": "美国疾病控制与预防中心发布的公共卫生数据集,涵盖疾病追踪和健康统计。", "labels": ["医疗健康", "政府数据"] }, { "name": "OpenStreetMap", "link": "https://www.openstreetmap.org/", "description": "开源地理数据协作项目,提供全球范围的道路、建筑等地理信息数据。", "labels": ["地理信息", "众包数据"] }, { "name": "FiveThirtyEight Datasets", "link": "https://data.fivethirtyeight.com/", "description": "涵盖政治、体育、文化等领域的数据集,常用于数据新闻分析。", "labels": ["社会趋势", "英文资源"] }, { "name": "Human Protein Atlas", "link": "https://www.proteinatlas.org/", "description": "包含人体蛋白质分布的组织图像数据,支持生物医学研究。", "labels": ["医疗健康", "研究数据"] } ] ================================================ FILE: docker-compose.yml ================================================ services: easy-dataset: image: ghcr.io/conardli/easy-dataset container_name: easy-dataset ports: - '1717:1717' volumes: - ./local-db:/app/local-db - ./prisma:/app/prisma restart: unless-stopped ================================================ FILE: docker-entrypoint.sh ================================================ #!/bin/sh set -e # Colors for output GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color # Define paths PRISMA_DIR="/app/prisma" PRISMA_TEMPLATE_DIR="/app/prisma-template" DB_FILE="$PRISMA_DIR/db.sqlite" LOCAL_DB_DIR="/app/local-db" echo "${GREEN}=== Easy Dataset Database Initialization ===${NC}" # Create prisma directory if it doesn't exist if [ ! -d "$PRISMA_DIR" ]; then echo "${YELLOW}Creating prisma directory...${NC}" mkdir -p "$PRISMA_DIR" fi # Check if database file exists if [ ! -f "$DB_FILE" ]; then echo "${YELLOW}Database file not found at: $DB_FILE${NC}" # Check if local-db has files (possible configuration issue) if [ -d "$LOCAL_DB_DIR" ] && [ -n "$(ls -A $LOCAL_DB_DIR 2>/dev/null | grep -v 'empty.txt')" ]; then echo "${YELLOW}Note: local-db contains files but database is missing.${NC}" echo "${YELLOW}If you have existing data, ensure prisma volume is mounted.${NC}" fi # Safety check: only initialize if directory is completely empty if [ -z "$(ls -A $PRISMA_DIR 2>/dev/null)" ]; then # Directory is completely empty - safe to initialize echo "${GREEN}Prisma directory is empty. Initializing from template...${NC}" if [ -d "$PRISMA_TEMPLATE_DIR" ]; then cp -r "$PRISMA_TEMPLATE_DIR"/* "$PRISMA_DIR/" echo "${GREEN}Database initialized from template!${NC}" else echo "${YELLOW}No template found. Running prisma db push...${NC}" cd /app pnpm prisma db push --accept-data-loss echo "${GREEN}Database created successfully!${NC}" fi else # Directory is not empty but database is missing - error out echo "${RED}ERROR: Database file missing but prisma directory is not empty!${NC}" echo "${YELLOW}This may indicate a configuration problem.${NC}" echo "" echo "${YELLOW}Files in $PRISMA_DIR:${NC}" ls -lh "$PRISMA_DIR" echo "" echo "${YELLOW}Please either:${NC}" echo " 1. Remove all files in prisma directory to re-initialize" echo " 2. Or run: pnpm prisma db push --accept-data-loss" echo "" exit 1 fi else echo "${GREEN}Database file exists: $DB_FILE${NC}" fi echo "${GREEN}=== Database Ready! Starting application... ===${NC}" echo "" # Execute the command passed to the container exec "$@" ================================================ FILE: electron/entitlements.mac.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-dyld-environment-variables com.apple.security.network.client com.apple.security.files.user-selected.read-write ================================================ FILE: electron/loading.html ================================================ Easy Dataset Loading...

Easy Dataset

The first startup may take a bit longer to load. Please be patient. ...

================================================ FILE: electron/main.js ================================================ const { app, dialog, ipcMain } = require('electron'); const { setupLogging, setupIpcLogging } = require('./modules/logger'); const { createWindow, loadAppUrl, openDevTools, getMainWindow } = require('./modules/window-manager'); const { createMenu } = require('./modules/menu'); const { startNextServer } = require('./modules/server'); const { setupAutoUpdater } = require('./modules/updater'); const { initializeDatabase } = require('./modules/database'); const { clearCache } = require('./modules/cache'); const { setupIpcHandlers } = require('./modules/ipc-handlers'); // 是否是开发环境 const isDev = process.env.NODE_ENV === 'development'; const port = 1717; let mainWindow; // 当 Electron 完成初始化时创建窗口 app.whenReady().then(async () => { try { // 设置日志系统 setupLogging(app); // 设置 IPC 处理程序 setupIpcHandlers(app, isDev); setupIpcLogging(ipcMain, app, isDev); // 初始化数据库 await initializeDatabase(app); // 创建主窗口 mainWindow = createWindow(isDev, port); // 创建菜单 createMenu(mainWindow, () => clearCache(app)); // 在开发环境中加载 localhost URL if (isDev) { loadAppUrl(`http://localhost:${port}`); openDevTools(); } else { // 在生产环境中启动 Next.js 服务 const appUrl = await startNextServer(port, app); loadAppUrl(appUrl); } // 设置自动更新 setupAutoUpdater(mainWindow); // 应用启动完成后的一段时间后自动检查更新 setTimeout(() => { if (!isDev) { const { autoUpdater } = require('electron-updater'); autoUpdater.checkForUpdates().catch(err => { console.error('Automatic update check failed:', err); }); } }, 10000); // Check for updates after 10 seconds } catch (error) { console.error('An error occurred during application initialization:', error); dialog.showErrorBox( 'Application Initialization Error', `An error occurred during startup, which may affect application functionality. Error details: ${error.message}` ); } }); // 当所有窗口关闭时退出应用 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow(isDev, port); } }); // 应用退出前清理 app.on('before-quit', () => { console.log('应用正在退出...'); }); ================================================ FILE: electron/modules/cache.js ================================================ const { clearLogs } = require('./logger'); const { clearDatabaseCache } = require('./database'); /** * 清除缓存函数 - 清理logs和local-db目录 * @param {Object} app Electron app 对象 * @returns {Promise} 操作是否成功 */ async function clearCache(app) { // 清理日志目录 await clearLogs(app); // 清理数据库缓存 await clearDatabaseCache(app); return true; } module.exports = { clearCache }; ================================================ FILE: electron/modules/database.js ================================================ const fs = require('fs'); const path = require('path'); const { dialog } = require('electron'); const { updateDatabase } = require('./db-updater'); /** * 清除数据库缓存 * @param {Object} app Electron app 对象 * @returns {Promise} 操作是否成功 */ async function clearDatabaseCache(app) { // 清理local-db目录,保留db.sqlite文件 const localDbDir = path.join(app.getPath('userData'), 'local-db'); if (fs.existsSync(localDbDir)) { // 读取目录下所有文件 const files = await fs.promises.readdir(localDbDir); // 删除除了db.sqlite之外的所有文件 for (const file of files) { if (file !== 'db.sqlite') { const filePath = path.join(localDbDir, file); const stat = await fs.promises.stat(filePath); if (stat.isFile()) { await fs.promises.unlink(filePath); global.appLog(`已删除数据库缓存文件: ${filePath}`); } else if (stat.isDirectory()) { // 如果是目录,可能需要递归删除,根据需求决定 global.appLog(`跳过目录: ${filePath}`); } } } } return true; } /** * 初始化数据库 * @param {Object} app Electron app 对象 * @returns {Promise} 数据库配置信息 */ async function initializeDatabase(app) { try { // 设置数据库路径 const userDataPath = app.getPath('userData'); const dataDir = path.join(userDataPath, 'local-db'); const dbFilePath = path.join(dataDir, 'db.sqlite'); const dbJSONPath = path.join(dataDir, 'db.json'); fs.writeFileSync(path.join(process.resourcesPath, 'root-path.txt'), dataDir); // 确保数据目录存在 if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); console.log(`数据目录已创建: ${dataDir}`); } // 设置数据库连接字符串 (Prisma 格式) const dbConnectionString = `file:${dbFilePath}`; process.env.DATABASE_URL = dbConnectionString; // 仅在开发环境记录日志 const logs = { userDataPath, dataDir, dbFilePath, dbConnectionString, dbExists: fs.existsSync(dbFilePath) }; global.appLog(`数据库配置: ${JSON.stringify(logs)}`); if (!fs.existsSync(dbFilePath)) { global.appLog('数据库文件不存在,正在初始化...'); try { const resourcePath = process.env.NODE_ENV === 'development' ? path.join(__dirname, '../..', 'prisma', 'template.sqlite') : path.join(process.resourcesPath, 'prisma', 'template.sqlite'); const resourceJSONPath = process.env.NODE_ENV === 'development' ? path.join(__dirname, '../..', 'prisma', 'sql.json') : path.join(process.resourcesPath, 'prisma', 'sql.json'); global.appLog(`resourcePath: ${resourcePath}`); if (fs.existsSync(resourcePath)) { fs.copyFileSync(resourcePath, dbFilePath); global.appLog(`数据库已从模板初始化: ${dbFilePath}`); } if (fs.existsSync(resourceJSONPath)) { fs.copyFileSync(resourceJSONPath, dbJSONPath); global.appLog(`数据库SQL配置已初始化: ${dbJSONPath}`); } } catch (error) { console.error('数据库初始化失败:', error); dialog.showErrorBox('数据库初始化失败', `应用无法初始化数据库,可能需要重新安装。\n错误详情: ${error.message}`); throw error; } } else { // 数据库文件存在,检查是否需要更新 global.appLog('检查数据库是否需要更新...'); try { const resourcesPath = process.env.NODE_ENV === 'development' ? path.join(__dirname, '../..') : process.resourcesPath; const isDev = process.env.NODE_ENV === 'development'; // 更新数据库 const result = await updateDatabase(userDataPath, resourcesPath, isDev, global.appLog); if (result.updated) { global.appLog(`数据库更新成功: ${result.message}`); global.appLog(`执行的版本: ${result.executedVersions.join(', ')}`); } else { global.appLog(`数据库无需更新: ${result.message}`); } } catch (error) { console.error('数据库更新失败:', error); global.appLog(`数据库更新失败: ${error.message}`, 'error'); // 非致命错误,只提示但不阻止应用启动 dialog.showMessageBox({ type: 'warning', title: '数据库更新警告', message: '数据库更新过程中出现错误,部分功能可能受影响。', detail: `错误详情: ${error.message}\n\n您可以继续使用应用,但如果遇到问题,请重新安装应用。`, buttons: ['继续'] }); } } return { userDataPath, dataDir, dbFilePath, dbConnectionString }; } catch (error) { console.error('初始化数据库时发生错误:', error); throw error; } } module.exports = { clearDatabaseCache, initializeDatabase }; ================================================ FILE: electron/modules/db-updater.js ================================================ const fs = require('fs'); const path = require('path'); const { PrismaClient } = require('@prisma/client'); /** * 执行SQL命令 * @param {string} dbUrl 数据库连接 URL * @param {string} sql SQL命令 * @returns {Promise} */ async function executeSql(dbUrl, sql) { // 允许多条SQL语句分开执行,支持分号和空行分隔 const statements = sql .split(';') .map(stmt => stmt.trim()) .filter(stmt => stmt.length > 0); if (statements.length === 0) { return; } // 设置环境变量 process.env.DATABASE_URL = dbUrl; // 创建Prisma实例 const prisma = new PrismaClient(); try { // 执行每条SQL语句 for (const statement of statements) { await prisma.$executeRawUnsafe(statement); } } finally { // 关闭连接 await prisma.$disconnect(); } } /** * 获取本地和应用的SQL配置文件 * @param {string} userDataPath 用户数据目录 * @param {string} resourcesPath 应用资源目录 * @param {boolean} isDev 是否开发环境 * @returns {Promise<{userSqlConfig: Array, appSqlConfig: Array}>} */ async function getSqlConfigs(userDataPath, resourcesPath, isDev, logger = console.log) { // 用户SQL配置文件路径 const userSqlPath = path.join(userDataPath, 'sql.json'); // 应用SQL配置文件路径 const appSqlPath = isDev ? path.join(__dirname, '..', 'prisma', 'sql.json') : path.join(resourcesPath, 'prisma', 'sql.json'); let userSqlConfig = []; let appSqlConfig = []; // 读取应用SQL配置 try { if (fs.existsSync(appSqlPath)) { const appSqlContent = fs.readFileSync(appSqlPath, 'utf8'); appSqlConfig = JSON.parse(appSqlContent); } } catch (error) { throw new Error(`读取应用SQL配置文件失败: ${error.message}`); } // 读取用户SQL配置(如果存在) try { if (fs.existsSync(userSqlPath)) { const userSqlContent = fs.readFileSync(userSqlPath, 'utf8'); userSqlConfig = JSON.parse(userSqlContent); } } catch (error) { // 如果用户SQL配置不存在或无法解析,使用空数组 userSqlConfig = []; } logger(appSqlPath); // logger(JSON.stringify(appSqlConfig, null, 2)); logger(userSqlPath); // logger(JSON.stringify(userSqlConfig, null, 2)); return { userSqlConfig, appSqlConfig }; } /** * 更新用户SQL配置文件 * @param {string} userDataPath 用户数据目录 * @param {Array} sqlConfig 新的SQL配置 */ function updateUserSqlConfig(userDataPath, sqlConfig) { const userSqlPath = path.join(userDataPath, 'sql.json'); fs.writeFileSync(userSqlPath, JSON.stringify(sqlConfig, null, 4), 'utf8'); } // 不再需要版本比较功能 /** * 获取需要执行的SQL命令 * @param {Array} userSqlConfig 用户SQL配置 * @param {Array} appSqlConfig 应用SQL配置 * @returns {Array} 需要执行的SQL命令 */ function getSqlsToExecute(userSqlConfig, appSqlConfig) { // 创建用户已执行的SQL集合 (使用 version + sql 的组合作为唯一标识) const userExecutedSqlSet = new Set(); userSqlConfig.forEach(item => { const key = `${item.version}:${item.sql}`; userExecutedSqlSet.add(key); }); // 过滤出用户需要执行的SQL (即应用SQL配置中存在但用户尚未执行的SQL) return appSqlConfig.filter(item => { const key = `${item.version}:${item.sql}`; return !userExecutedSqlSet.has(key); }); } /** * 更新数据库 * @param {string} userDataPath 用户数据目录 * @param {string} resourcesPath 应用资源目录 * @param {boolean} isDev 是否开发环境 * @param {function} logger 日志函数 */ async function updateDatabase(userDataPath, resourcesPath, isDev, logger = console.log) { const dbPath = path.join(userDataPath, 'local-db', 'db.sqlite'); try { // 获取SQL配置 const { userSqlConfig, appSqlConfig } = await getSqlConfigs(userDataPath, resourcesPath, isDev, logger); // 获取需要执行的SQL const sqlsToExecute = getSqlsToExecute(userSqlConfig, appSqlConfig); if (sqlsToExecute.length === 0) { logger('数据库已是最新版本,无需更新'); return { updated: false, message: '数据库已是最新版本' }; } // 设置数据库URL const dbUrl = `file:${dbPath}`; // 执行SQL更新 logger(`发现 ${sqlsToExecute.length} 个数据库更新,开始执行...`); for (const item of sqlsToExecute) { try { logger(`执行版本 ${item.version} 的SQL更新: ${item.sql.substring(0, 100)}...`); await executeSql(dbUrl, item.sql); // 添加到用户SQL配置 userSqlConfig.push(item); } catch (error) { logger(`执行版本 ${item.version} 的SQL更新失败: ${error.message}`); } } // 更新用户SQL配置文件 updateUserSqlConfig(userDataPath, userSqlConfig); logger('数据库更新完成'); return { updated: true, message: `成功执行了 ${sqlsToExecute.length} 个数据库更新`, executedVersions: sqlsToExecute.map(item => item.version) }; } catch (error) { logger(`数据库更新失败: ${error.message}`); return { updated: false, error: error.message }; } } module.exports = { updateDatabase, executeSql, getSqlConfigs, updateUserSqlConfig, getSqlsToExecute }; ================================================ FILE: electron/modules/ipc-handlers.js ================================================ const { ipcMain } = require('electron'); const { checkUpdate, downloadUpdate, installUpdate } = require('./updater'); /** * 设置 IPC 处理程序 * @param {Object} app Electron app 对象 * @param {boolean} isDev 是否为开发环境 */ function setupIpcHandlers(app, isDev) { // 获取用户数据路径 ipcMain.on('get-user-data-path', event => { event.returnValue = app.getPath('userData'); }); // 检查更新 ipcMain.handle('check-update', async () => { return await checkUpdate(isDev); }); // 下载更新 ipcMain.handle('download-update', async () => { return await downloadUpdate(); }); // 安装更新 ipcMain.handle('install-update', () => { return installUpdate(); }); } module.exports = { setupIpcHandlers }; ================================================ FILE: electron/modules/logger.js ================================================ const fs = require('fs'); const path = require('path'); /** * 设置应用日志系统 * @param {Object} app Electron app 对象 * @returns {string} 日志文件路径 */ function setupLogging(app) { const logDir = path.join(app.getPath('userData'), 'logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFilePath = path.join(logDir, `app-${new Date().toISOString().slice(0, 10)}.log`); // 创建自定义日志函数 global.appLog = (message, level = 'info') => { const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; // 同时输出到控制台和日志文件 console.log(message); fs.appendFileSync(logFilePath, logEntry); }; // 捕获全局未处理异常并记录 process.on('uncaughtException', error => { global.appLog(`未捕获的异常: ${error.stack || error}`, 'error'); }); return logFilePath; } /** * 设置 IPC 日志处理程序 * @param {Object} ipcMain IPC 主进程对象 * @param {Object} app Electron app 对象 * @param {boolean} isDev 是否为开发环境 */ function setupIpcLogging(ipcMain, app, isDev) { ipcMain.on('log', (event, { level, message }) => { const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; // 只在客户端环境下写入文件 if (!isDev || true) { const logsDir = path.join(app.getPath('userData'), 'logs'); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } const logFile = path.join(logsDir, `${new Date().toISOString().split('T')[0]}.log`); fs.appendFileSync(logFile, logEntry); } // 同时输出到控制台 console[level](message); }); } /** * 清理日志文件 * @param {Object} app Electron app 对象 * @returns {Promise} */ async function clearLogs(app) { const logsDir = path.join(app.getPath('userData'), 'logs'); if (fs.existsSync(logsDir)) { // 读取目录下所有文件 const files = await fs.promises.readdir(logsDir); // 删除所有文件 for (const file of files) { const filePath = path.join(logsDir, file); await fs.promises.unlink(filePath); global.appLog(`已删除日志文件: ${filePath}`); } } } module.exports = { setupLogging, setupIpcLogging, clearLogs }; ================================================ FILE: electron/modules/menu.js ================================================ const { Menu, dialog, shell, app } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { getAppVersion } = require('../util'); /** * 创建应用菜单 * @param {BrowserWindow} mainWindow 主窗口 * @param {Function} clearCache 清除缓存函数 */ function createMenu(mainWindow, clearCache) { const template = [ { label: 'File', submenu: [{ role: 'quit', label: 'Quit' }] }, { label: 'Edit', submenu: [ { role: 'undo', label: 'Undo' }, { role: 'redo', label: 'Redo' }, { type: 'separator' }, { role: 'cut', label: 'Cut' }, { role: 'copy', label: 'Copy' }, { role: 'paste', label: 'Paste' } ] }, { label: 'View', submenu: [ { role: 'reload', label: 'Refresh' }, { type: 'separator' }, { role: 'resetzoom', label: 'Reset Zoom' }, { role: 'zoomin', label: 'Zoom In' }, { role: 'zoomout', label: 'Zoom Out' }, { type: 'separator' }, { role: 'togglefullscreen', label: 'Fullscreen' } ] }, { label: 'Help', submenu: [ { label: 'About', click: () => { dialog.showMessageBox(mainWindow, { title: 'About Easy Dataset', message: `Easy Dataset v${getAppVersion()}`, detail: 'An application for creating fine-tuning datasets for large models.', buttons: ['OK'] }); } }, { label: 'Visit GitHub', click: () => { shell.openExternal('https://github.com/ConardLi/easy-dataset'); } } ] }, { label: 'More', submenu: [ { role: 'toggledevtools', label: 'Developer Tools' }, { label: 'Open Logs Directory', click: () => { const logsDir = path.join(app.getPath('userData'), 'logs'); if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } shell.openPath(logsDir); } }, { label: 'Open Data Directory', click: () => { const dataDir = path.join(app.getPath('userData'), 'local-db'); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } shell.openPath(dataDir); } }, { label: 'Open Data Directory (History)', click: () => { const dataDir = path.join(os.homedir(), '.easy-dataset-db'); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } shell.openPath(dataDir); } }, { label: 'Clear Cache', click: async () => { try { const response = await dialog.showMessageBox(mainWindow, { type: 'question', buttons: ['Cancel', 'Confirm'], defaultId: 1, title: 'Clear Cache', message: 'Are you sure you want to clear the cache?', detail: 'This will delete all files in the logs directory and local database cache files (excluding main database files).' }); if (response.response === 1) { // User clicked confirm await clearCache(); dialog.showMessageBox(mainWindow, { type: 'info', title: 'Cleared Successfully', message: 'Cache has been cleared successfully' }); } } catch (error) { global.appLog(`Failed to clear cache: ${error.message}`, 'error'); dialog.showErrorBox('Failed to clear cache', error.message); } } } ] } ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } module.exports = { createMenu }; ================================================ FILE: electron/modules/server.js ================================================ const http = require('http'); const path = require('path'); const fs = require('fs'); const { dialog } = require('electron'); /** * 检查端口是否被占用 * @param {number} port 端口号 * @returns {Promise} 端口是否被占用 */ function checkPort(port) { return new Promise(resolve => { const server = http.createServer(); server.once('error', () => { resolve(true); // 端口被占用 }); server.once('listening', () => { server.close(); resolve(false); // 端口未被占用 }); server.listen(port); }); } /** * 启动 Next.js 服务 * @param {number} port 端口号 * @param {Object} app Electron app 对象 * @returns {Promise} 服务URL */ async function startNextServer(port, app) { console.log(`Easy Dataset 客户端启动中,当前版本: ${require('../util').getAppVersion()}`); // 设置日志文件路径 const logDir = path.join(app.getPath('userData'), 'logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFile = path.join(logDir, `nextjs-${new Date().toISOString().replace(/:/g, '-')}.log`); const logStream = fs.createWriteStream(logFile, { flags: 'a' }); // 重定向 console.log 和 console.error const originalConsoleLog = console.log; const originalConsoleError = console.error; console.log = function () { const args = Array.from(arguments); const logMessage = args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg)).join(' '); logStream.write(`[${new Date().toISOString()}] [LOG] ${logMessage}\n`); originalConsoleLog.apply(console, args); }; console.error = function () { const args = Array.from(arguments); const logMessage = args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg)).join(' '); logStream.write(`[${new Date().toISOString()}] [ERROR] ${logMessage}\n`); originalConsoleError.apply(console, args); }; // 检查端口是否被占用 const isPortBusy = await checkPort(port); if (isPortBusy) { console.log(`端口 ${port} 已被占用,尝试直接连接...`); return `http://localhost:${port}`; } console.log(`启动 Next.js 服务,端口: ${port}`); try { // 动态导入 Next.js const next = require('next'); const nextApp = next({ dev: false, dir: path.join(__dirname, '../..'), conf: { // 配置 Next.js 的日志输出 onInfo: info => { console.log(`[Next.js Info] ${info}`); }, onError: error => { console.error(`[Next.js Error] ${error}`); }, onWarn: warn => { console.log(`[Next.js Warning] ${warn}`); } } }); const handle = nextApp.getRequestHandler(); await nextApp.prepare(); const server = http.createServer((req, res) => { // 记录请求日志 console.log(`[Request] ${req.method} ${req.url}`); handle(req, res); }); return new Promise(resolve => { server.listen(port, err => { if (err) throw err; console.log(`服务已启动,正在打开应用...`); resolve(`http://localhost:${port}`); }); }); } catch (error) { console.error('启动服务失败:', error); dialog.showErrorBox('启动失败', `无法启动 Next.js 服务: ${error.message}`); app.quit(); return ''; } } module.exports = { checkPort, startNextServer }; ================================================ FILE: electron/modules/updater.js ================================================ const { autoUpdater } = require('electron-updater'); const { getAppVersion } = require('../util'); /** * 设置自动更新 * @param {BrowserWindow} mainWindow 主窗口 */ function setupAutoUpdater(mainWindow) { autoUpdater.autoDownload = false; autoUpdater.allowDowngrade = false; // 检查更新时出错 autoUpdater.on('error', error => { if (mainWindow) { mainWindow.webContents.send('update-error', error.message); } }); // 检查到更新时 autoUpdater.on('update-available', info => { if (mainWindow) { mainWindow.webContents.send('update-available', { version: info.version, releaseDate: info.releaseDate, releaseNotes: info.releaseNotes }); } }); // 没有可用更新 autoUpdater.on('update-not-available', () => { if (mainWindow) { mainWindow.webContents.send('update-not-available'); } }); // 下载进度 autoUpdater.on('download-progress', progressObj => { if (mainWindow) { mainWindow.webContents.send('download-progress', progressObj); } }); // 下载完成 autoUpdater.on('update-downloaded', info => { if (mainWindow) { mainWindow.webContents.send('update-downloaded', { version: info.version, releaseDate: info.releaseDate, releaseNotes: info.releaseNotes }); } }); } /** * 检查更新 * @param {boolean} isDev 是否为开发环境 * @returns {Promise} 更新信息 */ async function checkUpdate(isDev) { try { if (isDev) { // 开发环境下模拟更新检查 return { hasUpdate: false, currentVersion: getAppVersion(), message: '开发环境下不检查更新' }; } // 返回当前版本信息,并开始检查更新 const result = await autoUpdater.checkForUpdates(); return { checking: true, currentVersion: getAppVersion() }; } catch (error) { console.error('检查更新失败:', error); return { hasUpdate: false, currentVersion: getAppVersion(), error: error.message }; } } /** * 下载更新 * @returns {Promise} 下载状态 */ async function downloadUpdate() { try { autoUpdater.downloadUpdate(); return { downloading: true }; } catch (error) { console.error('下载更新失败:', error); return { error: error.message }; } } /** * 安装更新 * @returns {Object} 安装状态 */ function installUpdate() { autoUpdater.quitAndInstall(false, true); return { installing: true }; } module.exports = { setupAutoUpdater, checkUpdate, downloadUpdate, installUpdate }; ================================================ FILE: electron/modules/window-manager.js ================================================ const { BrowserWindow, shell } = require('electron'); const path = require('path'); const url = require('url'); const { getAppVersion } = require('../util'); let mainWindow; /** * 创建主窗口 * @param {boolean} isDev 是否为开发环境 * @param {number} port 服务端口 * @returns {BrowserWindow} 创建的主窗口 */ function createWindow(isDev, port) { mainWindow = new BrowserWindow({ width: 1200, height: 800, show: false, frame: true, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, '..', 'preload.js') }, icon: path.join(__dirname, '../../public/imgs/logo.ico') }); // 设置窗口标题 mainWindow.setTitle(`Easy Dataset v${getAppVersion()}`); const loadingPath = url.format({ pathname: path.join(__dirname, '..', 'loading.html'), protocol: 'file:', slashes: true }); // 加载 loading 页面时使用专门的 preload 脚本 mainWindow.webContents.on('did-finish-load', () => { mainWindow.show(); }); mainWindow.loadURL(loadingPath); // 处理窗口导航事件,将外部链接在浏览器中打开 mainWindow.webContents.on('will-navigate', (event, navigationUrl) => { // 解析当前 URL 和导航 URL const parsedUrl = new URL(navigationUrl); const currentHostname = isDev ? 'localhost' : 'localhost'; const currentPort = port.toString(); // 检查是否是外部链接 if (parsedUrl.hostname !== currentHostname || (parsedUrl.port !== currentPort && parsedUrl.port !== '')) { event.preventDefault(); shell.openExternal(navigationUrl); } }); // 处理新窗口打开请求,将外部链接在浏览器中打开 mainWindow.webContents.setWindowOpenHandler(({ url: navigationUrl }) => { // 解析导航 URL const parsedUrl = new URL(navigationUrl); const currentHostname = isDev ? 'localhost' : 'localhost'; const currentPort = port.toString(); // 检查是否是外部链接 if (parsedUrl.hostname !== currentHostname || (parsedUrl.port !== currentPort && parsedUrl.port !== '')) { shell.openExternal(navigationUrl); return { action: 'deny' }; } return { action: 'allow' }; }); mainWindow.on('closed', () => { mainWindow = null; }); mainWindow.maximize(); return mainWindow; } /** * 加载应用URL * @param {string} appUrl 应用URL */ function loadAppUrl(appUrl) { if (mainWindow) { mainWindow.loadURL(appUrl); } } /** * 在开发环境中打开开发者工具 */ function openDevTools() { if (mainWindow) { mainWindow.webContents.openDevTools(); } } /** * 获取主窗口 * @returns {BrowserWindow} 主窗口 */ function getMainWindow() { return mainWindow; } module.exports = { createWindow, loadAppUrl, openDevTools, getMainWindow }; ================================================ FILE: electron/preload.js ================================================ const { contextBridge, ipcRenderer } = require('electron'); // 在渲染进程中暴露安全的 API contextBridge.exposeInMainWorld('electron', { // 获取应用版本 getAppVersion: () => ipcRenderer.invoke('get-app-version'), // 获取当前语言 getLanguage: () => { // 尝试从本地存储获取语言设置 const storedLang = localStorage.getItem('i18nextLng'); // 如果存在则返回,否则返回系统语言或默认为中文 return storedLang || navigator.language.startsWith('zh') ? 'zh' : 'en'; }, // 获取用户数据目录 getUserDataPath: () => { try { return ipcRenderer.sendSync('get-user-data-path'); } catch (error) { console.error('获取用户数据目录失败:', error); return null; } }, // 更新相关 API updater: { // 检查更新 checkForUpdates: () => ipcRenderer.invoke('check-update'), // 下载更新 downloadUpdate: () => ipcRenderer.invoke('download-update'), // 安装更新 installUpdate: () => ipcRenderer.invoke('install-update'), // 监听更新事件 onUpdateAvailable: callback => { const handler = (_, info) => callback(info); ipcRenderer.on('update-available', handler); return () => ipcRenderer.removeListener('update-available', handler); }, onUpdateNotAvailable: callback => { const handler = () => callback(); ipcRenderer.on('update-not-available', handler); return () => ipcRenderer.removeListener('update-not-available', handler); }, onUpdateError: callback => { const handler = (_, error) => callback(error); ipcRenderer.on('update-error', handler); return () => ipcRenderer.removeListener('update-error', handler); }, onDownloadProgress: callback => { const handler = (_, progress) => callback(progress); ipcRenderer.on('download-progress', handler); return () => ipcRenderer.removeListener('download-progress', handler); }, onUpdateDownloaded: callback => { const handler = (_, info) => callback(info); ipcRenderer.on('update-downloaded', handler); return () => ipcRenderer.removeListener('update-downloaded', handler); } } }); // 通知渲染进程 preload 脚本已加载完成 window.addEventListener('DOMContentLoaded', () => { console.log('Electron preload script loaded'); }); ================================================ FILE: electron/util.js ================================================ const path = require('path'); const fs = require('fs'); // 获取应用版本 const getAppVersion = () => { try { const packageJsonPath = path.join(__dirname, '../package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.version; } return '1.0.0'; } catch (error) { console.error('读取版本信息失败:', error); return '1.0.0'; } }; module.exports = { getAppVersion }; ================================================ FILE: hooks/useDebounce.js ================================================ import { useEffect, useState } from 'react'; export function useDebounce(value, delay = 500) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(timer); }; }, [value, delay]); return debouncedValue; } ================================================ FILE: hooks/useFileProcessingStatus.js ================================================ import { useState, useEffect } from 'react'; // 存储文件处理状态的共享对象 const fileProcessingSubscribers = { value: false, listeners: new Set() }; // 存储文件任务信息的共享对象 const fileTaskSubscribers = { value: null, listeners: new Set() }; /** * 自定义hook,用于在组件间共享文件处理任务的状态 */ export default function useFileProcessingStatus() { const [taskFileProcessing, setTaskFileProcessing] = useState(fileProcessingSubscribers.value); const [task, setTask] = useState(fileTaskSubscribers.value); useEffect(() => { // 添加当前组件为订阅者 const updateProcessingState = newValue => setTaskFileProcessing(newValue); const updateTaskState = newTask => setTask(newTask); fileProcessingSubscribers.listeners.add(updateProcessingState); fileTaskSubscribers.listeners.add(updateTaskState); // 组件卸载时清理 return () => { fileProcessingSubscribers.listeners.delete(updateProcessingState); fileTaskSubscribers.listeners.delete(updateTaskState); }; }, []); // 共享的setState函数 const setSharedFileProcessing = newValue => { fileProcessingSubscribers.value = newValue; // 通知所有订阅者 fileProcessingSubscribers.listeners.forEach(listener => listener(newValue)); }; // 共享的setTask函数 const setSharedTask = newTask => { fileTaskSubscribers.value = newTask; // 通知所有订阅者 fileTaskSubscribers.listeners.forEach(listener => listener(newTask)); }; return { taskFileProcessing, task, setTaskFileProcessing: setSharedFileProcessing, setTask: setSharedTask }; } ================================================ FILE: hooks/useGenerateDataset.js ================================================ import { useCallback } from 'react'; import { toast } from 'sonner'; import i18n from '@/lib/i18n'; import axios from 'axios'; import { useAtomValue } from 'jotai/index'; import { selectedModelInfoAtom } from '@/lib/store'; import { useTranslation } from 'react-i18next'; export function useGenerateDataset() { const model = useAtomValue(selectedModelInfoAtom); const { t } = useTranslation(); const generateSingleDataset = useCallback( async ({ projectId, questionId, questionInfo, imageId, imageName }) => { // 获取模型参数 if (!model) { toast.error(t('models.configNotFound')); return null; } // 判断是否为图片问题 const isImageQuestion = !!imageId; // 调用API生成数据集 const currentLanguage = i18n.language === 'zh-CN' ? '中文' : 'en'; if (isImageQuestion) { // 图片问题:调用图片数据集生成接口 toast.promise( axios.post(`/api/projects/${projectId}/images/datasets`, { imageName, question: { question: questionInfo, id: questionId }, model, language: currentLanguage }), { loading: t('datasets.generating'), description: `图片:【${imageName}】\n问题:【${questionInfo}】`, position: 'top-right', success: data => { return '生成数据集成功'; }, error: error => { return t('datasets.generateFailed', { error: error.response?.data?.error }); } } ); } else { // 文本问题:调用普通数据集生成接口 toast.promise( axios.post(`/api/projects/${projectId}/datasets`, { questionId, model, language: currentLanguage }), { loading: t('datasets.generating'), description: `问题:【${questionInfo}】`, position: 'top-right', success: data => { return '生成数据集成功'; }, error: error => { return t('datasets.generateFailed', { error: error.response?.data?.error }); } } ); } }, [model, t] ); const generateMultipleDataset = useCallback( async (projectId, questions) => { let completed = 0; const total = questions.length; // 显示带进度的Loading const loadingToastId = toast.loading(`正在处理请求 (${completed}/${total})...`, { position: 'top-right' }); // 处理每个请求 const processRequest = async question => { try { const isImageQuestion = !!question.imageId; let response; if (isImageQuestion) { // 图片问题 response = await axios.post(`/api/projects/${projectId}/images/datasets`, { imageName: question.imageName, question, model, language: i18n.language === 'zh-CN' ? '中文' : 'en' }); } else { // 文本问题 response = await axios.post(`/api/projects/${projectId}/datasets`, { questionId: question.id, model, language: i18n.language === 'zh-CN' ? '中文' : 'en' }); } const data = response.data; completed++; toast.success(`${question.question} 完成`, { position: 'top-right' }); toast.loading(`正在处理请求 (${completed}/${total})...`, { id: loadingToastId }); return data; } catch (error) { completed++; toast.error(`${question.question} 失败`, { description: error.message, position: 'top-right' }); toast.loading(`正在处理请求 (${completed}/${total})...`, { id: loadingToastId }); throw error; } }; try { const results = await Promise.allSettled(questions.map(req => processRequest(req))); // 全部完成后更新Loading为完成状态 toast.success(`全部请求处理完成 (成功: ${results.filter(r => r.status === 'fulfilled').length}/${total})`, { id: loadingToastId, position: 'top-right' }); return results; } catch { // Promise.allSettled不会进入catch,这里只是保险 } }, [model, t] ); return { generateSingleDataset, generateMultipleDataset }; } ================================================ FILE: hooks/useModelPlayground.js ================================================ 'use client'; import { useState, useEffect } from 'react'; import { useAtomValue } from 'jotai/index'; import { modelConfigListAtom } from '@/lib/store'; export default function useModelPlayground(projectId, defaultModelId = null) { // 状态管理 const [selectedModels, setSelectedModels] = useState(defaultModelId ? [defaultModelId] : []); const [loading, setLoading] = useState({}); const [userInput, setUserInput] = useState(''); const [conversations, setConversations] = useState({}); const [error, setError] = useState(null); const [outputMode, setOutputMode] = useState('normal'); // 'normal' 或 'streaming' const [uploadedImage, setUploadedImage] = useState(null); // 存储上传的图片Base64 const availableModels = useAtomValue(modelConfigListAtom); // 初始化会话状态 useEffect(() => { if (selectedModels.length > 0) { const initialConversations = {}; selectedModels.forEach(modelId => { if (!conversations[modelId]) { initialConversations[modelId] = []; } }); if (Object.keys(initialConversations).length > 0) { setConversations(prev => ({ ...prev, ...initialConversations })); } } }, [selectedModels]); // 处理模型选择 const handleModelSelection = event => { const { target: { value } } = event; // 限制最多选择 3 个模型 const selectedValues = typeof value === 'string' ? value.split(',') : value; const limitedSelection = selectedValues.slice(0, 3); setSelectedModels(limitedSelection); }; // 处理用户输入 const handleInputChange = e => { setUserInput(e.target.value); }; // 处理图片上传 const handleImageUpload = e => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onloadend = () => { setUploadedImage(reader.result); }; reader.readAsDataURL(file); } }; // 删除已上传的图片 const handleRemoveImage = () => { setUploadedImage(null); }; // 处理输出模式切换 const handleOutputModeChange = event => { setOutputMode(event.target.value); }; // 发送消息给所有选中的模型 const handleSendMessage = async () => { if (!userInput.trim() || Object.values(loading).some(value => value) || selectedModels.length === 0) return; // 获取用户输入 const input = userInput.trim(); setUserInput(''); // 获取图片(如果有的话) const image = uploadedImage; setUploadedImage(null); // 清除图片 // 更新所有选中模型的对话 const updatedConversations = { ...conversations }; selectedModels.forEach(modelId => { if (!updatedConversations[modelId]) { updatedConversations[modelId] = []; } // 检查是否有图片并且当前模型是视觉模型 const model = availableModels.find(m => m.id === modelId); const isVisionModel = model && model.type === 'vision'; if (isVisionModel && image) { // 如果是视觉模型并且有图片,使用复合格式 updatedConversations[modelId].push({ role: 'user', content: [ { type: 'text', text: input || '请描述这个图片' }, { type: 'image_url', image_url: { url: image } } ] }); } else { // 其他情况使用纯文本 updatedConversations[modelId].push({ role: 'user', content: input }); } }); setConversations(updatedConversations); // 为每个模型设置独立的加载状态 const updatedLoading = {}; selectedModels.forEach(modelId => { updatedLoading[modelId] = true; }); setLoading(updatedLoading); // 为每个模型单独发送请求 selectedModels.forEach(async modelId => { const model = availableModels.find(m => m.id === modelId); if (!model) { // 模型配置不存在 const modelConversation = [...(updatedConversations[modelId] || [])]; // 更新对话状态 setConversations(prev => ({ ...prev, [modelId]: [...modelConversation, { role: 'error', content: '模型配置不存在' }] })); // 更新加载状态 setLoading(prev => ({ ...prev, [modelId]: false })); return; } try { // 检查是否是视觉模型且有图片 const isVisionModel = model.type === 'vision'; // 构建请求消息 let requestMessages = [...updatedConversations[modelId]]; // 复制当前消息历史 // 如果是vision模型并且有图片,将最后一条用户消息替换为包含图片的消息 if (isVisionModel && image && requestMessages.length > 0) { // 找到最后一条用户消息 const lastUserMsgIndex = requestMessages.length - 1; // 替换为包含图片的消息 requestMessages[lastUserMsgIndex] = { role: 'user', content: [ { type: 'text', text: input || '请描述这个图片' }, { type: 'image_url', image_url: { url: image } } ] }; } // 根据输出模式选择不同的处理方式 if (outputMode === 'streaming') { // 流式输出处理 // 先添加一个空的助手回复,用于后续流式更新 setConversations(prev => { const modelConversation = [...(prev[modelId] || [])]; return { ...prev, [modelId]: [ ...modelConversation, { role: 'assistant', content: '', isStreaming: true, thinking: '', // 添加推理过程字段 showThinking: true // 默认显示推理过程 } ] }; }); const response = await fetch(`/api/projects/${projectId}/playground/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model, messages: requestMessages }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let accumulatedContent = ''; // 状态变量,用于跟踪是否正在处理思维链 let isInThinking = false; let currentThinking = ''; let currentContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; // 解码收到的数据块 const chunk = decoder.decode(value, { stream: true }); // 处理当前数据块 for (let i = 0; i < chunk.length; i++) { const char = chunk[i]; // 检测开始标签 if (i + 6 <= chunk.length && chunk.substring(i, i + 7) === '') { isInThinking = true; i += 6; // 跳过标签 continue; } // 检测结束标签 if (i + 7 <= chunk.length && chunk.substring(i, i + 8) === '') { isInThinking = false; i += 7; // 跳过标签 continue; } // 根据当前状态添加到对应内容中 if (isInThinking) { currentThinking += char; } else { currentContent += char; } } // 累积全部内容以便最终处理 accumulatedContent += chunk; // 更新对话内容 setConversations(prev => { const modelConversation = [...prev[modelId]]; const lastIndex = modelConversation.length - 1; // 更新最后一条消息的内容,包括思维链 modelConversation[lastIndex] = { ...modelConversation[lastIndex], content: currentContent, thinking: currentThinking, showThinking: currentThinking.length > 0 // 只要有思维链内容就显示 }; return { ...prev, [modelId]: modelConversation }; }); } // 完成流式传输,移除流式标记 // 使用刚刚实时跟踪的 currentThinking 和 currentContent作为最终的思维链和内容 let finalThinking = currentThinking; let finalAnswer = currentContent; // 如果到流结束时还在思维链中,确保解析完整的思维链内容 if (isInThinking) { console.log('警告: 流结束时仍在思维链中,可能有标签不完整'); isInThinking = false; } setConversations(prev => { const modelConversation = [...prev[modelId]]; const lastIndex = modelConversation.length - 1; // 更新最后一条消息,移除流式标记 modelConversation[lastIndex] = { role: 'assistant', content: finalAnswer, thinking: finalThinking, showThinking: finalThinking ? true : false, isStreaming: false }; return { ...prev, [modelId]: modelConversation }; }); } else { // 普通输出处理 const response = await fetch(`/api/projects/${projectId}/playground/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: { ...model, extra_body: { enable_thinking: true } // 启用思考链 }, messages: requestMessages }) }); // 获取响应数据 const data = await response.json(); // 独立更新此模型的对话状态 setConversations(prev => { const modelConversation = [...(prev[modelId] || [])]; if (response.ok) { // 处理可能包含思考链的内容 let thinking = ''; let content = data.response; // 检查是否包含思考链 if (content && content.includes('')) { const thinkParts = content.split(/(.*?)<\/think>/s); if (thinkParts.length >= 3) { thinking = thinkParts[1] || ''; // 移除思考链部分,只保留最终回答 content = thinkParts.filter((_, i) => i % 2 === 0).join(''); } } return { ...prev, [modelId]: [ ...modelConversation, { role: 'assistant', content: content, thinking: thinking, showThinking: thinking ? true : false } ] }; } else { return { ...prev, [modelId]: [...modelConversation, { role: 'error', content: `错误: ${data.error || '请求失败'}` }] }; } }); } } catch (error) { console.error(`请求模型 ${model.name} 失败:`, error); // 独立更新此模型的对话状态 - 添加错误消息 setConversations(prev => { const modelConversation = [...(prev[modelId] || [])]; return { ...prev, [modelId]: [...modelConversation, { role: 'error', content: `错误: ${error.message}` }] }; }); } finally { // 更新此模型的加载状态 setLoading(prev => ({ ...prev, [modelId]: false })); } }); }; // 清空所有对话 const handleClearConversations = () => { const clearedConversations = {}; selectedModels.forEach(modelId => { clearedConversations[modelId] = []; }); setConversations(clearedConversations); setLoading({}); }; // 获取模型名称 const getModelName = modelId => { const model = availableModels.find(m => m.id === modelId); return model ? `${model.provider}: ${model.name}` : modelId; }; return { availableModels, selectedModels, loading, userInput, conversations, error, outputMode, uploadedImage, handleModelSelection, handleInputChange, handleImageUpload, handleRemoveImage, handleSendMessage, handleClearConversations, handleOutputModeChange, getModelName }; } ================================================ FILE: hooks/useSnackbar.js ================================================ 'use client'; import { useState, useCallback } from 'react'; import { Snackbar, Alert } from '@mui/material'; export const useSnackbar = () => { const [open, setOpen] = useState(false); const [message, setMessage] = useState(''); const [severity, setSeverity] = useState('info'); const showMessage = useCallback((newMessage, newSeverity = 'info') => { setMessage(newMessage); setSeverity(newSeverity); setOpen(true); }, []); const showSuccess = useCallback( message => { showMessage(message, 'success'); }, [showMessage] ); const showError = useCallback( message => { showMessage(message, 'error'); }, [showMessage] ); const showInfo = useCallback( message => { showMessage(message, 'info'); }, [showMessage] ); const showWarning = useCallback( message => { showMessage(message, 'warning'); }, [showMessage] ); const handleClose = useCallback(() => { setOpen(false); }, []); const SnackbarComponent = useCallback( () => ( {message} ), [open, message, severity, handleClose] ); return { showMessage, showSuccess, showError, showInfo, showWarning, SnackbarComponent }; }; ================================================ FILE: hooks/useTaskSettings.js ================================================ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { DEFAULT_SETTINGS } from '@/constant/setting'; export default function useTaskSettings(projectId) { const { t } = useTranslation(); const [taskSettings, setTaskSettings] = useState({ ...DEFAULT_SETTINGS }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); useEffect(() => { async function fetchTaskSettings() { try { setLoading(true); const response = await fetch(`/api/projects/${projectId}/tasks`); if (!response.ok) { throw new Error(t('settings.fetchTasksFailed')); } const data = await response.json(); // 如果没有配置,使用默认值 if (Object.keys(data).length === 0) { setTaskSettings({ ...DEFAULT_SETTINGS }); } else { // 确保所有默认值都被正确设置,特别是数字类型的字段 const mergedSettings = { ...DEFAULT_SETTINGS, ...data }; // 确保 multiTurnRounds 是数字类型 if (mergedSettings.multiTurnRounds !== undefined) { mergedSettings.multiTurnRounds = Number(mergedSettings.multiTurnRounds); } setTaskSettings(mergedSettings); } } catch (error) { console.error('获取任务配置出错:', error); setError(error.message); } finally { setLoading(false); } } fetchTaskSettings(); }, [projectId, t]); return { taskSettings, setTaskSettings, loading, error, success, setSuccess }; } ================================================ FILE: jsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./*"] } } } ================================================ FILE: lib/api/chunk.js ================================================ import { request } from '@/lib/util/request'; /** * 获取文本块 * @param {string} projectId 项目ID * @param {string} chunkId 文本块ID * @returns */ export async function getChunkById(projectId, chunkId) { return await request(`/api/projects/${projectId}/chunks/${chunkId}`); } ================================================ FILE: lib/api/file.js ================================================ import i18n from '@/lib/i18n'; import { request } from '@/lib/util/request'; /** * 上传文件 * @param {File} file 文件 * @param {string} projectId 项目ID * @param {string} fileContent 文件内容 * @param {string} fileName 文件名 * @param {function} t 国际化函数 * @returns */ export async function uploadFile({ file, projectId, fileContent, fileName, t }) { return await request(`/api/projects/${projectId}/files`, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'x-file-name': encodeURIComponent(fileName) }, body: file.name.endsWith('.docx') ? new TextEncoder().encode(fileContent) : fileContent, errMsg: t('textSplit.uploadFailed') }); } /** * 删除文件 * @param {Object} fileToDelete 文件信息 * @param {string} projectId 项目ID * @param {string} domainTreeActionType 域树处理方式 * @param {Object} modelInfo 模型信息 * @returns */ export async function deleteFile({ fileToDelete, projectId, domainTreeActionType, modelInfo }) { return await request( `/api/projects/${projectId}/files?fileId=${fileToDelete.fileId}&domainTreeAction=${domainTreeActionType || 'keep'}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelInfo, language: i18n.language === 'zh-CN' ? '中文' : 'en' }) } ); } /** * 获取文件列表 * @param {string} projectId 项目ID * @param {number} page 页码 * @param {number} size 每页大小 * @param {string} fileName 搜索文件名(可选) */ export async function getFiles({ projectId, page, size, fileName }) { const params = new URLSearchParams({ page: page.toString(), pageSize: size.toString() }); if (fileName && fileName.trim()) { params.append('fileName', fileName.trim()); } return await request(`/api/projects/${projectId}/files?${params}`); } ================================================ FILE: lib/api/index.js ================================================ export * as fileApi from './file'; export * as taskApi from './task'; export * as chunkApi from './chunk'; ================================================ FILE: lib/api/task.js ================================================ import { request } from '@/lib/util/request'; /** * 获取项目任务 */ export function getProjectTasks(projectId) { return request(`/api/projects/${projectId}/tasks`); } ================================================ FILE: lib/db/base.js ================================================ 'use server'; import fs from 'fs'; import path from 'path'; import os from 'os'; // 获取适合的数据存储目录 function getDbDirectory() { if (process.resourcesPath) { const rootPath = String(fs.readFileSync(path.join(process.resourcesPath, 'root-path.txt'))); if (rootPath) { return rootPath; } } // 检查是否在浏览器环境中运行 if (typeof window !== 'undefined') { // 检查是否在 Electron 渲染进程中运行 if (window.electron && window.electron.getUserDataPath) { // 使用 preload 脚本中暴露的 API 获取用户数据目录 const userDataPath = window.electron.getUserDataPath(); if (userDataPath) { return path.join(userDataPath, 'local-db'); } } // 如果不是 Electron 或获取失败,则使用开发环境的路径 return path.join(process.cwd(), 'local-db'); } else if (process.versions && process.versions.electron) { // 在 Electron 主进程中运行 try { const { app } = require('electron'); return path.join(app.getPath('userData'), 'local-db'); } catch (error) { console.error('Failed to get user data directory:', String(error), path.join(os.homedir(), '.easy-dataset-db')); // 降级处理,使用临时目录 return path.join(os.homedir(), '.easy-dataset-db'); } } else { // 在普通 Node.js 环境中运行(开发模式) return path.join(process.cwd(), 'local-db'); } } let PROJECT_ROOT = ''; // 获取项目根目录 export async function getProjectRoot() { if (!PROJECT_ROOT) { PROJECT_ROOT = getDbDirectory(); } return PROJECT_ROOT; } export async function getProjectPath(projectId) { const projectRoot = await getProjectRoot(); return path.join(projectRoot, projectId); } // 确保数据库目录存在 export async function ensureDbExists() { try { await fs.promises.access(PROJECT_ROOT); } catch (error) { await fs.promises.mkdir(PROJECT_ROOT, { recursive: true }); } } // 读取JSON文件 export async function readJsonFile(filePath) { try { await fs.promises.access(filePath); const data = await fs.promises.readFile(filePath, 'utf8'); return JSON.parse(data); } catch (error) { return null; } } // 写入JSON文件 export async function writeJsonFile(filePath, data) { // 使用临时文件策略,避免写入中断导致文件损坏 const tempFilePath = `${filePath}_${Date.now()}.tmp`; try { // 序列化为JSON字符串 const jsonString = JSON.stringify(data, null, 2); // 先写入临时文件 await fs.promises.writeFile(tempFilePath, jsonString, 'utf8'); // 从临时文件读取内容并验证 try { const writtenContent = await fs.promises.readFile(tempFilePath, 'utf8'); JSON.parse(writtenContent); // 验证JSON是否有效 // 验证通过后,原子性地重命名文件替换原文件 await fs.promises.rename(tempFilePath, filePath); } catch (validationError) { // 验证失败,删除临时文件并抛出错误 await fs.promises.unlink(tempFilePath).catch(() => {}); throw new Error(`写入的JSON文件内容无效: ${validationError.message}`); } return data; } catch (error) { console.error(`写入JSON文件 ${filePath} 失败:`, error); throw error; } finally { // 确保临时文件被删除 await fs.promises.unlink(tempFilePath).catch(() => {}); } } // 确保目录存在 export async function ensureDir(dirPath) { try { await fs.promises.access(dirPath); } catch (error) { await fs.promises.mkdir(dirPath, { recursive: true }); } } ================================================ FILE: lib/db/chunks.js ================================================ 'use server'; import { db } from '@/lib/db/index'; import { ensureDir, getProjectRoot } from '@/lib/db/base'; import path from 'path'; import fs from 'fs'; export async function saveChunks(chunks) { try { return await db.chunks.createMany({ data: chunks }); } catch (error) { console.error('Failed to create chunks in database'); throw error; } } export async function getChunkById(chunkId) { try { return await db.chunks.findUnique({ where: { id: chunkId } }); } catch (error) { console.error('Failed to get chunks by id in database'); throw error; } } export async function getChunksByFileIds(fileIds) { try { return await db.chunks.findMany({ where: { fileId: { in: fileIds } }, include: { Questions: { select: { question: true } } } }); } catch (error) { console.error('Failed to get chunks by id in database'); throw error; } } // 获取项目中所有文本片段的ID export async function getChunkByProjectId(projectId, filter) { try { const whereClause = { projectId, NOT: { name: { in: ['Image Chunk', 'Distilled Content'] } } }; if (filter === 'generated') { whereClause.Questions = { some: {} }; } else if (filter === 'ungenerated') { whereClause.Questions = { none: {} }; } return await db.chunks.findMany({ where: whereClause, include: { Questions: { select: { question: true } }, EvalDatasets: { select: { id: true } } } }); } catch (error) { console.error('Failed to get chunks by projectId in database'); throw error; } } export async function deleteChunkById(chunkId) { try { const delQuestions = db.questions.deleteMany({ where: { chunkId } }); const delChunk = db.chunks.delete({ where: { id: chunkId } }); return await db.$transaction([delQuestions, delChunk]); } catch (error) { console.error('Failed to delete chunks by id in database'); throw error; } } /** * 根据文本块名称获取文本块 * @param {string} projectId - 项目ID * @param {string} chunkName - 文本块名称 * @returns {Promise} - 查询结果 */ export async function getChunkByName(projectId, chunkName) { try { return await db.chunks.findFirst({ where: { projectId, name: chunkName } }); } catch (error) { console.error('根据名称获取文本块失败', error); throw error; } } /** * 批量根据文本块名称获取文本块内容 * @param {string} projectId - 项目ID * @param {string[]} chunkNames - 文本块名称数组 * @returns {Promise} - 以 chunkName 为 key,content 为 value 的对象 */ export async function getChunkContentsByNames(projectId, chunkNames) { try { if (!chunkNames || chunkNames.length === 0) { return {}; } const chunks = await db.chunks.findMany({ where: { projectId, name: { in: chunkNames } }, select: { name: true, content: true } }); // 转换为 name -> content 的映射 const contentMap = {}; chunks.forEach(chunk => { contentMap[chunk.name] = chunk.content; }); return contentMap; } catch (error) { console.error('批量获取文本块内容失败', error); throw error; } } /** * 根据文件ID删除所有相关文本块 * @param {string} projectId - 项目ID * @param {string} fileId - 文件ID * @returns {Promise} - 删除操作的结果 */ export async function deleteChunksByFileId(projectId, fileId) { try { // 查找与该文件相关的所有文本块 const chunks = await db.chunks.findMany({ where: { projectId, fileId }, select: { id: true } }); // 提取文本块ID const chunkIds = chunks.map(chunk => chunk.id); // 如果没有找到文本块,直接返回 if (chunkIds.length === 0) { return { count: 0 }; } // 删除相关的问题 const delQuestions = db.questions.deleteMany({ where: { chunkId: { in: chunkIds } } }); // 删除文本块 const delChunks = db.chunks.deleteMany({ where: { id: { in: chunkIds } } }); // 使用事务确保原子性操作 const result = await db.$transaction([delQuestions, delChunks]); return { count: result[1].count }; } catch (error) { console.error('删除文件相关文本块失败:', error); throw error; } } export async function updateChunkById(chunkId, chunkData) { try { return await db.chunks.update({ where: { id: chunkId }, data: chunkData }); } catch (error) { console.error('Failed to update chunks by id in database'); throw error; } } // 删除文件及相关TOC文件 // TODO 后期优化 将文件也新增表结构关联 防止删除错误 export async function deleteChunkAndFile(projectId, fileName) { try { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filesDir = path.join(projectPath, 'files'); const tocDir = path.join(projectPath, 'toc'); // 确保目录存在 await ensureDir(tocDir); // 删除原始文件 const filePath = path.join(filesDir, fileName); try { await fs.promises.access(filePath); await fs.promises.unlink(filePath); } catch (error) { console.error(`删除文件 ${fileName} 失败:`, error); // 如果文件不存在,继续处理 } // 删除相关的TOC文件 const baseName = path.basename(fileName, path.extname(fileName)); const tocPath = path.join(filesDir, `${baseName}-toc.json`); try { await fs.promises.access(tocPath); await fs.promises.unlink(tocPath); } catch (error) { // 如果TOC文件不存在,继续处理 } // TODO 暂不删除数据库中Chunk数据 如果删除 Question Dataset关联的Chunk数据是否也要删除? // return await db.chunks.deleteMany({ // where: { // name: { // startsWith: baseName + '-part-', // }, projectId // } // }); } catch (error) { console.error('Failed to delete chunks by id in database'); throw error; } } // 更新文本块内容 export async function updateChunkContent(chunkId, newContent) { try { return await db.chunks.update({ where: { id: chunkId }, data: { content: newContent, size: newContent.length } }); } catch (error) { console.error('Failed to update chunk content in database'); throw error; } } // 获取文本块列表(支持分页) export async function getChunks(projectId, page = 1, pageSize = 20) { try { const whereClause = { projectId, NOT: { name: { in: ['Image Chunk', 'Distilled Content'] } } }; const [data, total] = await Promise.all([ db.chunks.findMany({ where: whereClause, orderBy: { createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), db.chunks.count({ where: whereClause }) ]); return { data, total, page, pageSize }; } catch (error) { console.error('Failed to get chunks with pagination:', error); throw error; } } ================================================ FILE: lib/db/custom-prompts.js ================================================ 'use server'; import { db } from '@/lib/db/index'; /** * 获取项目的自定义提示词 * @param {string} projectId 项目ID * @param {string} promptType 提示词类型 (如: question, answer, label等) * @param {string} language 语言 (zh-CN, en) * @returns {Promise} 自定义提示词列表 */ export async function getCustomPrompts(projectId, promptType = null, language = null) { try { const where = { projectId, isActive: true }; if (promptType) { where.promptType = promptType; } if (language) { where.language = language; } return await db.customPrompts.findMany({ where, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get custom prompts:', error); throw error; } } /** * 获取特定的自定义提示词内容 * @param {string} projectId 项目ID * @param {string} promptType 提示词类型 * @param {string} promptKey 提示词键名 * @param {string} language 语言 * @returns {Promise} 自定义提示词对象或null */ export async function getCustomPrompt(projectId, promptType, promptKey, language) { try { return await db.customPrompts.findUnique({ where: { projectId_promptType_promptKey_language: { projectId, promptType, promptKey, language } } }); } catch (error) { console.error('Failed to get custom prompt:', error); return null; } } /** * 保存自定义提示词 * @param {string} projectId 项目ID * @param {string} promptType 提示词类型 * @param {string} promptKey 提示词键名 * @param {string} language 语言 * @param {string} content 提示词内容 * @returns {Promise} 保存后的提示词对象 */ export async function saveCustomPrompt(projectId, promptType, promptKey, language, content) { try { return await db.customPrompts.upsert({ where: { projectId_promptType_promptKey_language: { projectId, promptType, promptKey, language } }, update: { content, updateAt: new Date() }, create: { projectId, promptType, promptKey, language, content } }); } catch (error) { console.error('Failed to save custom prompt:', error); throw error; } } /** * 删除自定义提示词 * @param {string} projectId 项目ID * @param {string} promptType 提示词类型 * @param {string} promptKey 提示词键名 * @param {string} language 语言 * @returns {Promise} 删除成功返回true */ export async function deleteCustomPrompt(projectId, promptType, promptKey, language) { try { await db.customPrompts.delete({ where: { projectId_promptType_promptKey_language: { projectId, promptType, promptKey, language } } }); return true; } catch (error) { console.error('Failed to delete custom prompt:', error); return false; } } /** * 批量保存自定义提示词 * @param {string} projectId 项目ID * @param {Array} prompts 提示词数组 * @returns {Promise} 保存结果 */ export async function batchSaveCustomPrompts(projectId, prompts) { try { const results = []; for (const prompt of prompts) { const { promptType, promptKey, language, content } = prompt; const result = await saveCustomPrompt(projectId, promptType, promptKey, language, content); results.push(result); } return results; } catch (error) { console.error('Failed to batch save custom prompts:', error); throw error; } } /** * 启用/禁用自定义提示词 * @param {string} id 提示词ID * @param {boolean} isActive 是否启用 * @returns {Promise} 更新后的提示词对象 */ export async function toggleCustomPrompt(id, isActive) { try { return await db.customPrompts.update({ where: { id }, data: { isActive, updateAt: new Date() } }); } catch (error) { console.error('Failed to toggle custom prompt:', error); throw error; } } /** * 获取所有可用的提示词类型和键名信息 * @returns {Promise} 提示词配置信息 */ export async function getPromptTemplates() { // 重新组织的提示词分类配置 return { generation: { displayName: { 'zh-CN': '内容生成', en: 'Content Generation' }, prompts: { QUESTION_PROMPT: { name: '基础问题生成', description: '根据文本内容生成高质量问题的基础提示词,变量:{{text}} 待生成问题的文本,{{textLength}} 文本字数,{{number}} 目标问题数量,可选 {{gaPrompt}} 用于体裁受众增强', type: 'question' }, QUESTION_PROMPT_EN: { name: 'Basic Question Generation', description: 'Prompt for generating high-quality questions from text content in English. Variables: {{text}} source text, {{textLength}} text length, {{number}} question count, optional {{gaPrompt}} for GA enhancement', type: 'question' }, ANSWER_PROMPT: { name: '基础答案生成', description: '基于给定文本和问题生成准确答案的基础提示词,变量:{{text}} 参考文本,{{question}} 需要回答的问题,{{templatePrompt}} 问题模版提示词,{{outputFormatPrompt}} 问题模版自定义输出格式', type: 'answer' }, ANSWER_PROMPT_EN: { name: 'Basic Answer Generation', description: 'Prompt for generating accurate answers based on given text and questions in English. Variables: {{text}} reference text, {{question}} question to answer, {{templatePrompt}} question template prompt, {{outputFormatPrompt}} question template custom output format', type: 'answer' }, ENHANCED_ANSWER_PROMPT: { name: 'MGA增强答案生成', description: '结合体裁受众信息生成风格化答案的高级提示词,变量:{{text}} 参考文本,{{question}} 原始问题,可选 {{gaPrompt}} 表示体裁受众要求,{{templatePrompt}} 问题模版提示词,{{outputFormatPrompt}} 问题模版自定义输出格式', type: 'enhancedAnswer' }, ENHANCED_ANSWER_PROMPT_EN: { name: 'MGA Enhanced Answer Generation', description: 'Advanced prompt for generating stylized answers with GA information in English. Variables: {{text}} reference content, {{question}} original question, optional {{gaPrompt}} for GA adaptation, {{templatePrompt}} question template prompt, {{outputFormatPrompt}} question template custom output format', type: 'enhancedAnswer' }, GA_GENERATION_PROMPT: { name: 'GA组合生成', description: '根据文本内容自动生成体裁受众组合的提示词,变量:{{text}} 原始文本', type: 'ga-generation' }, GA_GENERATION_PROMPT_EN: { name: 'GA Pair Generation', description: 'Prompt for automatically generating GA pairs from text content in English. Variable: {{text}} source text', type: 'ga-generation' }, DISTILL_QUESTIONS_PROMPT: { name: '问题蒸馏生成', description: '基于特定标签领域生成多样化高质量问题的蒸馏提示词,变量:{{currentTag}} 当前标签,{{tagPath}} 标签完整链路,{{count}} 目标问题数,可选 {{existingQuestions}} 用于避免重复', type: 'distillQuestions' }, DISTILL_QUESTIONS_PROMPT_EN: { name: 'Question Distillation', description: 'Distillation prompt for generating questions for tag domains in English. Variables: {{currentTag}} current tag, {{tagPath}} tag path, {{count}} question count, optional {{existingQuestionsText}} for deduplication', type: 'distillQuestions' }, ASSISTANT_REPLY_PROMPT: { name: '多轮对话回复生成', description: '生成多轮对话中助手角色回复的提示词,变量:{{scenario}} 对话场景,{{roleA}} 提问者角色,{{roleB}} 回答者角色,{{chunkContent}} 原始文本,{{conversationHistory}} 对话历史,{{currentRound}} 当前轮次,{{totalRounds}} 总轮次', type: 'multiTurnConversation' }, ASSISTANT_REPLY_PROMPT_EN: { name: 'Multi-turn Conversation Reply Generation', description: 'Prompt for generating assistant role replies in multi-turn conversations. Variables: {{scenario}} conversation scenario, {{roleA}} questioner role, {{roleB}} responder role, {{chunkContent}} original text, {{conversationHistory}} conversation history, {{currentRound}} current round, {{totalRounds}} total rounds', type: 'multiTurnConversation' }, NEXT_QUESTION_PROMPT: { name: '多轮对话问题生成', description: '基于对话历史生成下一轮问题的提示词,变量:{{scenario}} 对话场景,{{roleA}} 提问者角色,{{roleB}} 回答者角色,{{chunkContent}} 原始文本,{{conversationHistory}} 对话历史,{{nextRound}} 下一轮次,{{totalRounds}} 总轮次', type: 'multiTurnConversation' }, NEXT_QUESTION_PROMPT_EN: { name: 'Multi-turn Conversation Question Generation', description: 'Prompt for generating next round questions based on conversation history. Variables: {{scenario}} conversation scenario, {{roleA}} questioner role, {{roleB}} responder role, {{chunkContent}} original text, {{conversationHistory}} conversation history, {{nextRound}} next round, {{totalRounds}} total rounds', type: 'multiTurnConversation' }, IMAGE_QUESTION_PROMPT: { name: '图像问题生成', description: '基于图像内容生成高质量问题的专业提示词,用于构建视觉问答训练数据集。变量:{{number}} 目标问题数量', type: 'imageQuestion' }, IMAGE_QUESTION_PROMPT_EN: { name: 'Image Question Generation', description: 'Professional prompt for generating high-quality questions based on image content for visual question-answering training datasets. Variables: {{number}} target question count', type: 'imageQuestion' }, IMAGE_ANSWER_PROMPT: { name: '图像答案生成', description: '基于图像内容回答问题并生成答案的提示词(用于生成图像问答数据集)。变量:{{question}} 题目内容,可选 {{templatePrompt}}/{{outputFormatPrompt}} 用于问题模版与输出格式', type: 'imageAnswer' }, IMAGE_ANSWER_PROMPT_EN: { name: 'Image Answer Generation', description: 'Prompt for answering questions based on image content (used for generating image QA datasets). Variables: {{question}} question, optional {{templatePrompt}}/{{outputFormatPrompt}} for template and output format', type: 'imageAnswer' } } }, labeling: { displayName: { 'zh-CN': '标签管理', en: 'Label Management' }, prompts: { LABEL_PROMPT: { name: '领域树生成', description: '根据文档目录结构自动生成领域分类标签树的提示词,变量:{{text}} 待分析目录文本', type: 'label' }, LABEL_PROMPT_EN: { name: 'Domain Tree Generation', description: 'Prompt for generating domain label tree from document structure in English. Variable: {{text}} catalog content', type: 'label' }, ADD_LABEL_PROMPT: { name: '问题标签匹配', description: '为生成的问题匹配最合适领域标签的智能匹配提示词,变量:{{label}} 标签数组,{{question}} 问题数组', type: 'addLabel' }, ADD_LABEL_PROMPT_EN: { name: 'Question Label Matching', description: 'Intelligent matching prompt for assigning domain labels to questions in English. Variables: {{label}} label list, {{question}} question list', type: 'addLabel' }, LABEL_REVISE_PROMPT: { name: '领域树修订', description: '在内容变化时对现有领域树进行增量修订的提示词,变量:{{existingTags}} 现有标签树,{{text}} 最新目录汇总,可选 {{deletedContent}}/{{newContent}} 表示删除或新增内容', type: 'labelRevise' }, LABEL_REVISE_PROMPT_EN: { name: 'Domain Tree Revision', description: 'Prompt for incrementally revising domain tree in English environment. Variables: {{existingTags}} current tag tree, {{text}} combined TOC, optional {{deletedContent}}/{{newContent}} blocks', type: 'labelRevise' }, DISTILL_TAGS_PROMPT: { name: '标签蒸馏生成', description: '基于现有标签体系生成更细粒度子标签的蒸馏提示词,变量:{{parentTag}} 当前父标签,{{path}}/{{tagPath}} 标签链路,{{count}} 子标签数量,可选 {{existingTagsText}} 表示已有子标签', type: 'distillTags' }, DISTILL_TAGS_PROMPT_EN: { name: 'Tag Distillation', description: 'Distillation prompt for generating sub-tags based on tag system in English. Variables: {{parentTag}} parent tag, {{path}}/{{tagPath}} hierarchy path, {{count}} target number, optional {{existingTagsText}} existing sub-tags', type: 'distillTags' } } }, optimization: { displayName: { 'zh-CN': '内容优化', en: 'Content Optimization' }, prompts: { NEW_ANSWER_PROMPT: { name: '答案优化重写', description: '根据用户反馈建议对答案进行优化重写的提示词,变量:{{chunkContent}} 原始文本块,{{question}} 原始问题,{{answer}} 待优化答案,{{cot}} 待优化思维链,{{advice}} 优化建议', type: 'newAnswer' }, NEW_ANSWER_PROMPT_EN: { name: 'Answer Optimization Rewrite', description: 'Prompt for optimizing and rewriting answers based on feedback in English. Variables: {{chunkContent}} original chunk, {{question}} question, {{answer}} answer, {{cot}} chain of thought, {{advice}} feedback', type: 'newAnswer' }, OPTIMIZE_COT_PROMPT: { name: '思维链优化', description: '优化答案中思维链推理过程和逻辑结构的提示词,变量:{{originalQuestion}} 原始问题,{{answer}} 答案,{{originalCot}} 原始思维链', type: 'optimizeCot' }, OPTIMIZE_COT_PROMPT_EN: { name: 'Chain-of-Thought Optimization', description: 'Prompt for optimizing chain-of-thought reasoning process in English. Variables: {{originalQuestion}} question, {{answer}} answer, {{originalCot}} original chain of thought', type: 'optimizeCot' } } }, processing: { displayName: { 'zh-CN': '数据处理', en: 'Data Processing' }, prompts: { DATA_CLEAN_PROMPT: { name: '文本数据清洗', description: '清理和标准化原始文本数据格式的提示词,变量:{{text}} 需清洗文本,{{textLength}} 文本字数', type: 'dataClean' }, DATA_CLEAN_PROMPT_EN: { name: 'Text Data Cleaning', description: 'Prompt for cleaning and standardizing text data in English environment. Variables: {{text}} text to clean, {{text.length}} length placeholder', type: 'dataClean' } } }, evaluation: { displayName: { 'zh-CN': '质量评估', en: 'Quality Evaluation' }, prompts: { DATASET_EVALUATION_PROMPT: { name: '数据集质量评估', description: '对问答数据集进行多维度质量评估的专业提示词,变量:{{chunkContent}} 原始文本块内容,{{question}} 问题,{{answer}} 答案', type: 'datasetEvaluation' }, DATASET_EVALUATION_PROMPT_EN: { name: 'Dataset Quality Evaluation', description: 'Professional prompt for multi-dimensional quality evaluation of Q&A datasets. Variables: {{chunkContent}} original text chunk, {{question}} question, {{answer}} answer', type: 'datasetEvaluation' } } }, modelEvaluation: { displayName: { 'zh-CN': '模型评估', en: 'Model Evaluation' }, prompts: { // 评估题目生成 EVAL_TRUE_FALSE_PROMPT: { name: '生成评估数据集(判断题)', description: '根据文本内容生成是否判断题用于评估模型,变量:{{text}} 待分析文本,{{textLength}} 文本字数,{{number}} 题目数量', type: 'evalQuestion' }, EVAL_TRUE_FALSE_PROMPT_EN: { name: 'True/False Question Generation', description: 'Generate true/false questions for model evaluation. Variables: {{text}} source text, {{textLength}} text length, {{number}} question count', type: 'evalQuestion' }, EVAL_SINGLE_CHOICE_PROMPT: { name: '生成评估数据集(单选题)', description: '根据文本内容生成单选题用于评估模型,变量:{{text}} 待分析文本,{{textLength}} 文本字数,{{number}} 题目数量', type: 'evalQuestion' }, EVAL_SINGLE_CHOICE_PROMPT_EN: { name: 'Single Choice Question Generation', description: 'Generate single-choice questions for model evaluation. Variables: {{text}} source text, {{textLength}} text length, {{number}} question count', type: 'evalQuestion' }, EVAL_MULTIPLE_CHOICE_PROMPT: { name: '生成评估数据集(多选题)', description: '根据文本内容生成多选题用于评估模型,变量:{{text}} 待分析文本,{{textLength}} 文本字数,{{number}} 题目数量', type: 'evalQuestion' }, EVAL_MULTIPLE_CHOICE_PROMPT_EN: { name: 'Multiple Choice Question Generation', description: 'Generate multiple-choice questions for model evaluation. Variables: {{text}} source text, {{textLength}} text length, {{number}} question count', type: 'evalQuestion' }, EVAL_SHORT_ANSWER_PROMPT: { name: '生成评估数据集(短答案)', description: '根据文本内容生成短答案题用于评估模型,变量:{{text}} 待分析文本,{{textLength}} 文本字数,{{number}} 题目数量', type: 'evalQuestion' }, EVAL_SHORT_ANSWER_PROMPT_EN: { name: 'Short Answer Question Generation', description: 'Generate short-answer questions for model evaluation. Variables: {{text}} source text, {{textLength}} text length, {{number}} question count', type: 'evalQuestion' }, EVAL_OPEN_ENDED_PROMPT: { name: '生成评估数据集(开放问题)', description: '根据文本内容生成开放式问题用于评估模型,变量:{{text}} 待分析文本,{{textLength}} 文本字数,{{number}} 题目数量', type: 'evalQuestion' }, EVAL_OPEN_ENDED_PROMPT_EN: { name: 'Open-ended Question Generation', description: 'Generate open-ended questions for model evaluation. Variables: {{text}} source text, {{textLength}} text length, {{number}} question count', type: 'evalQuestion' }, // 模型答题提示词 TRUE_FALSE_ANSWER_PROMPT: { name: '执行测评(判断题)', description: '模型回答是否判断题的提示词,变量:{{question}} 题目内容,输出格式:✅ 或 ❌', type: 'modelEvaluation' }, TRUE_FALSE_ANSWER_PROMPT_EN: { name: 'True/False Question Answering', description: 'Prompt for model to answer true/false questions. Variables: {{question}} question content, output format: ✅ or ❌', type: 'modelEvaluation' }, SINGLE_CHOICE_ANSWER_PROMPT: { name: '执行测评(单选题)', description: '模型回答单选题的提示词,变量:{{question}} 题目内容,{{options}} 选项列表,输出格式:选项字母(A/B/C/D)', type: 'modelEvaluation' }, SINGLE_CHOICE_ANSWER_PROMPT_EN: { name: 'Single Choice Question Answering', description: 'Prompt for model to answer single-choice questions. Variables: {{question}} question content, {{options}} options list, output format: option letter (A/B/C/D)', type: 'modelEvaluation' }, MULTIPLE_CHOICE_ANSWER_PROMPT: { name: '执行测评(多选题)', description: '模型回答多选题的提示词,变量:{{question}} 题目内容,{{options}} 选项列表,输出格式:JSON数组如["A", "C"]', type: 'modelEvaluation' }, MULTIPLE_CHOICE_ANSWER_PROMPT_EN: { name: 'Multiple Choice Question Answering', description: 'Prompt for model to answer multiple-choice questions. Variables: {{question}} question content, {{options}} options list, output format: JSON array like ["A", "C"]', type: 'modelEvaluation' }, SHORT_ANSWER_PROMPT: { name: '执行测评(短答案)', description: '模型回答短答案题的提示词,变量:{{question}} 题目内容,要求极短答案(词/短语/数字/单句)', type: 'modelEvaluation' }, SHORT_ANSWER_PROMPT_EN: { name: 'Short Answer Question Answering', description: 'Prompt for model to answer short-answer questions. Variables: {{question}} question content, requires ultra-short answer (word/phrase/number/sentence)', type: 'modelEvaluation' }, OPEN_ENDED_ANSWER_PROMPT: { name: '执行测评(开放问题)', description: '模型回答开放式问题的提示词,变量:{{question}} 题目内容,要求全面深入的分析论述', type: 'modelEvaluation' }, OPEN_ENDED_ANSWER_PROMPT_EN: { name: 'Open-ended Question Answering', description: 'Prompt for model to answer open-ended questions. Variables: {{question}} question content, requires comprehensive and in-depth analysis', type: 'modelEvaluation' }, // LLM评分提示词 SHORT_ANSWER_JUDGE_PROMPT: { name: 'LLM 短答案题评分', description: 'LLM评估短答案题回答质量的提示词,变量:{{question}} 题目,{{correctAnswer}} 参考答案,{{modelAnswer}} 学生答案,输出JSON格式包含score和reason', type: 'llmJudge' }, SHORT_ANSWER_JUDGE_PROMPT_EN: { name: 'Short Answer Grading', description: 'LLM prompt for grading short-answer quality. Variables: {{question}} question, {{correctAnswer}} reference answer, {{modelAnswer}} student answer, output JSON with score and reason', type: 'llmJudge' }, OPEN_ENDED_JUDGE_PROMPT: { name: 'LLM 开放问题评分', description: 'LLM评估开放式问题回答质量的提示词,变量:{{question}} 题目,{{correctAnswer}} 参考答案,{{modelAnswer}} 学生答案,输出JSON格式包含score和reason', type: 'llmJudge' }, OPEN_ENDED_JUDGE_PROMPT_EN: { name: 'Open-ended Question Grading', description: 'LLM prompt for grading open-ended question quality. Variables: {{question}} question, {{correctAnswer}} reference answer, {{modelAnswer}} student answer, output JSON with score and reason', type: 'llmJudge' } } } }; } ================================================ FILE: lib/db/dataset-conversations.js ================================================ /** * 多轮对话数据集数据库操作 */ import { db } from '@/lib/db/index'; function buildDatasetConversationWhere(projectId, filters = {}) { const where = { projectId }; if (filters.keyword) { where.OR = [ { question: { contains: filters.keyword } }, { tags: { contains: filters.keyword } }, { note: { contains: filters.keyword } } ]; } if (filters.roleA) { where.roleA = { contains: filters.roleA }; } if (filters.roleB) { where.roleB = { contains: filters.roleB }; } if (filters.scenario) { where.scenario = { contains: filters.scenario }; } if (filters.scoreMin !== undefined) { where.score = { ...where.score, gte: parseFloat(filters.scoreMin) }; } if (filters.scoreMax !== undefined) { where.score = { ...where.score, lte: parseFloat(filters.scoreMax) }; } if (filters.confirmed !== undefined) { if (typeof filters.confirmed === 'boolean') { where.confirmed = filters.confirmed; } else { where.confirmed = filters.confirmed === 'true'; } } return where; } /** * 创建多轮对话数据集 * @param {object} data - 对话数据集数据 * @returns {Promise} 创建的对话数据集 */ export async function createDatasetConversation(data) { return await db.datasetConversations.create({ data }); } /** * 根据ID获取多轮对话数据集 * @param {string} id - 对话数据集ID * @returns {Promise} 对话数据集或null */ export async function getDatasetConversationById(id) { return await db.datasetConversations.findUnique({ where: { id }, include: { project: true } }); } /** * 分页获取多轮对话数据集列表 * @param {string} projectId - 项目ID * @param {number} page - 页码 * @param {number} pageSize - 页大小 * @param {object} filters - 筛选条件 * @returns {Promise} 分页数据 */ export async function getDatasetConversationsByPagination(projectId, page = 1, pageSize = 20, filters = {}) { const skip = (page - 1) * pageSize; const where = buildDatasetConversationWhere(projectId, filters); const [data, total] = await Promise.all([ db.datasetConversations.findMany({ where, skip, take: pageSize, orderBy: { createAt: 'desc' }, select: { id: true, question: true, scenario: true, turnCount: true, maxTurns: true, model: true, score: true, confirmed: true, createAt: true } }), db.datasetConversations.count({ where }) ]); return { data, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; } /** * 更新多轮对话数据集 * @param {string} id - 对话数据集ID * @param {object} data - 更新数据 * @returns {Promise} 更新后的对话数据集 */ export async function updateDatasetConversation(id, data) { return await db.datasetConversations.update({ where: { id }, data: { ...data, updateAt: new Date() } }); } /** * 删除多轮对话数据集 * @param {string} id - 对话数据集ID * @returns {Promise} 删除的对话数据集 */ export async function deleteDatasetConversation(id) { return await db.datasetConversations.delete({ where: { id } }); } /** * 获取项目的多轮对话数据集统计信息 * @param {string} projectId - 项目ID * @returns {Promise} 统计信息 */ export async function getDatasetConversationStats(projectId) { const [total, confirmed, avgScore] = await Promise.all([ db.datasetConversations.count({ where: { projectId } }), db.datasetConversations.count({ where: { projectId, confirmed: true } }), db.datasetConversations.aggregate({ where: { projectId, score: { gt: 0 } }, _avg: { score: true } }) ]); return { total, confirmed, unconfirmed: total - confirmed, avgScore: avgScore._avg.score || 0 }; } /** * 获取所有多轮对话数据集(用于导出) * @param {string} projectId - 项目ID * @param {object} filters - 筛选条件 * @returns {Promise} 对话数据集列表 */ export async function getAllDatasetConversations(projectId, filters = {}) { const where = buildDatasetConversationWhere(projectId, filters); return await db.datasetConversations.findMany({ where, orderBy: { createAt: 'desc' } }); } /** * 获取符合筛选条件的全部对话 ID(用于批量操作) * @param {string} projectId * @param {object} filters * @returns {Promise} */ export async function getAllDatasetConversationIds(projectId, filters = {}) { const where = buildDatasetConversationWhere(projectId, filters); const rows = await db.datasetConversations.findMany({ where, select: { id: true }, orderBy: { createAt: 'desc' } }); return rows.map(item => String(item.id)); } /** * 根据问题ID查找相关的多轮对话数据集 * @param {string} questionId - 问题ID * @returns {Promise} 相关的对话数据集 */ export async function getDatasetConversationsByQuestionId(questionId) { return await db.datasetConversations.findMany({ where: { questionId }, orderBy: { createAt: 'desc' } }); } /** * 获取多轮对话的导航项(上一个/下一个对话) * @param {string} projectId - 项目ID * @param {string} conversationId - 当前对话ID * @param {string} operateType - 操作类型 ('prev' | 'next') * @returns {Promise} 导航项或null */ export async function getConversationNavigationItems(projectId, conversationId, operateType) { const currentItem = await db.datasetConversations.findUnique({ where: { id: conversationId } }); if (!currentItem) { throw new Error('Current conversation does not exist'); } if (operateType === 'prev') { return await db.datasetConversations.findFirst({ where: { createAt: { gt: currentItem.createAt }, projectId }, orderBy: { createAt: 'asc' } }); } else { return await db.datasetConversations.findFirst({ where: { createAt: { lt: currentItem.createAt }, projectId }, orderBy: { createAt: 'desc' } }); } } ================================================ FILE: lib/db/datasets.js ================================================ 'use server'; import { db } from '@/lib/db/index'; /** * 获取数据集列表(根据项目ID) * @param projectId 项目id * @param page * @param pageSize * @param confirmed * @param input * @param field 搜索字段,可选值:question, answer, cot, questionLabel * @param hasCot 思维链筛选,可选值:all, yes, no * @param isDistill 蒸馏数据集筛选,可选值:all, yes, no * @param chunkName 文本块名称筛选 */ export async function getDatasetsByPagination( projectId, page = 1, size = 10, confirmed = undefined, input = '', field = 'question', hasCot = 'all', isDistill = 'all', scoreRange = '', customTag = '', noteKeyword = '', chunkName = '' ) { try { // 根据搜索字段构建查询条件 const searchCondition = {}; if (input) { if (field === 'question') { searchCondition.question = { contains: input }; } else if (field === 'answer') { searchCondition.answer = { contains: input }; } else if (field === 'cot') { searchCondition.cot = { contains: input }; } else if (field === 'questionLabel') { searchCondition.questionLabel = { contains: input }; } } // 思维链筛选条件 const cotCondition = {}; if (hasCot === 'yes') { cotCondition.cot = { not: '' }; } else if (hasCot === 'no') { cotCondition.cot = ''; } // 蒸馏数据集筛选条件 const distillCondition = {}; if (isDistill === 'yes') { distillCondition.chunkName = 'Distilled Content'; } else if (isDistill === 'no') { distillCondition.chunkName = { not: 'Distilled Content' }; } // 评分范围筛选条件 const scoreCondition = {}; if (scoreRange) { const [minScore, maxScore] = scoreRange.split('-').map(Number); if (!isNaN(minScore) && !isNaN(maxScore)) { scoreCondition.score = { gte: minScore, lte: maxScore }; } } // 自定义标签筛选条件 const tagCondition = {}; if (customTag) { tagCondition.tags = { contains: customTag }; } // 备注筛选条件 const noteCondition = {}; if (noteKeyword) { noteCondition.note = { contains: noteKeyword }; } // 文本块名称筛选条件 const chunkNameCondition = {}; if (chunkName) { chunkNameCondition.chunkName = { contains: chunkName }; } const whereClause = { projectId, ...(confirmed !== undefined && { confirmed: confirmed }), ...searchCondition, ...cotCondition, ...distillCondition, ...scoreCondition, ...tagCondition, ...noteCondition, ...chunkNameCondition }; const [data, total, confirmedCount] = await Promise.all([ db.datasets.findMany({ where: whereClause, orderBy: { createAt: 'desc' }, skip: (page - 1) * size, take: size }), db.datasets.count({ where: whereClause }), db.datasets.count({ where: { ...whereClause, confirmed: true } }) ]); return { data, total, confirmedCount }; } catch (error) { console.error('Failed to get datasets by pagination in database'); throw error; } } export async function getDatasets(projectId, confirmed) { try { const whereClause = { projectId, ...(confirmed !== undefined && { confirmed: confirmed }) }; return await db.datasets.findMany({ where: whereClause, select: { question: true, answer: true, cot: true, questionLabel: true }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get datasets in database'); throw error; } } /** * 分批获取数据集(用于大数据量导出) * @param {string} projectId 项目ID * @param {boolean} confirmed 是否只获取确认的数据 * @param {number} offset 偏移量 * @param {number} batchSize 批次大小 * @returns {Promise} 数据集列表 */ export async function getDatasetsBatch(projectId, confirmed, offset = 0, batchSize = 1000) { try { const whereClause = { projectId, ...(confirmed !== undefined && { confirmed: confirmed }) }; return await db.datasets.findMany({ where: whereClause, select: { question: true, answer: true, cot: true, questionLabel: true, chunkName: true }, orderBy: { createAt: 'desc' }, skip: offset, take: batchSize }); } catch (error) { console.error('Failed to get datasets batch in database'); throw error; } } export async function getDatasetsIds( projectId, confirmed = undefined, input = '', field = 'question', hasCot = 'all', isDistill = 'all', scoreRange = '', customTag = '', noteKeyword = '', chunkName = '' ) { try { // 根据搜索字段构建查询条件 const searchCondition = {}; if (input) { if (field === 'question') { searchCondition.question = { contains: input }; } else if (field === 'answer') { searchCondition.answer = { contains: input }; } else if (field === 'cot') { searchCondition.cot = { contains: input }; } else if (field === 'questionLabel') { searchCondition.questionLabel = { contains: input }; } } // 思维链筛选条件 const cotCondition = {}; if (hasCot === 'yes') { cotCondition.cot = { not: null }; cotCondition.cot = { not: '' }; } else if (hasCot === 'no') { cotCondition.OR = [{ cot: null }, { cot: '' }]; } // 蒸馏数据集筛选条件 const distillCondition = {}; if (isDistill === 'yes') { distillCondition.chunkName = 'Distilled Content'; } else if (isDistill === 'no') { distillCondition.chunkName = { not: 'Distilled Content' }; } // 评分范围筛选条件 const scoreCondition = {}; if (scoreRange) { const [minScore, maxScore] = scoreRange.split('-').map(Number); if (!isNaN(minScore) && !isNaN(maxScore)) { scoreCondition.score = { gte: minScore, lte: maxScore }; } } // 自定义标签筛选条件 const tagCondition = {}; if (customTag) { tagCondition.tags = { contains: customTag }; } // 备注筛选条件 const noteCondition = {}; if (noteKeyword) { noteCondition.note = { contains: noteKeyword }; } // 文本块名称筛选条件 const chunkNameCondition = {}; if (chunkName) { chunkNameCondition.chunkName = { contains: chunkName }; } const whereClause = { projectId, ...(confirmed !== undefined && { confirmed: confirmed }), ...searchCondition, ...cotCondition, ...distillCondition, ...scoreCondition, ...tagCondition, ...noteCondition, ...chunkNameCondition }; return await db.datasets.findMany({ where: whereClause, select: { id: true }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get datasets ids in database'); throw error; } } /** * 获取数据集数量(根据项目ID) * @param projectId 项目id */ export async function getDatasetsCount(projectId) { try { return await db.datasets.count({ where: { projectId } }); } catch (error) { console.error('Failed to get datasets count by projectId in database'); throw error; } } /** * 获取数据集数量(根据问题Id) * @param questionId 问题Id */ export async function getDatasetsCountByQuestionId(questionId) { try { return await db.datasets.count({ where: { questionId } }); } catch (error) { console.error('Failed to get datasets count by projectId in database'); throw error; } } /** * 获取数据集详情 * @param id 数据集id */ export async function getDatasetsById(id) { try { return await db.datasets.findUnique({ where: { id } }); } catch (error) { console.error('Failed to get datasets by id in database'); throw error; } } /** * 更新数据集的评分、标签、备注 * @param {string} id 数据集ID * @param {number} score 评分 (0-5) * @param {Array} tags 标签数组 * @param {string} note 备注 */ export async function updateDatasetMetadata(id, { score, tags, note }) { try { const updateData = {}; if (score !== undefined) { updateData.score = score; } if (tags !== undefined) { updateData.tags = JSON.stringify(tags); } if (note !== undefined) { updateData.note = note; } return await db.datasets.update({ where: { id }, data: updateData }); } catch (error) { console.error('Failed to update dataset metadata in database'); throw error; } } /** * 获取项目中所有使用过的自定义标签 * @param {string} projectId 项目ID */ export async function getUsedCustomTags(projectId) { try { const datasets = await db.datasets.findMany({ where: { projectId, tags: { not: '' } }, select: { tags: true } }); const allTags = new Set(); datasets.forEach(dataset => { try { const tags = JSON.parse(dataset.tags || '[]'); tags.forEach(tag => allTags.add(tag)); } catch (e) { // 忽略解析错误 } }); return Array.from(allTags).sort(); } catch (error) { console.error('Failed to get used custom tags in database'); throw error; } } /** * 保存数据集列表 * @param dataset */ export async function createDataset(dataset) { try { return await db.datasets.create({ data: dataset }); } catch (error) { console.error('Failed to save datasets in database'); throw error; } } export async function updateDataset(dataset) { try { return await db.datasets.update({ data: dataset, where: { id: dataset.id } }); } catch (error) { console.error('Failed to update datasets in database'); throw error; } } export async function deleteDataset(datasetId) { try { // 先获取要删除的数据集信息,以获取 questionId const dataset = await db.datasets.findUnique({ where: { id: datasetId } }); if (!dataset) { throw new Error('Dataset not found'); } // 删除数据集 const deletedDataset = await db.datasets.delete({ where: { id: datasetId } }); // 检查该问题是否还有其他数据集 const remainingDatasets = await db.datasets.count({ where: { questionId: dataset.questionId } }); // 如果没有其他数据集,将问题的 answered 状态改为 false // 但需要先检查问题是否存在(本地导入的数据集可能使用随机ID,对应的问题可能已被删除) if (remainingDatasets === 0) { const question = await db.questions.findUnique({ where: { id: dataset.questionId } }); if (question) { await db.questions.update({ where: { id: dataset.questionId }, data: { answered: false } }); } } return deletedDataset; } catch (error) { console.error('Failed to delete datasets in database'); throw error; } } /** * 更新数据集的AI评估结果 * @param {string} datasetId 数据集ID * @param {number} score 评估分数 (0-5) * @param {string} aiEvaluation AI评估结论 */ export async function updateDatasetEvaluation(datasetId, score, aiEvaluation) { try { return await db.datasets.update({ where: { id: datasetId }, data: { score, aiEvaluation, updateAt: new Date() } }); } catch (error) { console.error('Failed to update dataset evaluation in database'); throw error; } } export async function getDatasetsCounts(projectId) { try { const [total, confirmedCount] = await Promise.all([ db.datasets.count({ where: { projectId } }), db.datasets.count({ where: { projectId, confirmed: true } }) ]); return { total, confirmedCount }; } catch (error) { console.error('Failed to delete datasets in database'); throw error; } } export async function getNavigationItems(projectId, datasetId, operateType) { const currentItem = await db.datasets.findUnique({ where: { id: datasetId } }); if (!currentItem) { throw new Error('Current record does not exist'); } if (operateType === 'prev') { return await db.datasets.findFirst({ where: { createAt: { gt: currentItem.createAt }, projectId }, orderBy: { createAt: 'asc' } }); } else { return await db.datasets.findFirst({ where: { createAt: { lt: currentItem.createAt }, projectId }, orderBy: { createAt: 'desc' } }); } } /** * 获取按标签平衡的数据集 * @param {string} projectId 项目ID * @param {Array} balanceConfig 平衡配置 [{tagLabel, maxCount}] * @param {boolean} confirmed 是否只获取确认的数据 * @returns {Promise} 平衡后的数据集列表 */ export async function getBalancedDatasetsByTags(projectId, balanceConfig, confirmed) { try { const results = []; for (const config of balanceConfig) { const { tagLabel, maxCount } = config; // 获取该标签下的数据集 const tagDatasets = await db.datasets.findMany({ where: { projectId, questionLabel: tagLabel, ...(confirmed !== undefined && { confirmed: confirmed }) }, select: { question: true, answer: true, cot: true, questionLabel: true, chunkName: true }, orderBy: { createAt: 'desc' }, take: maxCount // 限制数量 }); results.push(...tagDatasets); } return results; } catch (error) { console.error('Failed to get balanced datasets by tags in database'); throw error; } } /** * 分批获取按标签平衡的数据集(用于大数据量导出) * @param {string} projectId 项目ID * @param {Array} balanceConfig 平衡配置 [{tagLabel, maxCount}] * @param {boolean} confirmed 是否只获取确认的数据 * @param {number} offset 偏移量 * @param {number} batchSize 批次大小 * @returns {Promise<{data: Array, hasMore: boolean}>} 分批数据和是否还有更多数据 */ export async function getBalancedDatasetsByTagsBatch( projectId, balanceConfig, confirmed, offset = 0, batchSize = 1000 ) { try { // 首先获取所有符合条件的数据集ID(用于分页) const allResults = []; for (const config of balanceConfig) { const { tagLabel, maxCount } = config; // 规范化 maxCount,防止传入字符串或非法值导致引擎异常 const count = Number.isFinite(maxCount) ? maxCount : parseInt(maxCount) || 0; if (count <= 0) continue; // 获取该标签下的数据集ID const tagDatasets = await db.datasets.findMany({ where: { projectId, questionLabel: tagLabel, ...(confirmed !== undefined && { confirmed: confirmed }) }, select: { id: true, createAt: true }, orderBy: { createAt: 'desc' }, take: count }); allResults.push(...tagDatasets); } // 按创建时间排序 allResults.sort((a, b) => new Date(b.createAt) - new Date(a.createAt)); // 分页获取当前批次的ID const batchIds = allResults.slice(offset, offset + batchSize).map(item => item.id); // 如果当前批次没有ID,直接返回空结果,避免 Prisma 在 in: [] 时可能出现的引擎异常 if (!batchIds.length) { return { data: [], hasMore: false }; } // 根据ID获取完整数据 const batchData = await db.datasets.findMany({ where: { projectId, id: { in: batchIds } }, select: { question: true, answer: true, cot: true, questionLabel: true, chunkName: true } // 不再额外排序,避免引擎在某些组合条件下出现异常 }); const hasMore = offset + batchSize < allResults.length; return { data: batchData, hasMore }; } catch (error) { console.error('Failed to get balanced datasets by tags batch in database'); throw error; } } /** * 获取标签的统计信息(包含数据集数量) * @param {string} projectId 项目ID * @param {boolean} confirmed 是否只统计确认的数据 * @returns {Promise} 标签统计信息 */ export async function getTagsWithDatasetCounts(projectId, confirmed) { try { // 获取所有标签的数据集统计 const tagCounts = await db.datasets.groupBy({ by: ['questionLabel'], where: { projectId, ...(confirmed !== undefined && { confirmed: confirmed }) }, _count: { id: true } }); // 转换为更友好的格式 return tagCounts.map(item => ({ tagLabel: item.questionLabel, datasetCount: item._count.id })); } catch (error) { console.error('Failed to get tags with dataset counts in database'); throw error; } } /** * 根据数据集 ID 列表获取数据集 * @param {string} projectId 项目 ID * @param {Array} datasetIds 数据集 ID 列表 * @returns {Promise} 数据集列表 */ export async function getDatasetsByIds(projectId, datasetIds) { try { if (!datasetIds || datasetIds.length === 0) { return []; } return await db.datasets.findMany({ where: { projectId, id: { in: datasetIds } }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get datasets by ids in database'); throw error; } } /** * 根据数据集 ID 列表分批获取数据集 * @param {string} projectId 项目 ID * @param {Array} datasetIds 数据集 ID 列表 * @param {number} offset 偏移量 * @param {number} batchSize 批次大小 * @returns {Promise} 批次数据集列表 */ export async function getDatasetsByIdsBatch(projectId, datasetIds, offset = 0, batchSize = 1000) { try { if (!datasetIds || datasetIds.length === 0) { return []; } // 分批获取数据,例如从 offset 开始取 batchSize 条 const batchIds = datasetIds.slice(offset, offset + batchSize); return await db.datasets.findMany({ where: { projectId, id: { in: batchIds } }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get datasets by ids batch in database'); throw error; } } ================================================ FILE: lib/db/evalDatasets.js ================================================ import { db } from '@/lib/db/index'; /** * 创建单个测评题目 * @param {Object} data - 题目数据 * @returns {Promise} - 创建的题目 */ export async function createEvalQuestion(data) { try { return await db.evalDatasets.create({ data }); } catch (error) { console.error('Failed to create eval question:', error); throw error; } } /** * 批量创建测评题目 * @param {Array} dataArray - 题目数据数组 * @returns {Promise} - 创建结果 */ export async function createManyEvalQuestions(dataArray) { try { return await db.evalDatasets.createMany({ data: dataArray }); } catch (error) { console.error('Failed to create many eval questions:', error); throw error; } } /** * 获取项目的所有测评题目(简单查询) * @param {string} projectId - 项目ID * @returns {Promise} - 测评题目数组 */ export async function getEvalQuestions(projectId) { try { return await db.evalDatasets.findMany({ where: { projectId }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get eval questions from database:', error); throw error; } } /** * 分页获取项目的测评题目 * @param {string} projectId - 项目ID * @param {Object} options - 查询选项 * @param {number} options.page - 页码 * @param {number} options.pageSize - 每页数量 * @param {string} options.questionType - 题型筛选 * @param {string} options.keyword - 关键词搜索 * @param {string} options.chunkId - 文本块ID筛选 * @param {string|string[]} options.tags - 标签筛选(支持多选) * @returns {Promise} - 分页结果 */ export function buildEvalQuestionWhere(projectId, { questionType, questionTypes, keyword, chunkId, tags } = {}) { const where = { projectId }; const types = Array.isArray(questionTypes) && questionTypes.length > 0 ? questionTypes : questionType ? [questionType] : []; if (types.length === 1) { where.questionType = types[0]; } else if (types.length > 1) { where.questionType = { in: types }; } if (chunkId) { where.chunkId = chunkId; } if (tags) { if (Array.isArray(tags) && tags.length > 0) { where.OR = tags.map(tag => ({ tags: { contains: tag } })); } else if (typeof tags === 'string' && tags.trim()) { const tagList = tags.split(',').filter(t => t.trim()); if (tagList.length > 0) { where.OR = tagList.map(tag => ({ tags: { contains: tag.trim() } })); } } } if (keyword) { where.question = { contains: keyword }; } return where; } export async function getEvalQuestionsWithPagination(projectId, options = {}) { try { const { page = 1, pageSize = 20, questionType, questionTypes, keyword, chunkId, tags } = options; const skip = (page - 1) * pageSize; // 构建查询条件 const where = buildEvalQuestionWhere(projectId, { questionType, questionTypes, keyword, chunkId, tags }); // 并行查询数据和总数 const [items, total] = await Promise.all([ db.evalDatasets.findMany({ where, skip, take: pageSize, orderBy: { createAt: 'desc' }, include: { chunks: { select: { id: true, name: true, fileName: true } } } }), db.evalDatasets.count({ where }) ]); return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; } catch (error) { console.error('Failed to get eval questions with pagination:', error); throw error; } } /** * 获取单个测评题目详情 * @param {string} id - 题目ID * @returns {Promise} - 题目详情 */ export async function getEvalQuestionById(id) { try { return await db.evalDatasets.findUnique({ where: { id }, include: { chunks: { select: { id: true, name: true, fileName: true, content: true } } } }); } catch (error) { console.error('Failed to get eval question by ID:', error); throw error; } } /** * 更新测评题目 * @param {string} id - 题目ID * @param {Object} data - 更新数据 * @returns {Promise} - 更新后的题目 */ export async function updateEvalQuestion(id, data) { try { return await db.evalDatasets.update({ where: { id }, data }); } catch (error) { console.error('Failed to update eval question:', error); throw error; } } /** * 获取项目测评题目统计 * @param {string} projectId - 项目ID * @returns {Promise} - 统计数据 */ export async function getEvalQuestionsStats(projectId) { try { const [total, byType, allTags] = await Promise.all([ db.evalDatasets.count({ where: { projectId } }), db.evalDatasets.groupBy({ by: ['questionType'], where: { projectId }, _count: { id: true } }), db.evalDatasets.findMany({ where: { projectId }, select: { tags: true } }) ]); const typeStats = {}; byType.forEach(item => { typeStats[item.questionType] = item._count.id; }); // 统计标签 const tagStats = {}; allTags.forEach(item => { if (item.tags) { // 支持中英文逗号分隔 const tags = item.tags .split(/[,,]/) .map(t => t.trim()) .filter(Boolean); tags.forEach(tag => { tagStats[tag] = (tagStats[tag] || 0) + 1; }); } }); return { total, byType: typeStats, byTag: tagStats }; } catch (error) { console.error('Failed to get eval questions stats:', error); throw error; } } /** * 根据文本块ID获取测评题目 * @param {string} chunkId - 文本块ID * @returns {Promise} - 测评题目数组 */ export async function getEvalQuestionsByChunkId(chunkId) { try { return await db.evalDatasets.findMany({ where: { chunkId }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get eval questions by chunk ID:', error); throw error; } } /** * 删除测评题目 * @param {string} id - 题目ID * @returns {Promise} - 删除的题目 */ export async function deleteEvalQuestion(id) { try { return await db.evalDatasets.delete({ where: { id } }); } catch (error) { console.error('Failed to delete eval question:', error); throw error; } } ================================================ FILE: lib/db/evalResults.js ================================================ 'use server'; import { db } from '@/lib/db/index'; /** * 创建评估结果 * @param {Object} data - 评估结果数据 * @returns {Promise} - 创建的评估结果 */ export async function createEvalResult(data) { try { return await db.evalResults.create({ data }); } catch (error) { console.error('Failed to create eval result:', error); throw error; } } /** * 批量创建评估结果 * @param {Array} dataArray - 评估结果数据数组 * @returns {Promise} - 创建结果 */ export async function createManyEvalResults(dataArray) { try { return await db.evalResults.createMany({ data: dataArray }); } catch (error) { console.error('Failed to create many eval results:', error); throw error; } } /** * 获取任务的所有评估结果(支持分页和筛选) * @param {string} taskId - 任务ID * @param {Object} options - 查询选项 { page, pageSize, type, isCorrect } * @returns {Promise} - { items: [], total: 0 } */ export async function getEvalResultsByTaskId(taskId, { page = 1, pageSize = 10, type = null, isCorrect = null } = {}) { try { const where = { taskId }; // 如果指定了题型,添加到查询条件 if (type) { where.evalDataset = { questionType: type }; } // 如果指定了正确性筛选 if (isCorrect !== null) { where.isCorrect = isCorrect; } const [items, total] = await Promise.all([ db.evalResults.findMany({ where, include: { evalDataset: { select: { id: true, question: true, questionType: true, options: true, correctAnswer: true, tags: true } } }, orderBy: { createAt: 'asc' }, // 按创建时间正序,即题目顺序 skip: (page - 1) * pageSize, take: pageSize }), db.evalResults.count({ where }) ]); return { items, total }; } catch (error) { console.error('Failed to get eval results by task ID:', error); throw error; } } /** * 获取项目的所有评估结果 * @param {string} projectId - 项目ID * @returns {Promise} - 评估结果数组 */ export async function getEvalResultsByProjectId(projectId) { try { return await db.evalResults.findMany({ where: { projectId }, include: { evalDataset: { select: { id: true, question: true, questionType: true, correctAnswer: true } } }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get eval results by project ID:', error); throw error; } } /** * 更新评估结果 * @param {string} id - 评估结果ID * @param {Object} data - 更新数据 * @returns {Promise} - 更新后的评估结果 */ export async function updateEvalResult(id, data) { try { return await db.evalResults.update({ where: { id }, data }); } catch (error) { console.error('Failed to update eval result:', error); throw error; } } /** * 批量更新评估结果(通过 taskId) * @param {string} taskId - 任务ID * @param {Object} data - 更新数据 * @returns {Promise} - 更新结果 */ export async function updateEvalResultsByTaskId(taskId, data) { try { return await db.evalResults.updateMany({ where: { taskId }, data }); } catch (error) { console.error('Failed to update eval results by task ID:', error); throw error; } } /** * 删除任务的所有评估结果 * @param {string} taskId - 任务ID * @returns {Promise} - 删除结果 */ export async function deleteEvalResultsByTaskId(taskId) { try { return await db.evalResults.deleteMany({ where: { taskId } }); } catch (error) { console.error('Failed to delete eval results by task ID:', error); throw error; } } /** * 获取任务评估统计 * @param {string} taskId - 任务ID * @returns {Promise} - 统计数据 */ export async function getEvalResultsStats(taskId) { try { const results = await db.evalResults.findMany({ where: { taskId }, select: { score: true, isCorrect: true, evalDataset: { select: { questionType: true } } } }); const totalQuestions = results.length; const totalScore = results.reduce((sum, r) => sum + r.score, 0); const correctCount = results.filter(r => r.isCorrect).length; // 按题型统计 const byType = {}; results.forEach(r => { const type = r.evalDataset.questionType; if (!byType[type]) { byType[type] = { total: 0, score: 0, correct: 0 }; } byType[type].total++; byType[type].score += r.score; if (r.isCorrect) byType[type].correct++; }); return { totalQuestions, totalScore, correctCount, scorePercentage: totalQuestions > 0 ? (totalScore / totalQuestions) * 100 : 0, accuracyPercentage: totalQuestions > 0 ? (correctCount / totalQuestions) * 100 : 0, byType }; } catch (error) { console.error('Failed to get eval results stats:', error); throw error; } } /** * 检查评估结果是否存在 * @param {string} taskId - 任务ID * @param {string} evalDatasetId - 评估题目ID * @returns {Promise} - 评估结果或 null */ export async function getEvalResult(taskId, evalDatasetId) { try { return await db.evalResults.findUnique({ where: { taskId_evalDatasetId: { taskId, evalDatasetId } } }); } catch (error) { console.error('Failed to get eval result:', error); throw error; } } /** * 创建或更新评估结果(upsert) * @param {string} taskId - 任务ID * @param {string} evalDatasetId - 评估题目ID * @param {Object} data - 评估结果数据 * @returns {Promise} - 评估结果 */ export async function upsertEvalResult(taskId, evalDatasetId, data) { try { return await db.evalResults.upsert({ where: { taskId_evalDatasetId: { taskId, evalDatasetId } }, update: data, create: { ...data, taskId, evalDatasetId } }); } catch (error) { console.error('Failed to upsert eval result:', error); throw error; } } ================================================ FILE: lib/db/fileToDb.js ================================================ import { getProjectRoot, readJsonFile } from '@/lib/db/base'; import fs from 'fs'; import path from 'path'; import { db } from '@/lib/db/index'; import { DEFAULT_MODEL_SETTINGS } from '@/constant/model'; import { getFileMD5 } from '@/lib/util/file'; /** * 执行迁移操作 * @param {Object} task 任务对象,用于更新进度 * @returns {Promise} 迁移项目数量 */ export async function main(task = null) { const projectRoot = await getProjectRoot(); let count = 0; const files = await fs.promises.readdir(projectRoot, { withFileTypes: true }); // 筛选出未迁移的项目 const unmigratedProjects = []; for (const file of files) { if (!file.isDirectory()) continue; const project = await db.projects.findUnique({ where: { id: file.name } }); if (!project) { unmigratedProjects.push({ file, projectPath: path.join(projectRoot, file.name) }); } } // 如果有任务对象,初始化任务信息 if (task) { task.total = unmigratedProjects.length; task.completed = 0; task.errors = []; if (task.total === 0) { task.progress = 100; task.status = 'completed'; return 0; } } // 使用互斥锁来安全地更新计数器 let completedCount = 0; // 创建并行任务,保留原有的 Promise.all 高效并行处理 const promises = unmigratedProjects.map(async ({ file, projectPath }) => { try { const projectId = file.name; const project = await db.projects.findUnique({ where: { id: projectId } }); if (!project) { // 再次检查项目是否已迁移 let projectDB = await projectHandle(projectId, projectRoot, projectPath); if (projectDB) { await chunkHandle(projectId, projectPath); await tagsHandle(projectId, projectPath); await modelConfigHandle(projectId, projectPath); await questionHandle(projectId, projectPath); await datasetHandle(projectId, projectPath); await updateQuestions(projectId); // 原子操作增加完成数量 completedCount++; // 更新进度 if (task) { task.completed = completedCount; task.progress = Math.floor((completedCount / task.total) * 100); } return 1; // 返回1表示迁移了一个项目 } } return 0; } catch (error) { console.error(`处理项目出错:${projectPath}`, error); if (task) { task.errors.push({ projectId: file.name, error: error.message }); } return 0; // 出错时返回0 } }); // 等待所有并行任务完成 const results = await Promise.all(promises); // 统计总完成数量 count = results.reduce((total, curr) => total + curr, 0); // 确保最终进度为100% if (task && count > 0) { task.progress = 100; } return count; } //备份文件 async function backupHandle(projectRoot, projectPath) { const projectName = path.basename(projectPath); const newProjectName = projectName + '-backup'; const newProjectPath = path.join(path.dirname(projectPath), newProjectName); try { await fs.promises.rename(projectPath, newProjectPath); console.log(`File renamed from ${projectPath} to ${newProjectPath}`); } catch (error) { console.error(`Failed to rename file from ${projectPath} to ${newProjectPath}`, error); } } //项目文件数据处理 async function projectHandle(projectId, projectRoot, projectPath) { try { const configPath = path.join(projectPath, 'config.json'); let projectData = await readJsonFile(configPath); if (!projectData) return null; let project = await db.projects.create({ data: { id: projectId, name: projectData.name, description: projectData.description } }); if (projectData.uploadedFiles && projectData.uploadedFiles.length > 0) { let projectId = project.id; const newProjectPath = path.join(projectRoot, projectId, 'files'); let uploadFileList = []; const filesPath = path.join(projectPath, 'files'); for (const fileName of projectData.uploadedFiles) { const fPath = path.join(filesPath, fileName); //获取文件大小 const stats = await fs.promises.stat(fPath); //获取文件md5 const md5 = await getFileMD5(fPath); //获取文件扩展名 const ext = path.extname(fPath); let data = { projectId, fileName, size: stats.size, md5, fileExt: ext, path: newProjectPath }; uploadFileList.push(data); } await db.uploadFiles.createMany({ data: uploadFileList }); } return project; } catch (error) { console.error('Error project insert db:', error); throw error; } } //同步其他配置文件 async function syncOtherConfigFile(projectRoot, projectPath, projectNewId) { if (fs.existsSync(projectPath)) { const newProjectPath = path.join(projectRoot, projectNewId); try { await copyDirRecursive(projectPath, newProjectPath, projectNewId); console.log(`sync config at: ${newProjectPath}`); } catch (error) { console.error(`Failed to sync config at: ${newProjectPath}`, error); } } else { console.error(`Project not found at path: ${projectPath}`); } } async function tagsHandle(projectId, projectPath) { const tagsPath = path.join(projectPath, 'tags.json'); try { if (!fs.existsSync(tagsPath)) { return; } const tagsData = await readJsonFile(tagsPath); if (tagsData.length === 0) return; await insertTags(projectId, tagsData); } catch (error) { console.error('Error tags.json insert db:', error); throw error; } } async function insertTags(projectId, tags, parentId = null) { for (const tag of tags) { // 插入当前节点 const createdTag = await db.tags.create({ data: { projectId, label: tag.label, parentId: parentId } }); // 如果有子节点,递归插入 if (tag.child && tag.child.length > 0) { await insertTags(projectId, tag.child, createdTag.id); } } } async function modelConfigHandle(projectId, projectPath) { const modelConfigPath = path.join(projectPath, 'model-config.json'); try { if (!fs.existsSync(modelConfigPath)) { return; } const modelConfigData = await readJsonFile(modelConfigPath); if (modelConfigData.length === 0) return; let modelConfigList = []; for (const modelConfig of modelConfigData) { if (!modelConfig.name) continue; modelConfigList.push({ projectId, providerId: modelConfig.providerId, providerName: modelConfig.provider, endpoint: modelConfig.endpoint, apiKey: modelConfig.apiKey, modelId: modelConfig.name, modelName: modelConfig.name, type: modelConfig.type ? modelConfig.type : 'text', maxTokens: modelConfig.maxTokens ? modelConfig.maxTokens : DEFAULT_MODEL_SETTINGS.maxTokens, temperature: modelConfig.temperature ? modelConfig.temperature : DEFAULT_MODEL_SETTINGS.temperature, topK: 0, topP: 0, status: 1 }); } return await db.modelConfig.createMany({ data: modelConfigList }); } catch (error) { console.error('Error model-config.json insert db:', error); throw error; } } //chunk文件数据处理 async function chunkHandle(projectId, projectPath) { try { const filesPath = path.join(projectPath, 'files'); const fileList = await safeReadDir(filesPath); let chunkList = []; for (const fileName of fileList) { const baseName = path.basename(fileName, path.extname(fileName)); let file = await db.uploadFiles.findFirst({ where: { fileName, projectId } }); const chunksPath = path.join(projectPath, 'chunks'); const chunks = await safeReadDir(chunksPath, { withFileTypes: true }); for (const chunk of chunks) { if (chunk.name.startsWith(baseName + '-part-')) { const content = await fs.promises.readFile(path.join(chunksPath, chunk.name), 'utf8'); chunkList.push({ name: path.basename(chunk.name, path.extname(chunk.name)), projectId, fileId: file !== null ? file.id : '', fileName, content, // TODO summary 暂时使用 content summary: content, size: content.length }); } } } return await db.chunks.createMany({ data: chunkList }); } catch (error) { console.error('Error chunk insert db:', error); throw error; } } //问题文件处理 async function questionHandle(projectId, projectPath) { const questionsPath = path.join(projectPath, 'questions.json'); let questionList = []; try { const questionsData = await readJsonFile(questionsPath); if (questionsData.length === 0) return; for (const question of questionsData) { // 确保 chunk 已存在 let chunk = await db.chunks.findFirst({ where: { name: question.chunkId, projectId } }); if (!chunk) { console.error(`Chunk with name ${question.chunkId} not found for project ${projectPath}`); continue; } if (!question.questions || question.questions.length === 0) continue; for (const item of question.questions) { const questionData = { projectId: projectId, chunkId: chunk.id, question: item.question, label: item.label }; questionList.push(questionData); } } return await db.questions.createMany({ data: questionList }); } catch (error) { console.error('Error questions.json insert db:', error); throw error; } } //数据集文件处理 async function datasetHandle(projectId, projectPath) { const datasetsPath = path.join(projectPath, 'datasets.json'); let datasetList = []; try { const datasetsData = await readJsonFile(datasetsPath); if (datasetsData.length === 0) return; for (const dataset of datasetsData) { let chunk = await db.chunks.findFirst({ where: { name: dataset.chunkId, projectId } }); if (!chunk) { console.error(`Chunk with name ${dataset.chunkId} not found for project ${projectPath}`); continue; } let question = await db.questions.findFirst({ where: { question: dataset.question } }); if (!question) { console.error(`Question with name ${dataset.question} not found for project ${projectPath}`); continue; } const datasetData = { projectId: projectId, chunkName: chunk.name, chunkContent: '', questionId: question.id, question: dataset.question, answer: dataset.answer, model: dataset.model, questionLabel: dataset.questionLabel, createAt: dataset.createdAt, cot: dataset.cot ? dataset.cot : '', confirmed: dataset.confirmed ? dataset.confirmed : false }; datasetList.push(datasetData); } return await db.datasets.createMany({ data: datasetList }); } catch (error) { console.error('Error datasets.json insert db:', error); throw error; } } //批量更新问题的答案状态 async function updateQuestions(projectId) { const result = await db.$queryRaw` UPDATE Questions SET answered = 1 WHERE EXISTS ( SELECT 1 FROM Datasets d WHERE d.question = Questions.question AND Questions.projectId = ${projectId} ) `; console.log(result); } // 复制文件夹 async function copyDirRecursive(src, dest, projectNewId) { try { // 检查源路径是否存在 if (!fs.existsSync(src)) { console.error(`Source directory not found: ${src}`); return; } // 确保目标路径存在 if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); } // 读取源路径下的所有文件和子目录 const entries = await fs.promises.readdir(src, { withFileTypes: true }); let old = ['config.json', 'chunks', 'questions.json', 'datasets.json', 'model-config.json']; for (const entry of entries) { if (old.includes(entry.name)) { continue; } const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { // 如果是目录,递归复制 await copyDirRecursive(srcPath, destPath, projectNewId); } else { // 如果是文件,直接复制 await fs.promises.copyFile(srcPath, destPath); } } } catch (error) { console.error(`Failed to copy directory from ${src} to ${dest}`, error); } } async function safeReadDir(dirPath, options = {}) { try { if (fs.existsSync(dirPath)) { return await fs.promises.readdir(dirPath, options); } return []; } catch (error) { console.error(`Error reading directory: ${dirPath}`, error); return []; } } ================================================ FILE: lib/db/files.js ================================================ /** * 文件操作辅助函数 */ const path = require('path'); const { promises: fs } = require('fs'); const { getProjectRoot, readJSON } = require('./base'); const { getProject } = require('./projects'); const { getUploadFileInfoById } = require('./upload-files'); /** * 获取项目文件内容 * @param {string} projectId - 项目ID * @param {string} fileName - 文件名 * @returns {Promise} 文件内容 */ async function getProjectFileContent(projectId, fileName) { try { // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filePath = path.join(projectPath, 'files', fileName); // 读取文件内容 const content = await fs.readFile(filePath, 'utf-8'); return content; } catch (error) { console.error('获取项目文件内容失败:', error); return ''; } } /** * 根据文件ID获取项目文件内容 * @param {string} projectId - 项目ID * @param {string} fileId - 文件ID * @returns {Promise} 文件内容 */ async function getProjectFileContentById(projectId, fileId) { try { // 获取文件信息 const fileInfo = await getUploadFileInfoById(fileId); if (!fileInfo) { throw new Error('文件不存在'); } // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filePath = path.join(projectPath, 'files', fileInfo.fileName); // 读取文件内容 const content = await fs.readFile(filePath, 'utf-8'); return content; } catch (error) { console.error('根据ID获取项目文件内容失败:', error); return ''; } } module.exports = { getProjectFileContent, getProjectFileContentById }; ================================================ FILE: lib/db/ga-pairs.js ================================================ 'use server'; import { db } from '@/lib/db/index'; /** * 获取文件的所有 GA 对 * @param {string} fileId - 文件 ID * @returns {Promise} GA 对列表 */ export async function getGaPairsByFileId(fileId) { try { return await db.gaPairs.findMany({ where: { fileId }, orderBy: { pairNumber: 'asc' } }); } catch (error) { console.error('Failed to get GA pairs by fileId in database:', error); throw error; } } /** * 获取项目的所有 GA 对 * @param {string} projectId - 项目 ID * @returns {Promise} GA 对列表 */ export async function getGaPairsByProjectId(projectId) { try { return await db.gaPairs.findMany({ where: { projectId }, include: { uploadFile: { select: { fileName: true } } }, orderBy: [{ fileId: 'asc' }, { pairNumber: 'asc' }] }); } catch (error) { console.error('Failed to get GA pairs by projectId in database:', error); throw error; } } /** * 创建 GA 对 * @param {Array} gaPairs - GA 对数据数组 * @returns {Promise} 创建的 GA 对 */ export async function createGaPairs(gaPairs) { try { return await db.gaPairs.createManyAndReturn({ data: gaPairs }); } catch (error) { console.error('Failed to create GA pairs in database:', error); throw error; } } /** * 更新单个 GA 对 * @param {string} id - GA 对 ID * @param {Object} data - 更新数据 * @returns {Promise} 更新后的 GA 对 */ export async function updateGaPair(id, data) { try { return await db.gaPairs.update({ where: { id }, data }); } catch (error) { console.error('Failed to update GA pair in database:', error); throw error; } } /** * 删除文件的所有 GA 对 * @param {string} fileId - 文件 ID * @returns {Promise} 删除结果 */ export async function deleteGaPairsByFileId(fileId) { try { return await db.gaPairs.deleteMany({ where: { fileId } }); } catch (error) { console.error('Failed to delete GA pairs by fileId in database:', error); throw error; } } /** * 切换 GA 对的激活状态 * @param {string} id - GA 对 ID * @param {boolean} isActive - 激活状态 * @returns {Promise} 更新后的 GA 对 */ export async function toggleGaPairActive(id, isActive) { try { return await db.gaPairs.update({ where: { id }, data: { isActive } }); } catch (error) { console.error('Failed to toggle GA pair active status in database:', error); throw error; } } /** * 获取文件的激活 GA 对 * @param {string} fileId - 文件 ID * @returns {Promise} 激活的 GA 对列表 */ export async function getActiveGaPairsByFileId(fileId) { try { return await db.gaPairs.findMany({ where: { fileId, isActive: true }, orderBy: { pairNumber: 'asc' } }); } catch (error) { console.error('Failed to get active GA pairs by fileId in database:', error); throw error; } } /** * 批量更新 GA 对 * @param {Array} updates - 更新数据数组,每个包含 id 和其他字段 * @returns {Promise} 更新结果 */ export async function batchUpdateGaPairs(updates) { try { const results = await Promise.all( updates.map(({ id, ...updateData }) => { // 过滤掉不应该更新的字段 const { createAt, updateAt, projectId, fileId, pairNumber, ...data } = updateData; // 确保有数据需要更新 if (Object.keys(data).length === 0) { console.warn(`No data to update for GA pair ${id}`); return Promise.resolve(null); } return db.gaPairs.update({ where: { id }, data }); }) ); // 过滤掉null结果 return results.filter(result => result !== null); } catch (error) { console.error('Failed to batch update GA pairs in database:', error); throw error; } } /** * Generate and save GA pairs for a specific file using LLM * @param {string} projectId - Project ID * @param {string} fileId - File ID * @param {Array} gaPairs - Array of GA pair objects from LLM * @returns {Promise} - Created GA pairs */ export async function saveGaPairs(projectId, fileId, gaPairs) { try { // First, delete existing GA pairs for this file to avoid conflicts await db.gaPairs.deleteMany({ where: { projectId, fileId } }); // Map the GA pairs to database format const gaPairData = gaPairs.map((pair, index) => ({ projectId, fileId, pairNumber: index + 1, // 1-5 genreTitle: pair.genre.title, genreDesc: pair.genre.description, audienceTitle: pair.audience.title, audienceDesc: pair.audience.description, isActive: true })); return await db.gaPairs.createMany({ data: gaPairData }); } catch (error) { console.error('Failed to save GA pairs in database:', error); throw error; } } /** * Check if GA pairs exist for a file * @param {string} projectId - Project ID * @param {string} fileId - File ID * @returns {Promise} - Whether GA pairs exist */ export async function hasGaPairs(projectId, fileId) { try { const count = await db.gaPairs.count({ where: { projectId, fileId } }); return count > 0; } catch (error) { console.error('Failed to check GA pairs existence:', error); throw error; } } /** * Get GA pair statistics for a project * @param {string} projectId - Project ID * @returns {Promise} - GA pair statistics */ export async function getGaPairStats(projectId) { try { const totalCount = await db.gaPairs.count({ where: { projectId } }); const activeCount = await db.gaPairs.count({ where: { projectId, isActive: true } }); const filesWithGaPairs = await db.gaPairs.groupBy({ by: ['fileId'], where: { projectId } }); return { totalPairs: totalCount, activePairs: activeCount, filesWithPairs: filesWithGaPairs.length }; } catch (error) { console.error('Failed to get GA pair statistics:', error); throw error; } } ================================================ FILE: lib/db/imageDatasets.js ================================================ 'use server'; import { db } from '@/lib/db/index'; /** * 创建图像数据集 */ export async function createImageDataset(projectId, datasetData) { try { return await db.imageDatasets.create({ data: { projectId, ...datasetData } }); } catch (error) { console.error('Failed to create image dataset:', error); throw error; } } /** * 获取图片的数据集列表 */ export async function getImageDatasets(imageId, page = 1, pageSize = 10) { try { const [data, total] = await Promise.all([ db.imageDatasets.findMany({ where: { imageId }, orderBy: { createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), db.imageDatasets.count({ where: { imageId } }) ]); return { data, total }; } catch (error) { console.error('Failed to get image datasets:', error); throw error; } } /** * 更新图像数据集 */ export async function updateImageDataset(datasetId, updateData) { try { return await db.imageDatasets.update({ where: { id: datasetId }, data: updateData }); } catch (error) { console.error('Failed to update image dataset:', error); throw error; } } /** * 删除图像数据集 */ export async function deleteImageDataset(datasetId) { try { return await db.imageDatasets.delete({ where: { id: datasetId } }); } catch (error) { console.error('Failed to delete image dataset:', error); throw error; } } /** * 根据项目ID获取所有图像数据集(支持筛选) */ export async function getImageDatasetsByProject(projectId, page = 1, pageSize = 10, filters = {}) { try { // 构建查询条件 const whereClause = { projectId }; // 搜索条件(问题或答案) if (filters.search) { whereClause.OR = [{ question: { contains: filters.search } }, { answer: { contains: filters.search } }]; } // 确认状态筛选 if (filters.confirmed !== undefined) { whereClause.confirmed = filters.confirmed; } // 评分筛选 if (filters.minScore !== undefined || filters.maxScore !== undefined) { whereClause.score = {}; if (filters.minScore !== undefined) { whereClause.score.gte = filters.minScore; } if (filters.maxScore !== undefined) { whereClause.score.lte = filters.maxScore; } } const [data, total] = await Promise.all([ db.imageDatasets.findMany({ where: whereClause, orderBy: { createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), db.imageDatasets.count({ where: whereClause }) ]); return { data, total }; } catch (error) { console.error('Failed to get image datasets by project:', error); throw error; } } /** * 根据ID获取单个图像数据集 */ export async function getImageDatasetById(datasetId) { try { const dataset = await db.imageDatasets.findUnique({ where: { id: datasetId }, include: { image: true // 包含关联的图片信息 } }); if (!dataset) { return null; } // 如果有 questionId,获取问题模版信息 if (dataset.questionId) { const questionData = await db.questions.findUnique({ where: { id: dataset.questionId } }); let questionTemplate = null; if (questionData) { dataset.questionData = questionData; if (questionData.templateId) { questionTemplate = await db.questionTemplates.findUnique({ where: { id: questionData.templateId } }); } } else { dataset.questionData = { id: 'x', question: dataset.question }; } if (questionTemplate) { // 解析标签 let availableLabels = []; if (questionTemplate.labels) { try { availableLabels = JSON.parse(questionTemplate.labels); } catch (e) { console.error('Failed to parse labels:', e); } } // 添加问题模版信息 return { ...dataset, availableLabels, customFormat: questionTemplate.customFormat || '', questionTemplate, questionData }; } } return dataset; } catch (error) { console.error('Failed to get image dataset by id:', error); throw error; } } /** * 根据项目ID获取所有图像数据集的标签 */ export async function getImageDatasetsTagsByProject(projectId) { try { const datasets = await db.imageDatasets.findMany({ where: { projectId, tags: { not: '' } }, select: { tags: true } }); return datasets; } catch (error) { console.error('Failed to get image datasets tags by project:', error); throw error; } } /** * 获取用于导出的图像数据集 */ export async function getImageDatasetsForExport(projectId, confirmedOnly = false) { try { const whereClause = { projectId }; // 如果只导出已确认的 if (confirmedOnly) { whereClause.confirmed = true; } const datasets = await db.imageDatasets.findMany({ where: whereClause }); return datasets; } catch (error) { console.error('Failed to get image datasets for export:', error); throw error; } } ================================================ FILE: lib/db/images.js ================================================ 'use server'; import { db } from '@/lib/db/index'; import { getProjectPath } from '@/lib/db/base'; import { getMimeType } from '@/lib/util/image'; /** * 获取项目的图片列表(分页) */ export async function getImages(projectId, page = 1, pageSize = 20, imageName = '', hasQuestions, hasDatasets, simple) { try { // 构建基础查询条件 const baseWhereClause = { projectId, ...(imageName && { imageName: { contains: imageName } }) }; // 如果有过滤条件,需要使用复杂查询 if (hasQuestions !== undefined || hasDatasets !== undefined) { // 先获取所有符合基础条件的图片ID和统计信息 const allImages = await db.images.findMany({ where: baseWhereClause, orderBy: { createAt: 'desc' } }); if (simple) { return { data: allImages }; } // 获取每个图片的统计信息并应用过滤 const imagesWithStats = await Promise.all( allImages.map(async image => { const [questionCount, datasetCount] = await Promise.all([ db.questions.count({ where: { projectId, imageId: image.id } }), db.imageDatasets.count({ where: { imageId: image.id } }) ]); return { ...image, questionCount, datasetCount }; }) ); // 应用筛选条件 let filteredImages = imagesWithStats; if (hasQuestions === 'true') { filteredImages = filteredImages.filter(img => img.questionCount > 0); } else if (hasQuestions === 'false') { filteredImages = filteredImages.filter(img => img.questionCount === 0); } if (hasDatasets === 'true') { filteredImages = filteredImages.filter(img => img.datasetCount > 0); } else if (hasDatasets === 'false') { filteredImages = filteredImages.filter(img => img.datasetCount === 0); } // 应用分页 const total = filteredImages.length; const startIndex = (page - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedImages = filteredImages.slice(startIndex, endIndex); // 为分页后的图片添加 base64 数据 const imagesWithBase64 = await Promise.all( paginatedImages.map(async image => { let base64Image = null; try { const fs = require('fs/promises'); const path = require('path'); const projectPath = await getProjectPath(projectId); const imagePath = path.join(projectPath, 'images', image.imageName); const imageBuffer = await fs.readFile(imagePath); const ext = path.extname(image.imageName).toLowerCase(); const mimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp', '.svg': 'image/svg+xml' }; const mimeType = mimeTypes[ext] || 'image/jpeg'; base64Image = `data:${mimeType};base64,${imageBuffer.toString('base64')}`; } catch (err) { console.warn(`Failed to read image: ${image.imageName}`, err); } return { ...image, base64: base64Image }; }) ); return { data: imagesWithBase64, total, page, pageSize }; } else { // 没有过滤条件时,使用原来的简单查询 const [data, total] = await Promise.all([ db.images.findMany({ where: baseWhereClause, orderBy: { createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), db.images.count({ where: baseWhereClause }) ]); // 获取每个图片的问题和数据集数量,并读取图片为 base64 const imagesWithStats = await Promise.all( data.map(async image => { const [questionCount, datasetCount] = await Promise.all([ db.questions.count({ where: { projectId, imageId: image.id } }), db.imageDatasets.count({ where: { imageId: image.id } }) ]); // 读取图片文件并转换为 base64 let base64Image = null; try { const fs = require('fs/promises'); const path = require('path'); const projectPath = await getProjectPath(projectId); const imagePath = path.join(projectPath, 'images', image.imageName); const imageBuffer = await fs.readFile(imagePath); const mimeType = getMimeType(image.imageName); base64Image = `data:${mimeType};base64,${imageBuffer.toString('base64')}`; } catch (err) { console.warn(`Failed to read image: ${image.imageName}`, err); } return { ...image, questionCount, datasetCount, base64: base64Image }; }) ); return { data: imagesWithStats, total, page, pageSize }; } } catch (error) { console.error('Failed to get images:', error); throw error; } } /** * 创建图片记录 */ export async function createImage(projectId, imageData) { try { return await db.images.create({ data: { projectId, ...imageData } }); } catch (error) { console.error('Failed to create image:', error); throw error; } } /** * 批量创建图片记录 */ export async function createImages(projectId, imagesData) { try { const results = []; for (const imageData of imagesData) { // 检查是否已存在 const existing = await db.images.findFirst({ where: { projectId, imageName: imageData.imageName } }); if (existing) { // 更新现有记录 const updated = await db.images.update({ where: { id: existing.id }, data: imageData }); results.push(updated); } else { // 创建新记录 const created = await db.images.create({ data: { projectId, ...imageData } }); results.push(created); } } return results; } catch (error) { console.error('Failed to create images:', error); throw error; } } /** * 根据图片 ID 获取图片 */ export async function getImageById(imageId) { try { return await db.images.findUnique({ where: { id: imageId } }); } catch (error) { console.error('Failed to get image by id:', error); throw error; } } /** * 根据图片名称获取图片 */ export async function getImageByName(projectId, imageName) { try { return await db.images.findFirst({ where: { projectId, imageName } }); } catch (error) { console.error('Failed to get image by name:', error); throw error; } } /** * 删除图片 */ export async function deleteImage(imageId) { try { return await db.images.delete({ where: { id: imageId } }); } catch (error) { console.error('Failed to delete image:', error); throw error; } } /** * 获取图片详情(包含统计信息) */ export async function getImageDetail(imageId) { try { const image = await db.images.findUnique({ where: { id: imageId } }); if (!image) { return null; } const [questionCount, datasetCount] = await Promise.all([ db.questions.count({ where: { projectId: image.projectId, imageId: image.id } }), db.imageDatasets.count({ where: { imageId: image.id } }) ]); return { ...image, questionCount, datasetCount }; } catch (error) { console.error('Failed to get image detail:', error); throw error; } } export async function getImageChunk(projectId) { let imageChunk = await db.chunks.findFirst({ where: { projectId, name: 'Image Chunk' } }); if (!imageChunk) { imageChunk = await db.chunks.create({ data: { name: 'Image Chunk', projectId, fileId: 'image', fileName: 'image.md', content: '', summary: '', size: 0 } }); } return imageChunk; } ================================================ FILE: lib/db/index.js ================================================ import { PrismaClient } from '@prisma/client'; const createPrismaClient = () => new PrismaClient({ // log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'] log: ['error'] }); const globalForPrisma = globalThis; export const db = globalForPrisma.prisma || createPrismaClient(); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db; ================================================ FILE: lib/db/llm-models.js ================================================ 'use server'; import { db } from '@/lib/db/index'; export async function getLlmModelsByProviderId(providerId) { try { return await db.llmModels.findMany({ where: { providerId } }); } catch (error) { console.error('Failed to get llmModels by providerId in database'); throw error; } } export async function createLlmModels(models) { try { return await db.llmModels.createMany({ data: models }); } catch (error) { console.error('Failed to create llmModels in database'); throw error; } } ================================================ FILE: lib/db/llm-providers.js ================================================ 'use server'; import { db } from '@/lib/db/index'; export async function getLlmProviders() { try { let list = await db.llmProviders.findMany(); if (list.length !== 0) { return list; } let data = [ { id: 'ollama', name: 'Ollama', apiUrl: 'http://127.0.0.1:11434/api' }, { id: 'openai', name: 'OpenAI', apiUrl: 'https://api.openai.com/v1/' }, { id: 'siliconcloud', name: '硅基流动', apiUrl: 'https://api.siliconflow.cn/v1/' }, { id: 'deepseek', name: 'DeepSeek', apiUrl: 'https://api.deepseek.com/v1/' }, { id: '302ai', name: '302.AI', apiUrl: 'https://api.302.ai/v1/' }, { id: 'zhipu', name: '智谱AI', apiUrl: 'https://open.bigmodel.cn/api/paas/v4/' }, { id: 'Doubao', name: '火山引擎', apiUrl: 'https://ark.cn-beijing.volces.com/api/v3/' }, { id: 'groq', name: 'Groq', apiUrl: 'https://api.groq.com/openai' }, { id: 'grok', name: 'Grok', apiUrl: 'https://api.x.ai' }, { id: 'openRouter', name: 'OpenRouter', apiUrl: 'https://openrouter.ai/api/v1/' }, { id: 'alibailian', name: '阿里云百炼', apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' } ]; await db.llmProviders.createMany({ data }); return data; } catch (error) { console.error('Failed to get llmProviders in database'); throw error; } } ================================================ FILE: lib/db/model-config.js ================================================ 'use server'; import { db } from '@/lib/db/index'; import { nanoid } from 'nanoid'; export async function getModelConfigByProjectId(projectId) { try { return await db.modelConfig.findMany({ where: { projectId } }); } catch (error) { console.error('Failed to get modelConfig by projectId in database'); throw error; } } export async function createInitModelConfig(data) { try { return await db.modelConfig.createManyAndReturn({ data }); } catch (error) { console.error('Failed to create init modelConfig list in database'); throw error; } } export async function getModelConfigById(id) { try { return await db.modelConfig.findUnique({ where: { id } }); } catch (error) { console.error('Failed to get modelConfig by id in database'); throw error; } } export async function deleteModelConfigById(id) { try { return await db.modelConfig.delete({ where: { id } }); } catch (error) { console.error('Failed to delete modelConfig by id in database'); throw error; } } export async function saveModelConfig(models) { try { if (!models.id) { models.id = nanoid(12); } return await db.modelConfig.upsert({ create: models, update: models, where: { id: models.id } }); } catch (error) { console.error('Failed to create modelConfig in database'); throw error; } } ================================================ FILE: lib/db/projects.js ================================================ 'use server'; import fs from 'fs'; import path from 'path'; import { getProjectRoot, readJsonFile } from './base'; import { DEFAULT_SETTINGS } from '@/constant/setting'; import { db } from '@/lib/db/index'; import { nanoid } from 'nanoid'; // 创建新项目 export async function createProject(projectData) { try { let projectId = nanoid(12); const projectRoot = await getProjectRoot(); const projectDir = path.join(projectRoot, projectId); // 创建项目目录 await fs.promises.mkdir(projectDir, { recursive: true }); // 创建子目录 await fs.promises.mkdir(path.join(projectDir, 'files'), { recursive: true }); // 原始文件 return await db.projects.create({ data: { id: projectId, name: projectData.name, description: projectData.description } }); } catch (error) { console.error('Failed to create project in database'); throw error; } } export async function isExistByName(name) { try { const count = await db.projects.count({ where: { name: name } }); return count > 0; } catch (error) { console.error('Failed to get project by name in database'); throw error; } } // 获取所有项目 export async function getProjects() { try { const projects = await db.projects.findMany({ include: { _count: { select: { Datasets: true, Questions: true, ImageDatasets: true, EvalDatasets: true } } }, orderBy: { createAt: 'desc' } }); // 批量获取每个项目的 Token 统计(使用聚合查询优化性能) const projectIds = projects.map(p => p.id); const tokenStats = await db.llmUsageLogs.groupBy({ by: ['projectId'], where: { projectId: { in: projectIds } }, _sum: { inputTokens: true, outputTokens: true } }); // 将 Token 统计映射到项目 const tokenStatsMap = new Map( tokenStats.map(stat => [ stat.projectId, { totalTokens: (stat._sum.inputTokens || 0) + (stat._sum.outputTokens || 0) } ]) ); // 合并数据 return projects.map(project => ({ ...project, totalTokens: tokenStatsMap.get(project.id)?.totalTokens || 0 })); } catch (error) { console.error('Failed to get projects in database'); throw error; } } // 获取项目详情 export async function getProject(projectId) { try { return await db.projects.findUnique({ where: { id: projectId } }); } catch (error) { console.error('Failed to get project by id in database'); throw error; } } // 更新项目配置 export async function updateProject(projectId, projectData) { try { delete projectData.projectId; return await db.projects.update({ where: { id: projectId }, data: { ...projectData } }); } catch (error) { console.error('Failed to update project in database'); throw error; } } // 删除项目 export async function deleteProject(projectId) { try { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); await db.projects.delete({ where: { id: projectId } }); if (fs.existsSync(projectPath)) { await fs.promises.rm(projectPath, { recursive: true }); } return true; } catch (error) { return false; } } // 获取任务配置 export async function getTaskConfig(projectId) { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const taskConfigPath = path.join(projectPath, 'task-config.json'); const taskData = await readJsonFile(taskConfigPath); if (!taskData) { return DEFAULT_SETTINGS; } return taskData; } ================================================ FILE: lib/db/questionTemplates.js ================================================ /** * 问题模板数据访问层(通用) */ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); /** * 获取问题模板列表 * @param {String} projectId 项目ID * @param {Object} options 查询选项 * @returns {Promise} */ export async function getTemplates(projectId, options = {}) { const { sourceType, search } = options; const where = { projectId }; if (sourceType) { where.sourceType = sourceType; } if (search) { where.question = { contains: search }; } const templates = await prisma.questionTemplates.findMany({ where, orderBy: [{ order: 'asc' }, { createAt: 'desc' }] }); // 解析 JSON 字段 return templates.map(template => ({ ...template, labels: template.labels ? JSON.parse(template.labels) : [], customFormat: template.customFormat ? JSON.parse(template.customFormat) : null })); } /** * 获取单个模板 * @param {String} templateId 模板ID * @returns {Promise} */ export async function getTemplateById(templateId) { const template = await prisma.questionTemplates.findUnique({ where: { id: templateId } }); if (!template) { return null; } return { ...template, labels: template.labels ? JSON.parse(template.labels) : [], customFormat: template.customFormat ? JSON.parse(template.customFormat) : null }; } /** * 创建问题模板 * @param {String} projectId 项目ID * @param {Object} data 模板数据 * @returns {Promise} */ export async function createTemplate(projectId, data) { const { question, sourceType, answerType, description, labels, customFormat, order } = data; const template = await prisma.questionTemplates.create({ data: { projectId, question, sourceType, answerType, description: description || '', labels: labels ? JSON.stringify(labels) : '', customFormat: customFormat ? JSON.stringify(customFormat) : '', order: order || 0 } }); return { ...template, labels: template.labels ? JSON.parse(template.labels) : [], customFormat: template.customFormat ? JSON.parse(template.customFormat) : null }; } /** * 更新问题模板 * @param {String} templateId 模板ID * @param {Object} data 更新数据 * @returns {Promise} */ export async function updateTemplate(templateId, data) { const updateData = { ...data }; // 序列化 JSON 字段 if (data.labels !== undefined) { updateData.labels = JSON.stringify(data.labels); } if (data.customFormat !== undefined) { updateData.customFormat = JSON.stringify(data.customFormat); } const template = await prisma.questionTemplates.update({ where: { id: templateId }, data: updateData }); return { ...template, labels: template.labels ? JSON.parse(template.labels) : [], customFormat: template.customFormat ? JSON.parse(template.customFormat) : null }; } /** * 删除问题模板 * @param {String} templateId 模板ID * @returns {Promise} */ export async function deleteTemplate(templateId) { await prisma.questionTemplates.delete({ where: { id: templateId } }); } /** * 获取模板使用统计 * @param {String} templateId 模板ID * @returns {Promise} */ export async function getTemplateUsageCount(templateId) { // 统计关联此模板的问题数量 const count = await prisma.questions.count({ where: { templateId } }); return count; } /** * 批量获取模板使用统计 * @param {Array} templateIds 模板ID列表 * @returns {Promise} { templateId: count } */ export async function getTemplatesUsageCount(templateIds) { const questions = await prisma.questions.groupBy({ by: ['templateId'], _count: { templateId: true }, where: { templateId: { in: templateIds } } }); const result = {}; questions.forEach(q => { result[q.templateId] = q._count.templateId; }); return result; } export default { getTemplates, getTemplateById, createTemplate, updateTemplate, deleteTemplate, getTemplateUsageCount, getTemplatesUsageCount }; ================================================ FILE: lib/db/questions.js ================================================ 'use server'; import { db } from '@/lib/db/index'; /** * 获取项目的所有问题 * @param {string} projectId - 项目ID * @param {number} page - 页码 * @param {number} pageSize - 每页大小 * @param answered * @param input * @param chunkName - 文本块名称筛选 * @param sourceType - 数据源类型筛选 ('all', 'text', 'image') * @param searchMatchMode - 搜索匹配模式 ('match', 'notMatch') * @returns {Promise<{data: Array, total: number}>} - 问题列表和总条数 */ export async function getQuestions( projectId, page = 1, pageSize = 10, answered, input, chunkName, sourceType = 'all', searchMatchMode = 'match' ) { try { const whereClause = { projectId, ...(answered !== undefined && { answered: answered }), // 确保 answered 是布尔值 ...(input && searchMatchMode === 'match' && { OR: [{ question: { contains: input } }, { label: { contains: input } }] }), ...(input && searchMatchMode === 'notMatch' && { question: { not: { contains: input } } }), ...(chunkName && { chunk: { name: { contains: chunkName } } }), ...(sourceType === 'text' && { imageId: null }), ...(sourceType === 'image' && { imageId: { not: null } }) }; const [data, total] = await Promise.all([ db.questions.findMany({ where: whereClause, orderBy: { createAt: 'desc' }, include: { chunk: { select: { name: true, content: true } } }, skip: (page - 1) * pageSize, take: pageSize }), db.questions.count({ where: whereClause }) ]); // 批量查询 datasetCount const datasetCounts = await getDatasetCountsForQuestions(data.map(item => item.id)); // 合并 datasetCount 到问题项中 const questionsWithDatasetCount = data.map((item, index) => ({ ...item, datasetCount: datasetCounts[index] })); return { data: questionsWithDatasetCount, total }; } catch (error) { console.error('Failed to get questions by projectId in database'); throw error; } } /** * 获取项目的所有问题(仅ID和标签),用于树形视图 * @param {string} projectId - 项目ID * @param {string} input - 搜索关键词 * @param {boolean} isDistill - 是否只查询蒸馏问题 * @param {boolean} excludeImage - 是否排除图片问题(label='image'),默认为 true * @returns {Promise} - 问题列表(仅包含ID和标签) */ export async function getQuestionsForTree(projectId, input, isDistill = false, excludeImage = true) { try { // console.log('[getQuestionsForTree] 参数:', { projectId, input, isDistill, excludeImage }); // 如果是蒸馏问题,需要先获取蒸馏文本块 let whereClause = { projectId, question: { contains: input || '' } }; // 排除图片问题 if (excludeImage) { whereClause.label = { not: 'image' }; } if (isDistill) { // 获取蒸馏文本块 const distillChunk = await db.chunks.findFirst({ where: { projectId, name: 'Distilled Content' } }); if (distillChunk) { whereClause.chunkId = distillChunk.id; } } const data = await db.questions.findMany({ where: whereClause, select: { id: true, label: true, answered: true }, orderBy: { createAt: 'desc' } }); return data; } catch (error) { console.error('获取树形视图问题失败:', error); throw error; } } /** * 根据标签获取项目的问题 * @param {string} projectId - 项目ID * @param {string} tag - 标签名称 * @param {string} input - 搜索关键词 * @param {boolean} isDistill - 是否只查询蒸馏问题 * @param {boolean} excludeImage - 是否排除图片问题(label='image'),默认为 true * @returns {Promise} - 问题列表 */ export async function getQuestionsByTag(projectId, tag, input, isDistill = false, excludeImage = true) { try { const whereClause = { projectId }; if (input) { whereClause.question = { contains: input }; } if (tag === 'uncategorized') { const { getTags } = await import('./tags'); const tagsData = await getTags(projectId); const extractAllLabels = tags => { const labels = []; tags.forEach(tag => { labels.push(tag.label); if (tag.child && tag.child.length > 0) { labels.push(...extractAllLabels(tag.child)); } }); return labels; }; const allTagLabels = extractAllLabels(tagsData || []); const orConditions = []; if (excludeImage) { if (allTagLabels.length > 0) { orConditions.push({ AND: [{ label: { notIn: [...allTagLabels, 'image'] } }] }); } // console.log('orConditions:', orConditions); } else { orConditions.push({ label: null }, { label: '' }); if (allTagLabels.length > 0) { orConditions.push({ label: { notIn: allTagLabels } }); } } whereClause.OR = orConditions; } else { if (excludeImage && tag === 'image') { return []; // 不返回任何问题 } whereClause.label = { in: [tag] }; } // 如果是蒸馏问题,需要先获取蒸馏文本块 if (isDistill) { // 获取蒸馏文本块 const distillChunk = await db.chunks.findFirst({ where: { projectId, name: 'Distilled Content' } }); if (distillChunk) { whereClause.chunkId = distillChunk.id; } } const data = await db.questions.findMany({ where: whereClause, include: { chunk: { select: { name: true, content: true } } }, orderBy: { createAt: 'desc' } }); // 批量查询 datasetCount const datasetCounts = await getDatasetCountsForQuestions(data.map(item => item.id)); // 合并 datasetCount 到问题项中 const questionsWithDatasetCount = data.map((item, index) => ({ ...item, datasetCount: datasetCounts[index] })); return questionsWithDatasetCount; } catch (error) { console.error(`根据标签获取问题失败 (${tag}):`, error); throw error; } } export async function getAllQuestionsByProjectId(projectId) { try { return await db.questions.findMany({ where: { projectId }, include: { chunk: { select: { name: true, content: true } } }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get datasets ids in database'); throw error; } } export async function getQuestionsIds( projectId, answered, input, chunkName, sourceType = 'all', searchMatchMode = 'match' ) { try { const whereClause = { projectId, ...(answered !== undefined && { answered: answered }), // 确保 answered 是布尔值 ...(input && searchMatchMode === 'match' && { OR: [{ question: { contains: input } }, { label: { contains: input } }] }), ...(input && searchMatchMode === 'notMatch' && { question: { not: { contains: input } } }), ...(chunkName && { chunk: { name: { contains: chunkName } } }), ...(sourceType === 'text' && { imageId: null }), ...(sourceType === 'image' && { imageId: { not: null } }) }; // 对于大数据量,添加限制以防止内存溢出 const MAX_SELECTION = 10000; // 最多允许全选10000条 const count = await db.questions.count({ where: whereClause }); if (count > MAX_SELECTION) { console.warn(`尝试选择 ${count} 条问题,超过限制 ${MAX_SELECTION},将只返回前 ${MAX_SELECTION} 条`); } return await db.questions.findMany({ where: whereClause, select: { id: true }, orderBy: { createAt: 'desc' }, take: Math.min(count, MAX_SELECTION) // 限制最大数量 }); } catch (error) { console.error('Failed to get datasets ids in database'); throw error; } } export async function getQuestionsByTagName(projectId, tagName) { try { return await db.questions.findMany({ where: { projectId, label: tagName }, include: { chunk: { select: { name: true } } }, orderBy: { createAt: 'desc' } }); } catch (error) { console.error('Failed to get datasets ids in database'); throw error; } } /** * 批量获取问题的 datasetCount * 包含普通数据集、图片数据集和多轮对话数据集 * @param {Array} questionIds - 问题ID列表 * @returns {Promise>} - 每个问题的 datasetCount 列表 */ async function getDatasetCountsForQuestions(questionIds) { // 如果问题数量为0,直接返回空数组 if (questionIds.length === 0) { return []; } // 分批处理,避免 Prisma 参数限制(每批最多1000个) const BATCH_SIZE = 1000; const batches = []; for (let i = 0; i < questionIds.length; i += BATCH_SIZE) { batches.push(questionIds.slice(i, i + BATCH_SIZE)); } // 1. 统计普通数据集(Datasets 表)- 分批查询 const datasetCountsArray = await Promise.all( batches.map(batch => db.datasets.groupBy({ by: ['questionId'], _count: { questionId: true }, where: { questionId: { in: batch } } }) ) ); const datasetCounts = datasetCountsArray.flat(); // 2. 统计多轮对话数据集(datasetConversations 表)- 分批查询 const multiTurnCountsArray = await Promise.all( batches.map(batch => db.datasetConversations.groupBy({ by: ['questionId'], _count: { questionId: true }, where: { questionId: { in: batch } } }) ) ); const multiTurnCounts = multiTurnCountsArray.flat(); // 3. 对于图片问题,通过 imageId + question 统计 ImageDatasets // 先获取图片问题的 imageId 和问题文本 - 分批查询 const imageQuestionsArray = await Promise.all( batches.map(batch => db.questions.findMany({ where: { id: { in: batch }, imageId: { not: null } }, select: { id: true, imageId: true, question: true } }) ) ); const imageQuestions = imageQuestionsArray.flat(); // 统计图片数据集 const imageDatasetCounts = []; if (imageQuestions.length > 0) { // 为每个图片问题统计对应的数据集数量 const countPromises = imageQuestions.map(async q => { const count = await db.imageDatasets.count({ where: { imageId: q.imageId, question: q.question } }); return { questionId: q.id, count }; }); const counts = await Promise.all(countPromises); counts.forEach(item => { if (item.count > 0) { imageDatasetCounts.push({ questionId: item.questionId, _count: { questionId: item.count } }); } }); } // 合并所有统计结果 const totalCountMap = {}; // 添加普通数据集统计 datasetCounts.forEach(item => { totalCountMap[item.questionId] = (totalCountMap[item.questionId] || 0) + item._count.questionId; }); // 添加多轮对话数据集统计 multiTurnCounts.forEach(item => { totalCountMap[item.questionId] = (totalCountMap[item.questionId] || 0) + item._count.questionId; }); // 添加图片数据集统计 imageDatasetCounts.forEach(item => { totalCountMap[item.questionId] = (totalCountMap[item.questionId] || 0) + item._count.questionId; }); // 返回与 questionIds 顺序对应的 datasetCount 列表 return questionIds.map(id => totalCountMap[id] || 0); } export async function getQuestionById(id) { try { return await db.questions.findUnique({ where: { id } }); } catch (error) { console.error('Failed to get questions by name in database'); throw error; } } export async function isExistByQuestion(question, projectId) { try { const count = await db.questions.count({ where: { question, projectId } }); return count > 0; } catch (error) { console.error('Failed to get questions by name in database'); throw error; } } export async function getQuestionsCount(projectId) { try { return await db.questions.count({ where: { projectId } }); } catch (error) { console.error('Failed to get questions count in database'); throw error; } } /** * 保存项目的问题列表 * @param {string} projectId - 项目ID * @param {Array} questions - 问题列表 * @param chunkId * @returns {Promise} - 保存后的问题列表 */ export async function saveQuestions(projectId, questions, chunkId) { try { let data = questions.map(item => { return { projectId, chunkId: chunkId ? chunkId : item.chunkId, question: item.question, label: item.label, imageId: item.imageId, imageName: item.imageName, templateId: item.templateId }; }); return await db.questions.createMany({ data: data }); } catch (error) { console.error('Failed to create questions in database'); throw error; } } export async function updateQuestion(question) { try { return await db.questions.update({ where: { id: question.id }, data: question }); } catch (error) { console.error('Failed to update questions in database'); throw error; } } /** * 更新图片问题的 answered 状态 * @param {string} projectId - 项目ID * @param {string} imageId - 图片ID * @param {string} questionText - 问题文本 * @param {boolean} answered - answered 状态 */ export async function updateQuestionAnsweredStatus(projectId, imageId, questionText, answered) { try { await db.questions.updateMany({ where: { projectId, imageId, question: questionText }, data: { answered } }); } catch (error) { console.error('Failed to update question answered status:', error); throw error; } } /** * 保存项目的问题列表(支持GA配对) * @param {string} projectId - 项目ID * @param {Array} questions - 问题列表 * @param {string} chunkId - 文本块ID * @param {string} gaPairId - GA配对ID(可选) * @returns {Promise} - 保存后的问题列表 */ export async function saveQuestionsWithGaPair(projectId, questions, chunkId, gaPairId = null) { try { let data = questions.map(item => { return { projectId, chunkId: chunkId ? chunkId : item.chunkId, question: item.question, label: item.label, gaPairId: gaPairId // 添加GA配对ID }; }); return await db.questions.createMany({ data: data }); } catch (error) { console.error('Failed to create questions with GA pair in database'); throw error; } } /** * 获取指定文本块的问题 * @param {string} projectId - 项目ID * @param {string} chunkId - 文本块ID * @returns {Promise} - 问题列表 */ export async function getQuestionsForChunk(projectId, chunkId) { return await db.questions.findMany({ where: { projectId, chunkId } }); } /** * 删除单个问题 * @param {string} questionId - 问题ID */ export async function deleteQuestion(questionId) { try { // console.log(questionId); return await db.questions.delete({ where: { id: questionId } }); } catch (error) { console.error('Failed to delete questions by id in database'); throw error; } } /** * 批量删除问题 * @param {Array} questionIds */ export async function batchDeleteQuestions(questionIds) { try { return await db.questions.deleteMany({ where: { id: { in: questionIds } } }); } catch (error) { console.error('Failed to delete batch questions in database'); throw error; } } export async function getQuestionTemplateById(id) { const { templateId } = await db.questions.findUnique({ where: { id } }); if (templateId) { return await db.questionTemplates.findUnique({ where: { id: templateId } }); } } ================================================ FILE: lib/db/tags.js ================================================ 'use server'; import path from 'path'; import { getProjectRoot, readJsonFile, writeJsonFile } from './base'; import { db } from '@/lib/db/index'; import fs from 'fs'; // 获取标签树 export async function getTags(projectId) { try { return await getTagsTreeWithQuestionCount(projectId); } catch (error) { return []; } } // 优化后的递归查询树状结构,并统计问题数量 async function getTagsTreeWithQuestionCount(projectId, parentId = null) { // 一次性获取所有相关标签 const allTags = await db.tags.findMany({ where: { projectId }, orderBy: { label: 'asc' } }); // 创建标签映射以便快速查找 const tagMap = new Map(); const tagTree = []; // 初始化标签映射 allTags.forEach(tag => { tagMap.set(tag.id, { ...tag, questionCount: 0, child: [] }); }); // 构建树结构 allTags.forEach(tag => { if (tag.parentId === parentId) { tagTree.push(tagMap.get(tag.id)); } else if (tag.parentId && tagMap.has(tag.parentId)) { tagMap.get(tag.parentId).child.push(tagMap.get(tag.id)); } }); // 获取该项目的所有问题并按标签分组统计 const questionCounts = await db.questions.groupBy({ by: ['label'], where: { projectId }, _count: { id: true } }); // 创建标签到问题数量的映射 const questionCountMap = new Map(); questionCounts.forEach(item => { questionCountMap.set(item.label, item._count.id); }); // 为每个标签设置直接问题数量 allTags.forEach(tag => { const count = questionCountMap.get(tag.label) || 0; tagMap.get(tag.id).questionCount = count; }); // 数字感知的标签比较函数 function compareLabels(tag1, tag2) { const label1 = tag1.label; const label2 = tag2.label; if (!label1 && !label2) return 0; if (!label1) return -1; if (!label2) return 1; // 使用正则表达式匹配以数字或小数开头的部分 const numberPattern = /^(\d+(\.\d+)?)\s*(.*)/; const match1 = label1.match(numberPattern); const match2 = label2.match(numberPattern); // 如果两个label都以数字或小数开头 if (match1 && match2) { // 提取数字部分并转换为float进行比较 const num1 = parseFloat(match1[1]); const num2 = parseFloat(match2[1]); // 如果数字部分不相等,按数字排序 if (num1 !== num2) { return num1 - num2; } // 如果数字部分相等,按剩余部分排序 const rest1 = match1[3] || ''; const rest2 = match2[3] || ''; return rest1.localeCompare(rest2); } // 如果不都以数字开头,按字符串排序 return label1.localeCompare(label2); } // 对标签树进行递归排序 function sortTagTree(tag) { // 对当前节点的子节点进行排序 tag.child.sort(compareLabels); // 递归对所有子节点进行排序 tag.child.forEach(child => sortTagTree(child)); } // 对所有根节点进行排序 tagTree.sort(compareLabels); // 递归排序每个根节点下的子树 tagTree.forEach(root => sortTagTree(root)); return tagTree; } // 已废弃的方法,保留以确保向后兼容性 async function getAllLabels(tagId) { const labels = []; const queue = [tagId]; while (queue.length > 0) { const currentId = queue.shift(); const tag = await db.tags.findUnique({ where: { id: currentId } }); if (tag) { labels.push(tag.label); // 获取子分类的 ID,加入队列 const children = await db.tags.findMany({ where: { parentId: currentId }, select: { id: true } }); queue.push(...children.map(child => child.id)); } } return labels; } export async function createTag(projectId, label, parentId) { try { let data = { projectId, label }; if (parentId) { data.parentId = parentId; } return await db.tags.create({ data }); } catch (error) { console.error('Error insert tags db:', error); throw error; } } export async function updateTag(label, id) { try { return await db.tags.update({ where: { id }, data: { label } }); } catch (error) { console.error('Error update tags db:', error); throw error; } } /** * 删除标签及其所有子标签、问题和数据集 * @param {string} id - 要删除的标签 ID * @returns {Promise} 删除结果 */ export async function deleteTag(id) { try { console.log(`开始删除标签: ${id}`); // 1. 获取要删除的标签 const tag = await db.tags.findUnique({ where: { id } }); if (!tag) { throw new Error(`标签不存在: ${id}`); } // 2. 获取所有子标签(所有层级) const allChildTags = await getAllChildTags(id, tag.projectId); console.log(`找到 ${allChildTags.length} 个子标签需要删除`); // 3. 从叶子节点开始删除,防止外键约束问题 for (const childTag of allChildTags.reverse()) { // 删除与标签相关的数据集 await deleteDatasetsByTag(childTag.label, childTag.projectId); // 删除与标签相关的问题 await deleteQuestionsByTag(childTag.label, childTag.projectId); // 删除标签 await db.tags.delete({ where: { id: childTag.id } }); console.log(`删除子标签: ${childTag.id} (${childTag.label})`); } // 4. 删除与当前标签相关的数据集 await deleteDatasetsByTag(tag.label, tag.projectId); // 5. 删除与当前标签相关的问题 await deleteQuestionsByTag(tag.label, tag.projectId); // 6. 删除当前标签 console.log(`删除主标签: ${id} (${tag.label})`); return await db.tags.delete({ where: { id } }); } catch (error) { console.error('删除标签时出错:', error); throw error; } } /** * 获取标签的所有子标签(所有层级) * @param {string} parentId - 父标签 ID * @param {string} projectId - 项目 ID * @returns {Promise} 所有子标签列表 */ async function getAllChildTags(parentId, projectId) { const result = []; // 递归获取子标签 async function fetchChildTags(pid) { // 查询直接子标签 const children = await db.tags.findMany({ where: { parentId: pid, projectId } }); // 如果有子标签 if (children.length > 0) { // 将子标签添加到结果中 result.push(...children); // 递归获取每个子标签的子标签 for (const child of children) { await fetchChildTags(child.id); } } } // 开始递归获取 await fetchChildTags(parentId); return result; } /** * 删除与标签相关的问题 * @param {string} label - 标签名称 * @param {string} projectId - 项目 ID */ async function deleteQuestionsByTag(label, projectId) { try { // 查找并删除与标签相关的所有问题 await db.questions.deleteMany({ where: { label, projectId } }); } catch (error) { console.error(`删除标签 "${label}" 相关问题时出错:`, error); throw error; } } /** * 删除与标签相关的数据集 * @param {string} label - 标签名称 * @param {string} projectId - 项目 ID */ async function deleteDatasetsByTag(label, projectId) { try { // 查找并删除与标签相关的所有数据集 await db.datasets.deleteMany({ where: { questionLabel: label, projectId } }); } catch (error) { console.error(`删除标签 "${label}" 相关数据集时出错:`, error); throw error; } } // 保存整个标签树 export async function batchSaveTags(projectId, tags) { try { // 仅在入口函数删除所有标签,避免递归中重复删除 await db.tags.deleteMany({ where: { projectId } }); // 处理标签树 await insertTags(projectId, tags); } catch (error) { console.error('Error insert tags db:', error); throw error; } } async function insertTags(projectId, tags, parentId = null) { // 删除操作已移至外层函数,这里不再需要 for (const tag of tags) { // 插入当前节点 const createdTag = await db.tags.create({ data: { projectId, label: tag.label, parentId: parentId } }); // 如果有子节点,递归插入 if (tag.child && tag.child.length > 0) { await insertTags(projectId, tag.child, createdTag.id); } } } ================================================ FILE: lib/db/texts.js ================================================ 'use server'; import fs from 'fs'; import path from 'path'; import { getProjectRoot, ensureDir } from './base'; // 获取项目中所有原始文件 export async function getFiles(projectId) { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filesDir = path.join(projectPath, 'files'); await fs.promises.access(filesDir); const files = await fs.promises.readdir(filesDir); const fileStats = await Promise.all( files.map(async fileName => { // 跳过非文件项目 const filePath = path.join(filesDir, fileName); const stats = await fs.promises.stat(filePath); // 只返回Markdown文件,跳过其他文件 if (!fileName.endsWith('.md')) { return null; } return { name: fileName, path: filePath, size: stats.size, createdAt: stats.birthtime }; }) ); return fileStats.filter(Boolean); // 过滤掉null值 } // 删除项目中的原始文件及相关的文本块 export async function deleteFile(projectId, fileName) { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filesDir = path.join(projectPath, 'files'); const chunksDir = path.join(projectPath, 'chunks'); const tocDir = path.join(projectPath, 'toc'); // 确保目录存在 await ensureDir(tocDir); // 删除原始文件 const filePath = path.join(filesDir, fileName); try { await fs.promises.access(filePath); await fs.promises.unlink(filePath); } catch (error) { console.error(`删除文件 ${fileName} 失败:`, error); // 如果文件不存在,继续处理 } // 删除相关的TOC文件 const baseName = path.basename(fileName, path.extname(fileName)); const tocPath = path.join(tocDir, `${baseName}-toc.json`); try { await fs.promises.access(tocPath); await fs.promises.unlink(tocPath); } catch (error) { // 如果TOC文件不存在,继续处理 } // 删除相关的文本块 try { await fs.promises.access(chunksDir); const chunks = await fs.promises.readdir(chunksDir); // 过滤出与该文件相关的文本块 const relatedChunks = chunks.filter(chunk => chunk.startsWith(`${baseName}-part-`) && chunk.endsWith('.txt')); // 删除相关的文本块 for (const chunk of relatedChunks) { const chunkPath = path.join(chunksDir, chunk); await fs.promises.unlink(chunkPath); } } catch (error) { console.error(`删除文件 ${fileName} 相关的文本块失败:`, error); } return { success: true, fileName }; } ================================================ FILE: lib/db/upload-files.js ================================================ 'use server'; import { db } from '@/lib/db/index'; import fs from 'fs'; import path from 'path'; import { deleteDataset } from './datasets'; import { deleteChunkById } from './chunks'; //获取文件列表 export async function getUploadFilesPagination(projectId, page = 1, pageSize = 10, fileName) { try { const whereClause = { projectId, fileName: { contains: fileName } }; const [data, total] = await Promise.all([ db.uploadFiles.findMany({ where: whereClause, orderBy: { createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }), db.uploadFiles.count({ where: whereClause }) ]); return { data, total }; } catch (error) { console.error('Failed to get uploadFiles by pagination in database'); throw error; } } export async function getUploadFileInfoById(fileId) { try { return await db.uploadFiles.findUnique({ where: { id: fileId } }); } catch (error) { console.error('Failed to get uploadFiles by id in database'); throw error; } } export async function getUploadFilesByProjectId(projectId) { try { return await db.uploadFiles.findMany({ where: { projectId, NOT: { id: { in: await db.chunks .findMany({ where: { projectId }, select: { fileId: true } }) .then(chunks => chunks.map(chunk => chunk.fileId)) } } } }); } catch (error) { console.error('Failed to get uploadFiles by id in database'); throw error; } } export async function checkUploadFileInfoByMD5(projectId, md5) { try { return await db.uploadFiles.findFirst({ where: { projectId, md5 } }); } catch (error) { console.error('Failed to check uploadFiles by md5 in database'); throw error; } } export async function createUploadFileInfo(fileInfo) { try { return await db.uploadFiles.create({ data: fileInfo }); } catch (error) { console.error('Failed to get uploadFiles by id in database'); throw error; } } export async function delUploadFileInfoById(fileId) { try { // 1. 获取文件信息 let fileInfo = await db.uploadFiles.findUnique({ where: { id: fileId } }); if (!fileInfo) { throw new Error('File not found'); } // 2. 获取与文件关联的所有文本块 const chunks = await db.chunks.findMany({ where: { fileId: fileId } }); // 记录统计数据,用于返回给前端显示 const chunkIds = chunks.map(chunk => chunk.id); const stats = { chunks: chunks.length, questions: 0, datasets: 0 }; // 3. 找出所有关联的问题和数据集 let questionIds = []; let datasets = []; if (chunkIds.length > 0) { // 统计问题数量 const questionsCount = await db.questions.count({ where: { chunkId: { in: chunkIds } } }); stats.questions = questionsCount; // 获取所有问题ID const questions = await db.questions.findMany({ where: { chunkId: { in: chunkIds } }, select: { id: true } }); questionIds = questions.map(q => q.id); // 4. 统计数据集数量 if (questionIds.length > 0) { const datasetsCount = await db.datasets.count({ where: { questionId: { in: questionIds } } }); stats.datasets = datasetsCount; // 获取所有数据集 datasets = await db.datasets.findMany({ where: { questionId: { in: questionIds } }, select: { id: true } }); } } // 5. 使用事务批量删除所有数据库数据 // 按照外键依赖关系从外到内删除 const deleteOperations = []; // 先删除数据集 if (datasets.length > 0) { deleteOperations.push( db.datasets.deleteMany({ where: { id: { in: datasets.map(d => d.id) } } }) ); } // 再删除问题 if (questionIds.length > 0) { deleteOperations.push( db.questions.deleteMany({ where: { id: { in: questionIds } } }) ); } // 然后删除文本块 if (chunkIds.length > 0) { deleteOperations.push( db.chunks.deleteMany({ where: { id: { in: chunkIds } } }) ); } // 最后删除文件记录 deleteOperations.push( db.uploadFiles.delete({ where: { id: fileId } }) ); // 执行数据库事务,确保原子性 await db.$transaction(deleteOperations); // 6. 删除文件系统中的文件 let projectPath = path.join(fileInfo.path, fileInfo.fileName); if (fileInfo.fileExt !== '.md') { let filePath = path.join(fileInfo.path, fileInfo.fileName.replace(/\.[^/.]+$/, '.md')); if (fs.existsSync(filePath)) { await fs.promises.rm(filePath, { recursive: true }); } } if (fs.existsSync(projectPath)) { await fs.promises.rm(projectPath, { recursive: true }); } return { success: true, stats, fileName: fileInfo.fileName, fileInfo }; } catch (error) { console.error('Failed to delete uploadFiles by id in database:', error); throw error; } } ================================================ FILE: lib/file/file-process/check-file.js ================================================ import { FILE } from '@/constant'; /** * 检查文件大小 */ export function checkMaxSize(files) { const oversizedFiles = files.filter(file => file.size > FILE.MAX_FILE_SIZE); if (oversizedFiles.length > 0) { throw new Error(`Max 50MB: ${oversizedFiles.map(f => f.name).join(', ')}`); } } /** * 获取可以上传的文件 * @param {*} files * @returns */ export function getvalidFiles(files) { return files.filter( file => file.name.endsWith('.md') || file.name.endsWith('.txt') || file.name.endsWith('.docx') || file.name.endsWith('.pdf') || file.name.endsWith('.epub') ); } /** * 检查不能上传的文件 * @param {*} files * @returns */ export function checkInvalidFiles(files) { const invalidFiles = files.filter( file => !file.name.endsWith('.md') && !file.name.endsWith('.txt') && !file.name.endsWith('.docx') && !file.name.endsWith('.pdf') && !file.name.endsWith('.epub') ); if (invalidFiles.length > 0) { throw new Error(`Unsupported File Format: ${invalidFiles.map(f => f.name).join(', ')}`); } return invalidFiles; } ================================================ FILE: lib/file/file-process/epub/index.js ================================================ import JSZip from 'jszip'; import { DOMParser } from 'xmldom'; import TurndownService from 'turndown'; /** * 处理 EPUB 文件,提取文本内容并转换为 Markdown * @param {ArrayBuffer} arrayBuffer - EPUB 文件的二进制数据 * @returns {Promise} - 转换后的 Markdown 内容 */ export async function processEpub(arrayBuffer) { try { const zip = new JSZip(); const epub = await zip.loadAsync(arrayBuffer); // 1. 读取 META-INF/container.xml 获取 OPF 文件路径 const containerXml = await epub.file('META-INF/container.xml').async('text'); const containerDoc = new DOMParser().parseFromString(containerXml, 'text/xml'); const opfPath = containerDoc.getElementsByTagName('rootfile')[0].getAttribute('full-path'); // 2. 读取 OPF 文件获取章节信息 const opfContent = await epub.file(opfPath).async('text'); const opfDoc = new DOMParser().parseFromString(opfContent, 'text/xml'); // 获取 manifest 中的所有项目 const manifestItems = Array.from(opfDoc.getElementsByTagName('item')); const spineItems = Array.from(opfDoc.getElementsByTagName('itemref')); // 3. 按照 spine 顺序获取章节文件 const chapters = []; for (const spineItem of spineItems) { const idref = spineItem.getAttribute('idref'); const manifestItem = manifestItems.find(item => item.getAttribute('id') === idref); if (manifestItem && manifestItem.getAttribute('media-type') === 'application/xhtml+xml') { const href = manifestItem.getAttribute('href'); const chapterPath = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) + href : href; try { const chapterContent = await epub.file(chapterPath).async('text'); chapters.push({ title: getChapterTitle(chapterContent), content: chapterContent, path: chapterPath }); } catch (error) { console.warn(`无法读取章节文件: ${chapterPath}`, error); } } } // 4. 转换为 Markdown const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); // 配置 turndown 规则 turndownService.addRule('removeStyles', { filter: ['style', 'script'], replacement: () => '' }); let markdownContent = ''; // 添加书籍标题 const title = getBookTitle(opfDoc); if (title) { markdownContent += `# ${title}\n\n`; } // 转换每个章节 for (const chapter of chapters) { if (chapter.title && chapter.title !== title) { markdownContent += `## ${chapter.title}\n\n`; } // 提取正文内容 const bodyContent = extractBodyContent(chapter.content); const chapterMarkdown = turndownService.turndown(bodyContent); // 清理多余的空行 const cleanedMarkdown = chapterMarkdown.replace(/\n{3,}/g, '\n\n').trim(); if (cleanedMarkdown) { markdownContent += cleanedMarkdown + '\n\n'; } } return markdownContent.trim(); } catch (error) { console.error('处理 EPUB 文件时出错:', error); throw new Error(`EPUB 文件处理失败: ${error.message}`); } } /** * 从 OPF 文件中获取书籍标题 */ function getBookTitle(opfDoc) { try { const titleElements = opfDoc.getElementsByTagName('dc:title'); if (titleElements.length > 0) { return titleElements[0].textContent.trim(); } const titleElements2 = opfDoc.getElementsByTagName('title'); if (titleElements2.length > 0) { return titleElements2[0].textContent.trim(); } } catch (error) { console.warn('获取书籍标题失败:', error); } return null; } /** * 从章节内容中提取标题 */ function getChapterTitle(htmlContent) { try { const doc = new DOMParser().parseFromString(htmlContent, 'text/html'); // 尝试从 title 标签获取 const titleElement = doc.getElementsByTagName('title')[0]; if (titleElement && titleElement.textContent.trim()) { return titleElement.textContent.trim(); } // 尝试从第一个 h1-h6 标签获取 for (let i = 1; i <= 6; i++) { const headings = doc.getElementsByTagName(`h${i}`); if (headings.length > 0 && headings[0].textContent.trim()) { return headings[0].textContent.trim(); } } // 尝试从第一个段落获取(如果很短的话) const paragraphs = doc.getElementsByTagName('p'); if (paragraphs.length > 0) { const firstParagraph = paragraphs[0].textContent.trim(); if (firstParagraph.length < 100) { return firstParagraph; } } } catch (error) { console.warn('提取章节标题失败:', error); } return null; } /** * 从 HTML 内容中提取 body 部分 */ function extractBodyContent(htmlContent) { try { const doc = new DOMParser().parseFromString(htmlContent, 'text/html'); const bodyElement = doc.getElementsByTagName('body')[0]; if (bodyElement) { // 移除不需要的元素 const elementsToRemove = ['script', 'style', 'nav', 'header', 'footer']; elementsToRemove.forEach(tagName => { const elements = bodyElement.getElementsByTagName(tagName); for (let i = elements.length - 1; i >= 0; i--) { elements[i].parentNode.removeChild(elements[i]); } }); return bodyElement.innerHTML || bodyElement.textContent; } // 如果没有 body 标签,返回整个内容 return htmlContent; } catch (error) { console.warn('提取正文内容失败:', error); return htmlContent; } } ================================================ FILE: lib/file/file-process/get-content.js ================================================ import TurndownService from 'turndown'; import mammoth from 'mammoth'; import { processEpub } from './epub'; /** * 获取文件内容 * @param {*} file */ export async function getContent(file) { let fileContent; let fileName = file.name; // 如果是 docx 文件,先转换为 markdown if (file.name.endsWith('.docx')) { const arrayBuffer = await file.arrayBuffer(); const htmlResult = await mammoth.convertToHtml( { arrayBuffer }, { convertImage: image => { return mammoth.docx.paragraph({ children: [ mammoth.docx.textRun({ text: '' }) ] }); } } ); const turndownService = new TurndownService(); fileContent = turndownService.turndown(htmlResult.value); fileName = file.name.replace('.docx', '.md'); } else if (file.name.endsWith('.epub')) { // 如果是 epub 文件,转换为 markdown const arrayBuffer = await file.arrayBuffer(); fileContent = await processEpub(arrayBuffer); fileName = file.name.replace('.epub', '.md'); } else { // 对于 md 和 txt 文件,直接读取内容 const reader = new FileReader(); fileContent = await new Promise((resolve, reject) => { reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsArrayBuffer(file); }); fileName = file.name.replace('.txt', '.md'); } return { fileContent, fileName }; } ================================================ FILE: lib/file/file-process/index.js ================================================ export * from './get-content'; export * from './check-file'; export * from './utils'; ================================================ FILE: lib/file/file-process/pdf/default.js ================================================ import pdf2md from '@opendocsg/pdf2md'; import { getProjectRoot } from '@/lib/db/base'; import fs from 'fs'; import path from 'path'; export async function defaultProcessing(projectId, fileName) { console.log('executing default pdf conversion strategy......'); // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); // 获取文件路径 const filePath = path.join(projectPath, 'files', fileName); // 读取文件 const pdfBuffer = fs.readFileSync(filePath); // 转换后文件名 const convertName = fileName.replace(/\.([^.]*)$/, '') + '.md'; try { const text = await pdf2md(pdfBuffer); const outputFile = path.join(projectPath, 'files', convertName); console.log(`Writing to ${outputFile}...`); fs.writeFileSync(path.resolve(outputFile), text); console.log('pdf conversion completed!'); // 返回转换后的文件名 return { success: true, fileName: convertName }; } catch (err) { console.error('pdf conversion failed:', err); throw err; } } export default { defaultProcessing }; ================================================ FILE: lib/file/file-process/pdf/index.js ================================================ import { defaultProcessing } from './default'; import { minerUProcessing } from './mineru'; import { visionProcessing } from './vision'; import { minerULocalProcessing } from './mineru-local'; /** * PDF处理服务入口 * @param {string} projectId 项目ID * @param {string} fileName 文件名 * @param {Object} options 处理选项 * @param {string} strategy 处理策略,可选值: 'default', 'mineru', 'vision' * @returns {Promise} 处理结果 */ export async function processPdf(strategy = 'default', projectId, fileName, options = {}) { switch (strategy.toLowerCase()) { case 'default': return await defaultProcessing(projectId, fileName, options); case 'mineru': return await minerUProcessing(projectId, fileName, options); case 'vision': return await visionProcessing(projectId, fileName, options); case 'mineru-local': return await minerULocalProcessing(projectId, fileName, options); default: throw new Error(`unsupported PDF processing strategy: ${strategy}`); } } export default { processPdf }; export * from './util'; ================================================ FILE: lib/file/file-process/pdf/mineru-local.js ================================================ import http from 'http'; import https from 'https'; import { getProjectRoot } from '@/lib/db/base'; import fs from 'fs'; import path from 'path'; const MINERU_BASE_URL = 'file_parse'; export async function minerULocalProcessing(projectId, fileName, options = {}) { console.log('executing pdf mineru local conversion strategy......'); const { updateTask, task, message } = options; let taskCompletedCount = task.completedCount; // 获取项目路径 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filePath = path.join(projectPath, 'files', fileName); // 读取任务配置 const taskConfigPath = path.join(projectPath, 'task-config.json'); let taskConfig; try { await fs.promises.access(taskConfigPath); const taskConfigData = await fs.promises.readFile(taskConfigPath, 'utf8'); taskConfig = JSON.parse(taskConfigData); } catch (error) { console.error('Error getting MinerU token configuration:', error); throw new Error('Token configuration not found, please check if MinerU token is configured in task settings'); } const url = taskConfig?.minerULocalUrl; if (url === undefined || url === null || url === '') { throw new Error( 'MinerU local URL configuration not found, please check if MinerU local URL is configured in task settings' ); } const uploadUrl = url.endsWith('/') ? url + MINERU_BASE_URL : url + '/' + MINERU_BASE_URL; try { message.current.processedPage = parseInt(message.current.totalPage / 2) + 1; await updateTask(task.id, { detail: JSON.stringify(message) }); const uploadResponse = await processingFile(filePath, uploadUrl); //返回的结果是字符串,先转成json const jsonContent = JSON.parse(uploadResponse); const resultKey = Object.keys(jsonContent.results)[0]; const mdContent = jsonContent.results[resultKey].md_content; const outputPath = filePath.replace('.pdf', '.md'); fs.writeFileSync(outputPath, mdContent); } catch (error) { console.error('Error writing file:', error); throw error; } console.log('minerU local url processing completed'); return { success: true }; } /** * 将文件传送到本地mineru解析 */ async function processingFile(filePath, uploadUrl) { return new Promise((resolve, reject) => { const isHttps = uploadUrl.startsWith('https'); const url = new URL(uploadUrl); const client = url.protocol === 'https:' ? https : http; const FormData = require('form-data'); const form = new FormData(); form.append('files', fs.createReadStream(filePath)); const options = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: `${url.pathname}${url.search}`, method: 'POST', headers: form.getHeaders() }; const req = client.request(options, res => { let responseData = ''; res.on('data', chunk => { responseData += chunk; }); res.on('end', () => { if (res.statusCode === 200) { resolve(responseData); } else { reject(new Error(`Upload failed with status ${res.statusCode}: ${responseData}`)); } }); }); req.on('error', error => { reject(error); }); form.pipe(req); }); } export default { minerULocalProcessing }; ================================================ FILE: lib/file/file-process/pdf/mineru.js ================================================ import http from 'http'; import https from 'https'; import AdmZip from 'adm-zip'; import { getProjectRoot } from '@/lib/db/base'; import fs from 'fs'; import path from 'path'; // 常量定义 const MINERU_API_BASE = 'https://mineru.net/api/v4'; const POLL_INTERVAL = 3000; // 3秒 const MAX_POLL_ATTEMPTS = 90; // 最多尝试90次 const PROCESSING_STATES = { DONE: 'done', FAILED: 'failed' }; export async function minerUProcessing(projectId, fileName, options = {}) { console.log('executing pdf mineru conversion strategy......'); try { const { updateTask, task, message } = options; let taskCompletedCount = task.completedCount; // 获取项目路径 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filePath = path.join(projectPath, 'files', fileName); // 读取任务配置 const taskConfigPath = path.join(projectPath, 'task-config.json'); let taskConfig; try { await fs.promises.access(taskConfigPath); const taskConfigData = await fs.promises.readFile(taskConfigPath, 'utf8'); taskConfig = JSON.parse(taskConfigData); } catch (error) { console.error('error getting mineru token configuration:', error); throw new Error('token configuration not found, please check if mineru token is configured in task settings'); } const key = taskConfig?.minerUToken; if (key === undefined || key === null || key === '') { throw new Error('token configuration not found, please check if mineru token is configured in task settings'); } // 准备请求选项 const requestOptions = JSON.stringify({ enable_formula: true, layout_model: 'doclayout_yolo', enable_table: true, files: [{ name: fileName, is_ocr: true, data_id: 'abcd' }] }); // 1. 获取文件上传地址 console.log('mineru getting file upload url...'); const urlResponse = await makeHttpRequest(`${MINERU_API_BASE}/file-urls/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestOptions), Authorization: `Bearer ${key}` }, body: requestOptions }); if (urlResponse.code !== 0 || !urlResponse.data?.file_urls?.[0]) { throw new Error('failed to get file upload url: ' + JSON.stringify(urlResponse)); } //上传文件后会自动执行任务 let batchId = null; let uploadUrl = null; console.log('mineru executing file upload task...'); if (urlResponse.code == 0) { //上传文件地址 uploadUrl = urlResponse.data?.file_urls?.[0]; //此次任务id batchId = urlResponse.data?.batch_id; } // 2. 上传文件 await uploadFile(filePath, uploadUrl); console.log('mineru file upload completed!'); // 3. 轮询查询转换状态 console.log('mineru starting to check task progress...'); let currentPage = 0; let totalPage = 0; while (true) { try { //查询任务进度API const resultResponse = await makeHttpRequest(`${MINERU_API_BASE}/extract-results/batch/${batchId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` } }); // 任务状态 const currentState = resultResponse.data?.extract_result?.[0]?.state; const extract_progress = resultResponse.data?.extract_result?.[0]?.extract_progress; if (extract_progress) { // 任务进度 currentPage = extract_progress.extracted_pages; // 总页数 totalPage = extract_progress.total_pages; } else { currentPage = totalPage; } message.current.processedPage = currentPage; message.stepInfo = `processing ${fileName} ${currentPage}/${totalPage} pages progress: ${(currentPage / totalPage) * 100}%`; //更新任务状态 await updateTask(task.id, { completedCount: currentPage + taskCompletedCount, detail: JSON.stringify(message) }); console.log(`mineru ${fileName} current progress: ${currentPage}/${totalPage}, status: ${currentState}`); //解析成功结束回写状态定时器 if (resultResponse.code === 0 && currentState === PROCESSING_STATES.DONE) { const zipUrl = resultResponse.data.extract_result[0].full_zip_url; const savePath = path.join(projectPath, 'files'); await downloadAndExtractZip(zipUrl, savePath, fileName); break; } // 检查是否失败 if (resultResponse.code !== 0 || currentState === PROCESSING_STATES.FAILED) { throw new Error(`task processing failed: ${JSON.stringify(resultResponse)}`); } // 等待下次轮询 await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); } catch (error) { throw error; } } console.log('mineru pdf conversion completed!'); return { success: true }; } catch (error) { console.error('mineru api call error:', error); throw error; } } /** * 发送 HTTP 请求 */ async function makeHttpRequest(url, options) { return new Promise((resolve, reject) => { const isHttps = url.startsWith('https'); const client = isHttps ? https : http; const urlObj = new URL(url); const requestOptions = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: `${urlObj.pathname}${urlObj.search}`, method: options.method, headers: options.headers }; const req = client.request(requestOptions, res => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { try { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(JSON.parse(data)); } else { reject(new Error(`request failed, status code: ${res.statusCode}, response: ${data}`)); } } catch (error) { reject(new Error('failed to parse response')); } }); }); req.on('error', error => { reject(error); }); if (options.body) { req.write(options.body); } req.end(); }); } /** * 上传文件至MinerU指定地址 */ async function uploadFile(filePath, uploadUrl) { return new Promise((resolve, reject) => { const isHttps = uploadUrl.startsWith('https'); const url = new URL(uploadUrl); const client = url.protocol === 'https:' ? https : http; const fileStream = fs.createReadStream(filePath); const options = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: `${url.pathname}${url.search}`, method: 'PUT' }; const req = client.request(options, res => { let responseData = ''; res.on('data', chunk => { responseData += chunk; }); res.on('end', () => { if (res.statusCode === 200) { resolve(responseData); } else { reject(new Error(`Upload failed with status ${res.statusCode}: ${responseData}`)); } }); }); req.on('error', error => { reject(error); }); fileStream.pipe(req); }); } /** * 获取任务执行完成后的压缩包,仅解压md文件 */ async function downloadAndExtractZip(zipUrl, targetDir, fileName) { // 创建目标目录 if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } // 下载 ZIP 文件到内存 const zipBuffer = await new Promise((resolve, reject) => { https.get(zipUrl, res => { const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => resolve(Buffer.concat(chunks))); res.on('error', reject); }); }); // 解压到目标目录 const zip = new AdmZip(zipBuffer); const zipEntries = zip.getEntries(); zipEntries.forEach(entry => { if (entry.entryName.toLowerCase().endsWith('.md')) { // 获取文件内容为 Buffer const content = zip.readFile(entry); // 尝试用 UTF-8 解码,如果失败则尝试其他编码 const text = content.toString('utf8'); // 创建输出文件路径 const outputPath = path.join(targetDir, fileName.replace('.pdf', '.md')); // 写入文件,确保使用 UTF-8 编码 fs.writeFileSync(outputPath, text, { encoding: 'utf8' }); console.log(`extracted to directory: ${outputPath}`); } }); } export default { minerUProcessing }; ================================================ FILE: lib/file/file-process/pdf/prompt/optimalTitle.js ================================================ module.exports = function reTitlePrompt() { return ` 你是一个专业的文本结构化处理助手,擅长根据前缀规则和标题语义分析并优化Markdown文档的标题层级结构。请根据以下要求处理我提供的Markdown标题: ## 任务描述 请根据markdown文章标题的实际含义,以及标题的前缀特征调整各级标题的正确层级关系,具体要求如下: 1. 一般相同格式的前缀的标题是同级关系({title}代表实际的标题内容): 例如: 纯数字前缀开头\`1 {title}\`, \` 2 {title}\` ,\` 3 {title}\`,\` 4 {title}\`,\` 5 {title}\` ... 等 罗马数字前缀开头的\`I {title}\`,\`II {title}\` ,\`III {title}\`,\`IV {title}\`,\`V {title}\` ... 等 小数点分隔数组前缀开头 \`1.1 {title}\`, \`1.2 {title}\`, \`1.3 {title}\`.... \`2.1 {title}\`, \`2.2 {title}\` 等 2. 将子标题正确嵌套到父标题下(如\`1.1 {title}\`应作为\`1 {title}\`的子标题) 3. 剔除与文章内容无关的标题 4. 保持输出标题内容与输入完全一致 5. 确保内容无缺失 6. 如果是中文文献,但有英文的文章题目,可以省略 ## 输入输出格式 - 输入:包含错误层级关系的markdown标题结构 - 输出:修正后的标准markdown标题层级结构 ## 处理原则 1. 严格根据标题语义确定所属关系 2. 仅调整层级不修改原标题文本 3. 无关标题直接移除不保留占位 4. 相同前缀规则的标题必须是同一级别,不能出现 一部分是 n级标题,一部分是其他级别的标题 ## 输出要求 请将修正后的完整标题结构放在代码块中返回,格式示例如下: 期望输出: \`\`\`markdown \`\`\` 请处理以下数据: `; }; ================================================ FILE: lib/file/file-process/pdf/prompt/optimalTitleEn.js ================================================ module.exports = function reTitlePromptEn() { return ` You are a professional text structuring assistant specializing in analyzing and optimizing the hierarchical structure of Markdown document titles based on prefix rules and semantic analysis. Please process the Markdown titles I provide according to the following requirements: ## Task Description Adjust the correct hierarchical relationships of titles based on the actual meaning of the Markdown article titles and the prefix characteristics of the titles. The specific requirements are as follows: 1. Titles with the same prefix format are generally at the same level ({title} represents the actual title content): For example: - Titles starting with pure number prefixes: \`1 {title}\`, \`2 {title}\`, \`3 {title}\`, \`4 {title}\`, \`5 {title}\`, etc. - Titles starting with Roman numeral prefixes: \`I {title}\`, \`II {title}\`, \`III {title}\`, \`IV {title}\`, \`V {title}\`, etc. - Titles starting with decimal-separated array prefixes: \`1.1 {title}\`, \`1.2 {title}\`, \`1.3 {title}\`, ..., \`2.1 {title}\`, \`2.2 {title}\`, etc. 2. Correctly nest sub-titles under parent titles (e.g., \`1.1 {title}\` should be a sub-title of \`1 {title}\`). 3. Remove titles unrelated to the content of the article. 4. Keep the content of the output titles identical to the input. 5. Ensure no content is missing. 6. For Chinese literature with English article titles, the English titles can be omitted. ## Input and Output Format - Input: Markdown title structure with incorrect hierarchical relationships. - Output: Corrected standard Markdown title hierarchical structure. ## Processing Principles 1. Strictly determine the hierarchical relationship based on the semantic meaning of the titles. 2. Adjust only the hierarchy without modifying the original title text. 3. Directly remove unrelated titles without retaining placeholders. 4. Titles with the same prefix rules must be at the same level; they cannot be partially at one level and partially at another. ## Output Requirements Please return the corrected complete title structure within a code block, formatted as follows: Expected Output: \`\`\`markdown \`\`\` Please process the following data: `; }; ================================================ FILE: lib/file/file-process/pdf/prompt/pdfToMarkdown.js ================================================ module.exports = function convertPrompt() { return ` 使用markdown语法,将图片中识别到的文字转换为markdown格式输出。你必须做到: 1. 输出和使用识别到的图片的相同的语言,例如,识别到英语的字段,输出的内容必须是英语。 2. 不要解释和输出无关的文字,直接输出图片中的内容。 3. 内容不要包含在\`\`\`markdown \`\`\`中、段落公式使用 $$ $$ 的形式、行内公式使用 $ $ 的形式。 4. 忽略掉页眉页脚里的内容 5. 请不要对图片的标题进行markdown的格式化,直接以文本形式输出到内容中。 6. 有可能每页都会出现期刊名称,论文名称,会议名称或者书籍名称,请忽略他们不要识别成标题 7. 请精确分析当前PDF页面的文本结构和视觉布局,按以下要求处理: 1. 识别所有标题文本,并判断其层级(根据字体大小、加粗、位置等视觉特征) 2. 输出为带层级的Markdown格式,严格使用以下规则: - 一级标题:字体最大/顶部居中,前面加 # - 二级标题:字体较大/左对齐加粗,有可能是数字开头也有可能是罗马数组开头,前面加 ## - 三级标题:字体稍大/左对齐加粗,前面加 ### - 正文文本:直接转换为普通段落 3. 不确定层级的标题请标记[?] 4. 如果是中文文献,但是有英文标题和摘要可以省略不输出 示例输出: ## 4研究方法 ### 4.1数据收集 本文采用问卷调查... `; }; ================================================ FILE: lib/file/file-process/pdf/prompt/pdfToMarkdownEn.js ================================================ module.exports = function convertPromptEn() { return ` Use Markdown syntax to convert the text extracted from images into Markdown format and output it. You must adhere to the following requirements: 1. Output in the same language as the text extracted from the image. For example, if the extracted text is in English, the output must also be in English. 2. Do not explain or output any text unrelated to the content. Directly output the text from the image. 3. Do not enclose the content within \`\`\`markdown \`\`\`. Use $$ $$ for block equations and $ $ for inline equations. 4. Ignore content in headers and footers. 5. Do not format the titles from images using Markdown; output them as plain text within the content. 6. Journal names, paper titles, conference names, or book titles that may appear on each page should be ignored and not treated as headings. 7. Precisely analyze the text structure and visual layout of the current PDF page, and process it as follows: 1. Identify all heading texts and determine their hierarchy based on visual features such as font size, boldness, and position. 2. Output the text in hierarchical Markdown format, strictly following these rules: - Level 1 headings: Largest font size, centered at the top, prefixed with # - Level 2 headings: Larger font size, left-aligned and bold, possibly starting with numbers or Roman numerals, prefixed with ## - Level 3 headings: Slightly larger font size, left-aligned and bold, prefixed with ### - Body text: Convert directly into regular paragraphs 3. For headings with uncertain hierarchy, mark them with [?]. 4. For Chinese literature with English titles and abstracts, these can be omitted from the output. Example Output: ## 4 Research Methods ### 4.1 Data Collection This paper uses questionnaires... `; }; ================================================ FILE: lib/file/file-process/pdf/util.js ================================================ import { getProjectRoot } from '@/lib/db/base'; import path from 'path'; export async function getFilePageCount(projectId, fileList) { const projectRoot = await getProjectRoot(); let totalPages = 0; for (const file of fileList) { if (file.fileName.endsWith('.pdf')) { const { getPageNum } = await import('pdf2md-js'); const filePath = path.join(projectRoot, projectId, 'files', file.fileName); try { const pageCount = await getPageNum(filePath); totalPages += pageCount; file.pageCount = pageCount; } catch (error) { console.error(`Failed to get page count for ${file.fileName}:`, error); } } else { totalPages += 1; file.pageCount = 1; } } console.log(`Total pages to process: ${totalPages}`); return totalPages; } ================================================ FILE: lib/file/file-process/pdf/vision.js ================================================ import { getProjectRoot } from '@/lib/db/base'; import { getTaskConfig } from '@/lib/db/projects'; import convertPrompt from './prompt/pdfToMarkdown'; import convertPromptEn from './prompt/pdfToMarkdownEn'; import reTitlePrompt from './prompt/optimalTitle'; import reTitlePromptEn from './prompt/optimalTitleEn'; import path from 'path'; export async function visionProcessing(projectId, fileName, options = {}) { try { const { updateTask, task, message } = options; let taskCompletedCount = task.completedCount; console.log('executing vision conversion strategy......'); // 获取项目路径 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const filePath = path.join(projectPath, 'files', fileName); // 获取项目配置 const taskConfig = await getTaskConfig(projectId); const model = task.vsionModel; if (!model) { throw new Error('please check if pdf conversion vision model is configured'); } if (model.type !== 'vision') { throw new Error( `${model.modelName}(${model.providerName}) this model is not a vision model, please check [model configuration]` ); } if (!model.apiKey) { throw new Error( `${model.modelName}(${model.providerName}) this model has no api key configured, please check [model configuration]` ); } const convert = task.language === 'en' ? convertPromptEn : convertPrompt; const reTitle = task.language === 'en' ? reTitlePromptEn : reTitlePrompt; //创建临时文件夹分割不同任务产生的临时图片文件,防止同时读写一个文件夹,导致内容出错 const config = { pdfPath: filePath, outputDir: path.join(projectPath, 'files'), apiKey: model.apiKey, model: model.modelId, baseUrl: model.endpoint, useFullPage: true, verbose: false, concurrency: taskConfig.visionConcurrencyLimit, prompt: convert(), textPrompt: reTitle(), onProgress: async ({ current, total, taskStatus }) => { if (updateTask && task.id) { message.current.processedPage = current; message.setpInfo = `processing ${fileName} ${current}/${total} pages progress: ${(current / total) * 100}% `; await updateTask(task.id, { completedCount: taskCompletedCount + current, detail: JSON.stringify(message) }); } } }; console.log('vision strategy: starting pdf file processing'); const { parsePdf } = await import('pdf2md-js'); await parsePdf(filePath, config); //转换结束 return { success: true }; } catch (error) { console.error('vision strategy processing error:', error); throw error; } } export default { visionProcessing }; ================================================ FILE: lib/file/file-process/utils.js ================================================ /** * 名字太长影响 UI 显示,截取文件名 * @param {*} filename */ export function handleLongFileName(filename) { if (filename.length <= 13) { return filename; } const front = filename.substring(0, 7); const back = filename.substring(filename.length - 5); return `${front}···${back}`; } ================================================ FILE: lib/file/split-markdown/core/parser.js ================================================ /** * Markdown文档解析模块 */ /** * 提取文档大纲 * @param {string} text - Markdown文本 * @returns {Array} - 提取的大纲数组 */ function extractOutline(text) { const outlineRegex = /^(#{1,6})\s+(.+?)(?:\s*\{#[\w-]+\})?\s*$/gm; const outline = []; let match; while ((match = outlineRegex.exec(text)) !== null) { const level = match[1].length; const title = match[2].trim(); outline.push({ level, title, position: match.index }); } return outline; } /** * 根据标题分割文档 * @param {string} text - Markdown文本 * @param {Array} outline - 文档大纲 * @returns {Array} - 按标题分割的段落数组 */ function splitByHeadings(text, outline) { if (outline.length === 0) { return [ { heading: null, level: 0, content: text, position: 0 } ]; } const sections = []; // 添加第一个标题前的内容(如果有) if (outline[0].position > 0) { const frontMatter = text.substring(0, outline[0].position).trim(); if (frontMatter.length > 0) { sections.push({ heading: null, level: 0, content: frontMatter, position: 0 }); } } // 分割每个标题的内容 for (let i = 0; i < outline.length; i++) { const current = outline[i]; const next = i < outline.length - 1 ? outline[i + 1] : null; const headingLine = text.substring(current.position).split('\n')[0]; const startPos = current.position + headingLine.length + 1; const endPos = next ? next.position : text.length; let content = text.substring(startPos, endPos).trim(); sections.push({ heading: current.title, level: current.level, content: content, position: current.position }); } return sections; } module.exports = { extractOutline, splitByHeadings }; ================================================ FILE: lib/file/split-markdown/core/splitter.js ================================================ /** * Markdown文档分割模块 */ /** * 分割超长段落 * @param {Object} section - 段落对象 * @param {number} maxSplitLength - 最大分割字数 * @returns {Array} - 分割后的段落数组 */ function splitLongSection(section, maxSplitLength) { const content = section.content; const paragraphs = content.split(/\n\n+/); const result = []; let currentChunk = ''; for (const paragraph of paragraphs) { // 如果当前段落本身超过最大长度,可能需要进一步拆分 if (paragraph.length > maxSplitLength) { // 如果当前块不为空,先加入结果 if (currentChunk.length > 0) { result.push(currentChunk); currentChunk = ''; } // 对超长段落进行分割(例如,按句子或固定长度) const sentenceSplit = paragraph.match(/[^.!?。!?]+[.!?。!?]+/g) || [paragraph]; // 处理分割后的句子 let sentenceChunk = ''; for (const sentence of sentenceSplit) { if ((sentenceChunk + sentence).length <= maxSplitLength) { sentenceChunk += sentence; } else { if (sentenceChunk.length > 0) { result.push(sentenceChunk); } // 如果单个句子超过最大长度,可能需要进一步拆分 if (sentence.length > maxSplitLength) { // 简单地按固定长度分割 for (let i = 0; i < sentence.length; i += maxSplitLength) { result.push(sentence.substr(i, maxSplitLength)); } } else { sentenceChunk = sentence; } } } if (sentenceChunk.length > 0) { currentChunk = sentenceChunk; } } else if ((currentChunk + '\n\n' + paragraph).length <= maxSplitLength) { // 如果添加当前段落不超过最大长度,则添加到当前块 currentChunk = currentChunk.length > 0 ? currentChunk + '\n\n' + paragraph : paragraph; } else { // 如果添加当前段落超过最大长度,则将当前块加入结果,并重新开始一个新块 result.push(currentChunk); currentChunk = paragraph; } } // 添加最后一个块(如果有) if (currentChunk.length > 0) { result.push(currentChunk); } return result; } /** * 处理段落,根据最小和最大分割字数进行分割 * @param {Array} sections - 段落数组 * @param {Array} outline - 目录大纲 * @param {number} minSplitLength - 最小分割字数 * @param {number} maxSplitLength - 最大分割字数 * @returns {Array} - 处理后的段落数组 */ function processSections(sections, outline, minSplitLength, maxSplitLength) { // 预处理:将相邻的小段落合并 const preprocessedSections = []; let currentSection = null; for (const section of sections) { const contentLength = section.content.trim().length; if (contentLength < minSplitLength && currentSection) { // 如果当前段落小于最小长度且有累积段落,尝试合并 const mergedContent = `${currentSection.content}\n\n${section.heading ? `${'#'.repeat(section.level)} ${section.heading}\n` : ''}${section.content}`; if (mergedContent.length <= maxSplitLength) { // 如果合并后不超过最大长度,则合并 currentSection.content = mergedContent; if (section.heading) { currentSection.headings = currentSection.headings || []; currentSection.headings.push({ heading: section.heading, level: section.level, position: section.position }); } continue; } } // 如果无法合并,则开始新的段落 if (currentSection) { preprocessedSections.push(currentSection); } currentSection = { ...section, headings: section.heading ? [{ heading: section.heading, level: section.level, position: section.position }] : [] }; } // 添加最后一个段落 if (currentSection) { preprocessedSections.push(currentSection); } const result = []; let accumulatedSection = null; // 用于累积小于最小分割字数的段落 for (let i = 0; i < preprocessedSections.length; i++) { const section = preprocessedSections[i]; const contentLength = section.content.trim().length; // 检查是否需要累积段落 if (contentLength < minSplitLength) { // 如果还没有累积过段落,创建新的累积段落 if (!accumulatedSection) { accumulatedSection = { heading: section.heading, level: section.level, content: section.content, position: section.position, headings: [{ heading: section.heading, level: section.level, position: section.position }] }; } else { // 已经有累积段落,将当前段落添加到累积段落中 accumulatedSection.content += `\n\n${section.heading ? `${'#'.repeat(section.level)} ${section.heading}\n` : ''}${section.content}`; if (section.heading) { accumulatedSection.headings.push({ heading: section.heading, level: section.level, position: section.position }); } } // 只有当累积内容达到最小长度时才处理 const accumulatedLength = accumulatedSection.content.trim().length; if (accumulatedLength >= minSplitLength) { const summary = require('./summary').generateEnhancedSummary(accumulatedSection, outline); if (accumulatedLength > maxSplitLength) { // 如果累积段落超过最大长度,进一步分割 const subSections = splitLongSection(accumulatedSection, maxSplitLength); for (let j = 0; j < subSections.length; j++) { result.push({ summary: `${summary} - Part ${j + 1}/${subSections.length}`, content: subSections[j] }); } } else { // 添加到结果中 result.push({ summary, content: accumulatedSection.content }); } accumulatedSection = null; // 重置累积段落 } continue; } // 如果有累积的段落,先处理它 if (accumulatedSection) { const summary = require('./summary').generateEnhancedSummary(accumulatedSection, outline); const accumulatedLength = accumulatedSection.content.trim().length; if (accumulatedLength > maxSplitLength) { // 如果累积段落超过最大长度,进一步分割 const { result: subSections, lastChunk } = splitLongSection(accumulatedSection, maxSplitLength, minSplitLength); for (let j = 0; j < subSections.length; j++) { result.push({ summary: `${summary} - Part ${j + 1}/${subSections.length}`, content: subSections[j] }); } // 如果有未处理的小段落,保存下来等待下一次合并 if (lastChunk) { accumulatedSection = { ...accumulatedSection, content: lastChunk }; continue; } } else { // 添加到结果中 result.push({ summary, content: accumulatedSection.content }); } accumulatedSection = null; // 重置累积段落 } // 处理当前段落 // 如果段落长度超过最大分割字数,需要进一步分割 if (contentLength > maxSplitLength) { const subSections = splitLongSection(section, maxSplitLength); // 为当前段落创建一个标准的headings数组 if (!section.headings && section.heading) { section.headings = [{ heading: section.heading, level: section.level, position: section.position }]; } for (let i = 0; i < subSections.length; i++) { const subSection = subSections[i]; const summary = require('./summary').generateEnhancedSummary(section, outline, i + 1, subSections.length); result.push({ summary, content: subSection }); } } else { // 为当前段落创建一个标准的headings数组 if (!section.headings && section.heading) { section.headings = [{ heading: section.heading, level: section.level, position: section.position }]; } // 生成增强的摘要并添加到结果 const summary = require('./summary').generateEnhancedSummary(section, outline); const content = `${section.heading ? `${'#'.repeat(section.level)} ${section.heading}\n` : ''}${section.content}`; result.push({ summary, content }); } } // 处理最后剩余的小段落 if (accumulatedSection) { if (result.length > 0) { // 尝试将剩余的小段落与最后一个结果合并 const lastResult = result[result.length - 1]; const mergedContent = `${lastResult.content}\n\n${accumulatedSection.content}`; if (mergedContent.length <= maxSplitLength) { // 如果合并后不超过最大长度,则合并 const summary = require('./summary').generateEnhancedSummary( { ...accumulatedSection, content: mergedContent }, outline ); result[result.length - 1] = { summary, content: mergedContent }; } else { // 如果合并后超过最大长度,将accumulatedSection作为单独的段落添加,这里的contentLength一定小于maxSplitLength const summary = require('./summary').generateEnhancedSummary(accumulatedSection, outline); const content = `${accumulatedSection.heading ? `${'#'.repeat(accumulatedSection.level)} ${accumulatedSection.heading}\n` : ''}${accumulatedSection.content}`; result.push({ summary, content }); } } else { // 如果result为空,直接添加accumulatedSection const summary = require('./summary').generateEnhancedSummary(accumulatedSection, outline); const content = `${accumulatedSection.heading ? `${'#'.repeat(accumulatedSection.level)} ${accumulatedSection.heading}\n` : ''}${accumulatedSection.content}`; result.push({ summary, content }); } } return result; } module.exports = { splitLongSection, processSections }; ================================================ FILE: lib/file/split-markdown/core/summary.js ================================================ /** * 摘要生成模块 */ /** * 生成段落增强摘要,包含该段落中的所有标题 * @param {Object} section - 段落对象 * @param {Array} outline - 目录大纲 * @param {number} partIndex - 子段落索引(可选) * @param {number} totalParts - 子段落总数(可选) * @returns {string} - 生成的增强摘要 */ function generateEnhancedSummary(section, outline, partIndex = null, totalParts = null) { // 如果是文档前言 if ((!section.heading && section.level === 0) || (!section.headings && !section.heading)) { // 获取文档标题(如果存在) const docTitle = outline.length > 0 && outline[0].level === 1 ? outline[0].title : '文档'; return `${docTitle} 前言`; } // 如果有headings数组,使用它 if (section.headings && section.headings.length > 0) { // 按照级别和位置排序标题 const sortedHeadings = [...section.headings].sort((a, b) => { if (a.level !== b.level) return a.level - b.level; return a.position - b.position; }); // 构建所有标题包含的摘要 const headingsMap = new Map(); // 用于去重 // 首先处理每个标题,找到其完整路径 for (const heading of sortedHeadings) { // 跳过空标题 if (!heading.heading) continue; // 查找当前标题在大纲中的位置 const headingIndex = outline.findIndex(item => item.title === heading.heading && item.level === heading.level); if (headingIndex === -1) { // 如果在大纲中找不到,直接使用当前标题 headingsMap.set(heading.heading, heading.heading); continue; } // 查找所有上级标题 const pathParts = []; let parentLevel = heading.level - 1; for (let i = headingIndex - 1; i >= 0 && parentLevel > 0; i--) { if (outline[i].level === parentLevel) { pathParts.unshift(outline[i].title); parentLevel--; } } // 添加当前标题 pathParts.push(heading.heading); // 生成完整路径并存储到Map中 const fullPath = pathParts.join(' > '); headingsMap.set(fullPath, fullPath); } // 将所有标题路径转换为数组并按间隔符数量排序(表示层级深度) const paths = Array.from(headingsMap.values()).sort((a, b) => { const aDepth = (a.match(/>/g) || []).length; const bDepth = (b.match(/>/g) || []).length; return aDepth - bDepth || a.localeCompare(b); }); // 如果没有有效的标题,返回默认摘要 if (paths.length === 0) { return section.heading ? section.heading : '未命名段落'; } // 如果是单个标题,直接返回 if (paths.length === 1) { let summary = paths[0]; // 如果是分段的部分,添加Part信息 if (partIndex !== null && totalParts > 1) { summary += ` - Part ${partIndex}/${totalParts}`; } return summary; } // 如果有多个标题,生成多标题摘要 let summary = ''; // 尝试找到公共前缀 const firstPath = paths[0]; const segments = firstPath.split(' > '); for (let i = 0; i < segments.length - 1; i++) { const prefix = segments.slice(0, i + 1).join(' > '); let isCommonPrefix = true; for (let j = 1; j < paths.length; j++) { if (!paths[j].startsWith(prefix + ' > ')) { isCommonPrefix = false; break; } } if (isCommonPrefix) { summary = prefix + ' > ['; // 添加非公共部分 for (let j = 0; j < paths.length; j++) { const uniquePart = paths[j].substring(prefix.length + 3); // +3 为 ' > ' 的长度 summary += (j > 0 ? ', ' : '') + uniquePart; } summary += ']'; break; } } // 如果没有公共前缀,使用完整列表 if (!summary) { summary = paths.join(', '); } // 如果是分段的部分,添加Part信息 if (partIndex !== null && totalParts > 1) { summary += ` - Part ${partIndex}/${totalParts}`; } return summary; } // 兼容旧逻辑,当没有headings数组时 if (!section.heading && section.level === 0) { return '文档前言'; } // 查找当前段落在大纲中的位置 const currentHeadingIndex = outline.findIndex(item => item.title === section.heading && item.level === section.level); if (currentHeadingIndex === -1) { return section.heading ? section.heading : '未命名段落'; } // 查找所有上级标题 const parentHeadings = []; let parentLevel = section.level - 1; for (let i = currentHeadingIndex - 1; i >= 0 && parentLevel > 0; i--) { if (outline[i].level === parentLevel) { parentHeadings.unshift(outline[i].title); parentLevel--; } } // 构建摘要 let summary = ''; if (parentHeadings.length > 0) { summary = parentHeadings.join(' > ') + ' > '; } summary += section.heading; // 如果是分段的部分,添加Part信息 if (partIndex !== null && totalParts > 1) { summary += ` - Part ${partIndex}/${totalParts}`; } return summary; } /** * 旧的摘要生成函数,保留供兼容性使用 * @param {Object} section - 段落对象 * @param {Array} outline - 目录大纲 * @param {number} partIndex - 子段落索引(可选) * @param {number} totalParts - 子段落总数(可选) * @returns {string} - 生成的摘要 */ function generateSummary(section, outline, partIndex = null, totalParts = null) { return generateEnhancedSummary(section, outline, partIndex, totalParts); } module.exports = { generateEnhancedSummary, generateSummary }; ================================================ FILE: lib/file/split-markdown/core/toc.js ================================================ /** * Markdown目录提取模块 */ /** * 提取Markdown文档的目录结构 * @param {string} text - Markdown文本 * @param {Object} options - 配置选项 * @param {number} options.maxLevel - 提取的最大标题级别,默认为6 * @param {boolean} options.includeLinks - 是否包含锚点链接,默认为true * @param {boolean} options.flatList - 是否返回扁平列表,默认为false(返回嵌套结构) * @returns {Array} - 目录结构数组 */ function extractTableOfContents(text, options = {}) { const { maxLevel = 6, includeLinks = true, flatList = false } = options; // 匹配标题的正则表达式 const headingRegex = /^(#{1,6})\s+(.+?)(?:\s*\{#[\w-]+\})?\s*$/gm; const tocItems = []; let match; while ((match = headingRegex.exec(text)) !== null) { const level = match[1].length; // 如果标题级别超过了设定的最大级别,则跳过 if (level > maxLevel) { continue; } const title = match[2].trim(); const position = match.index; // 生成锚点ID(用于链接) const anchorId = generateAnchorId(title); tocItems.push({ level, title, position, anchorId, children: [] }); } // 如果需要返回扁平列表,直接返回处理后的结果 if (flatList) { return tocItems.map(item => { const result = { level: item.level, title: item.title, position: item.position }; if (includeLinks) { result.link = `#${item.anchorId}`; } return result; }); } // 构建嵌套结构 return buildNestedToc(tocItems, includeLinks); } /** * 生成标题的锚点ID * @param {string} title - 标题文本 * @returns {string} - 生成的锚点ID */ function generateAnchorId(title) { return title .toLowerCase() .replace(/\s+/g, '-') .replace(/[^\w\-]/g, '') .replace(/\-+/g, '-') .replace(/^\-+|\-+$/g, ''); } /** * 构建嵌套的目录结构 * @param {Array} items - 扁平的目录项数组 * @param {boolean} includeLinks - 是否包含链接 * @returns {Array} - 嵌套的目录结构 */ function buildNestedToc(items, includeLinks) { const result = []; const stack = [{ level: 0, children: result }]; items.forEach(item => { const tocItem = { title: item.title, level: item.level, position: item.position, children: [] }; if (includeLinks) { tocItem.link = `#${item.anchorId}`; } // 找到当前项的父级 while (stack[stack.length - 1].level >= item.level) { stack.pop(); } // 将当前项添加到父级的children中 stack[stack.length - 1].children.push(tocItem); // 将当前项入栈 stack.push(tocItem); }); return result; } /** * 将目录结构转换为Markdown格式 * @param {Array} toc - 目录结构(嵌套或扁平) * @param {Object} options - 配置选项 * @param {boolean} options.isNested - 是否为嵌套结构,默认为true * @param {boolean} options.includeLinks - 是否包含链接,默认为true * @returns {string} - Markdown格式的目录 */ function tocToMarkdown(toc, options = {}) { const { isNested = true, includeLinks = true } = options; if (isNested) { return nestedTocToMarkdown(toc, 0, includeLinks); } else { return flatTocToMarkdown(toc, includeLinks); } } /** * 将嵌套的目录结构转换为Markdown格式 * @private */ function nestedTocToMarkdown(items, indent = 0, includeLinks) { let result = ''; const indentStr = ' '.repeat(indent); // 添加数据验证 if (!Array.isArray(items)) { console.warn('Warning: items is not an array in nestedTocToMarkdown'); return result; } items.forEach(item => { const titleText = includeLinks && item.link ? `[${item.title}](${item.link})` : item.title; result += `${indentStr}- ${titleText}\n`; if (item.children && item.children.length > 0) { result += nestedTocToMarkdown(item.children, indent + 1, includeLinks); } }); return result; } /** * 将扁平的目录结构转换为Markdown格式 * @private */ function flatTocToMarkdown(items, includeLinks) { let result = ''; items.forEach(item => { const indent = ' '.repeat(item.level - 1); const titleText = includeLinks && item.link ? `[${item.title}](${item.link})` : item.title; result += `${indent}- ${titleText}\n`; }); return result; } module.exports = { extractTableOfContents, tocToMarkdown }; ================================================ FILE: lib/file/split-markdown/index.js ================================================ /** * Markdown文本分割工具主模块 */ const parser = require('./core/parser'); const splitter = require('./core/splitter'); const summary = require('./core/summary'); const formatter = require('./output/formatter'); const fileWriter = require('./output/fileWriter'); const toc = require('./core/toc'); /** * 拆分Markdown文档 * @param {string} markdownText - Markdown文本 * @param {number} minSplitLength - 最小分割字数 * @param {number} maxSplitLength - 最大分割字数 * @returns {Array} - 分割结果数组 */ function splitMarkdown(markdownText, minSplitLength, maxSplitLength) { // 解析文档结构 const outline = parser.extractOutline(markdownText); // 按标题分割文档 const sections = parser.splitByHeadings(markdownText, outline); // 处理段落,确保满足分割条件 const res = splitter.processSections(sections, outline, minSplitLength, maxSplitLength); return res.map(r => ({ result: `> **📑 Summarization:** *${r.summary}*\n\n---\n\n${r.content}`, ...r })); } // 导出模块功能 module.exports = { // 核心功能 splitMarkdown, combineMarkdown: formatter.combineMarkdown, saveToSeparateFiles: fileWriter.saveToSeparateFiles, // 目录提取功能 extractTableOfContents: toc.extractTableOfContents, tocToMarkdown: toc.tocToMarkdown, // 其他导出的子功能 parser, splitter, summary, formatter, fileWriter, toc }; ================================================ FILE: lib/file/split-markdown/output/fileWriter.js ================================================ /** * 文件输出模块 */ const fs = require('fs'); const path = require('path'); const { ensureDirectoryExists } = require('../utils/common'); /** * 将分割结果保存到单独的文件 * @param {Array} splitResult - 分割结果数组 * @param {string} baseFilename - 基础文件名(不包含扩展名) * @param {Function} callback - 回调函数 */ function saveToSeparateFiles(splitResult, baseFilename, callback) { // 获取基础目录和文件名(无扩展名) const basePath = path.dirname(baseFilename); const filenameWithoutExt = path.basename(baseFilename).replace(/\.[^/.]+$/, ''); // 创建用于存放分割文件的目录 const outputDir = path.join(basePath, `${filenameWithoutExt}_parts`); // 确保目录存在 ensureDirectoryExists(outputDir); // 递归保存文件 function saveFile(index) { if (index >= splitResult.length) { // 所有文件保存完成 callback(null, outputDir, splitResult.length); return; } const part = splitResult[index]; const paddedIndex = String(index + 1).padStart(3, '0'); // 确保文件排序正确 const outputFile = path.join(outputDir, `${filenameWithoutExt}_part${paddedIndex}.md`); // 将摘要和内容格式化为Markdown const content = `> **📑 Summarization:** *${part.summary}*\n\n---\n\n${part.content}`; fs.writeFile(outputFile, content, 'utf8', err => { if (err) { callback(err); return; } // 继续保存下一个文件 saveFile(index + 1); }); } // 开始保存文件 saveFile(0); } module.exports = { saveToSeparateFiles }; ================================================ FILE: lib/file/split-markdown/output/formatter.js ================================================ /** * 输出格式化模块 */ /** * 将分割后的文本重新组合成Markdown文档 * @param {Array} splitResult - 分割结果数组 * @returns {string} - 组合后的Markdown文档 */ function combineMarkdown(splitResult) { let result = ''; for (let i = 0; i < splitResult.length; i++) { const part = splitResult[i]; // 添加分隔线和摘要 if (i > 0) { result += '\n\n---\n\n'; } result += `> **📑 Summarization:** *${part.summary}*\n\n---\n\n${part.content}`; } return result; } module.exports = { combineMarkdown }; ================================================ FILE: lib/file/split-markdown/utils/common.js ================================================ /** * 通用工具函数模块 */ const fs = require('fs'); const path = require('path'); /** * 检查并创建目录 * @param {string} directory - 目录路径 */ function ensureDirectoryExists(directory) { if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } } /** * 从文件路径获取不带扩展名的文件名 * @param {string} filePath - 文件路径 * @returns {string} - 不带扩展名的文件名 */ function getFilenameWithoutExt(filePath) { return path.basename(filePath).replace(/\.[^/.]+$/, ''); } module.exports = { ensureDirectoryExists, getFilenameWithoutExt }; ================================================ FILE: lib/file/text-splitter.js ================================================ 'use server'; import fs from 'fs'; import path from 'path'; import { getProjectRoot, ensureDir } from '../db/base'; import { getProject } from '@/lib/db/projects'; import { getChunkByProjectId, saveChunks } from '@/lib/db/chunks'; const { TokenTextSplitter, CharacterTextSplitter, RecursiveCharacterTextSplitter } = require('@langchain/textsplitters'); const { Document } = require('@langchain/core/documents'); // 导入Markdown分割工具 const markdownSplitter = require('./split-markdown/index'); async function splitFileByType({ projectPath, fileContent, fileName, projectId, fileId }) { // 获取任务配置 const taskConfigPath = path.join(projectPath, 'task-config.json'); let taskConfig; try { await fs.promises.access(taskConfigPath); const taskConfigData = await fs.promises.readFile(taskConfigPath, 'utf8'); taskConfig = JSON.parse(taskConfigData); } catch (error) { taskConfig = { textSplitMinLength: 1500, textSplitMaxLength: 2000 }; } // 获取分割参数 const minLength = taskConfig.textSplitMinLength || 1500; const maxLength = taskConfig.textSplitMaxLength || 2000; const chunkSize = taskConfig.chunkSize || 1500; const chunkOverlap = taskConfig.chunkOverlap || 200; const separator = taskConfig.separator || '\n\n'; const separators = taskConfig.separators || ['|', '##', '>', '-']; const splitLanguage = taskConfig.splitLanguage || 'js'; const splitType = taskConfig.splitType; if (splitType === 'text') { // 字符分块 const textSplitter = new CharacterTextSplitter({ separator, chunkSize, chunkOverlap }); const splitResult = await textSplitter.createDocuments([fileContent]); return splitResult.map((part, index) => { const chunkId = `${path.basename(fileName, path.extname(fileName))}-part-${index + 1}`; return { projectId, name: chunkId, fileId, fileName, content: part.pageContent, summary: '', size: part.pageContent.length }; }); } else if (splitType === 'token') { // Token 分块 const textSplitter = new TokenTextSplitter({ chunkSize, chunkOverlap }); const splitResult = await textSplitter.splitText(fileContent); return splitResult.map((part, index) => { const chunkId = `${path.basename(fileName, path.extname(fileName))}-part-${index + 1}`; return { projectId, name: chunkId, fileId, fileName, content: part, summary: '', size: part.length }; }); } else if (splitType === 'code') { // 递归分块 const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap, separators }); const jsSplitter = RecursiveCharacterTextSplitter.fromLanguage(splitLanguage, { chunkSize, chunkOverlap }); const splitResult = await jsSplitter.createDocuments([fileContent]); return splitResult.map((part, index) => { const chunkId = `${path.basename(fileName, path.extname(fileName))}-part-${index + 1}`; return { projectId, name: chunkId, fileId, fileName, content: part.pageContent, summary: '', size: part.pageContent.length }; }); } else if (splitType === 'recursive') { // 递归分块 const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap, separators }); const splitResult = await textSplitter.splitDocuments([new Document({ pageContent: fileContent })]); return splitResult.map((part, index) => { const chunkId = `${path.basename(fileName, path.extname(fileName))}-part-${index + 1}`; return { projectId, name: chunkId, fileId, fileName, content: part.pageContent, summary: '', size: part.pageContent.length }; }); } else if (splitType === 'custom') { // 自定义符号分块 const customSeparator = taskConfig.customSeparator || '---'; // 使用自定义分隔符分割文本,过滤掉空块 const splitResult = fileContent.split(customSeparator).filter(content => content.trim().length > 0); return splitResult.map((part, index) => { const chunkId = `${path.basename(fileName, path.extname(fileName))}-part-${index + 1}`; // 去除首尾空白字符 const trimmedContent = part.trim(); return { projectId, name: chunkId, fileId, fileName, content: trimmedContent, summary: '', size: trimmedContent.length }; }); } else { // 默认采用之前的分块方法 const splitResult = markdownSplitter.splitMarkdown(fileContent, minLength, maxLength); return splitResult.map((part, index) => { const chunkId = `${path.basename(fileName, path.extname(fileName))}-part-${index + 1}`; return { projectId, name: chunkId, fileId, fileName, content: part.content, summary: part.summary, size: part.content.length }; }); } } /** * 分割项目中的Markdown文件 * @param {string} projectId - 项目ID * @param {string} fileName - 文件名 * @returns {Promise} - 分割结果数组 */ export async function splitProjectFile(projectId, file) { const { fileName, fileId } = file; try { // 获取项目根目录 const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); let filePath = path.join(projectPath, 'files', fileName); if (!filePath.endsWith('.md')) { filePath = path.join(projectPath, 'files', fileName.replace(/\.[^/.]+$/, '.md')); } try { await fs.promises.access(filePath); } catch (error) { throw new Error(`文件 ${fileName} 不存在`); } // 读取文件内容 const fileContent = await fs.promises.readFile(filePath, 'utf8'); // 保存分割结果到chunks目录 const savedChunks = await splitFileByType({ projectPath, fileContent, fileName, projectId, fileId }); await saveChunks(savedChunks); // 提取目录结构(如果需要所有文件的内容拼接后再提取目录) const tocJSON = markdownSplitter.extractTableOfContents(fileContent); const toc = markdownSplitter.tocToMarkdown(tocJSON, { isNested: true }); // 保存目录结构到单独的toc文件夹 const tocDir = path.join(projectPath, 'toc'); await ensureDir(tocDir); const tocPath = path.join(tocDir, `${path.basename(fileName, path.extname(fileName))}-toc.json`); await fs.promises.writeFile(tocPath, JSON.stringify(tocJSON, null, 2)); return { fileName, totalChunks: savedChunks.length, chunks: savedChunks, toc }; } catch (error) { console.error('文本分割出错:', error); throw error; } } /** * 获取项目中的所有文本块 * @param {string} projectId - 项目ID * @returns {Promise} - 文本块详细信息数组 */ export async function getProjectChunks(projectId, filter) { try { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const tocDir = path.join(projectPath, 'toc'); const project = await getProject(projectId); let chunks = await getChunkByProjectId(projectId, filter); // 读取所有TOC文件 const tocByFile = {}; let toc = ''; try { await fs.promises.access(tocDir); const tocFiles = await fs.promises.readdir(tocDir); for (const tocFile of tocFiles) { if (tocFile.endsWith('-toc.json')) { const tocPath = path.join(tocDir, tocFile); const tocContent = await fs.promises.readFile(tocPath, 'utf8'); const fileName = tocFile.replace('-toc.json', '.md'); try { tocByFile[fileName] = JSON.parse(tocContent); toc += '### File:' + fileName + '\n'; toc += markdownSplitter.tocToMarkdown(tocByFile[fileName], { isNested: true }) + '\n'; } catch (e) { console.error(`解析TOC文件 ${tocFile} 出错:`, e); } } } } catch (error) { // TOC目录不存在或读取出错,继续处理 } // 整合结果 let fileResult = { fileName: project.name + '.md', totalChunks: chunks.length, chunks, toc }; return { fileResult, // 单个文件结果,而不是数组 chunks }; } catch (error) { console.error('获取文本块出错:', error); throw error; } } /** * 获取项目中的所有目录 * @param {string} projectId - 项目ID */ export async function getProjectTocs(projectId) { try { const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const tocDir = path.join(projectPath, 'toc'); // 读取所有TOC文件 const tocByFile = {}; let toc = ''; try { await fs.promises.access(tocDir); const tocFiles = await fs.promises.readdir(tocDir); for (const tocFile of tocFiles) { if (tocFile.endsWith('-toc.json')) { const tocPath = path.join(tocDir, tocFile); const tocContent = await fs.promises.readFile(tocPath, 'utf8'); const fileName = tocFile.replace('-toc.json', '.md'); try { tocByFile[fileName] = JSON.parse(tocContent); toc += '### File:' + fileName + '\n'; toc += markdownSplitter.tocToMarkdown(tocByFile[fileName], { isNested: true }) + '\n'; } catch (e) { console.error(`解析TOC文件 ${tocFile} 出错:`, e); } } } } catch (error) { // TOC目录不存在或读取出错,继续处理 } return toc; } catch (error) { console.error('获取文本块出错:', error); throw error; } } /** * 指定文件的目录 */ export async function getProjectTocByName(projectId, fileName) { try { console.log(`[getProjectTocByName] projectId: ${projectId}, fileName: ${fileName}`); const projectRoot = await getProjectRoot(); const projectPath = path.join(projectRoot, projectId); const tocDir = path.join(projectPath, 'toc'); console.log(`[getProjectTocByName] tocDir: ${tocDir}`); // 读取所有TOC文件 const tocByFile = {}; let toc = ''; try { await fs.promises.access(tocDir); const tocFiles = await fs.promises.readdir(tocDir); console.log(`[getProjectTocByName] Found toc files:`, tocFiles); const targetTocFile = fileName.replace('.md', '') + '-toc.json'; console.log(`[getProjectTocByName] Looking for target file: ${targetTocFile}`); for (const tocFile of tocFiles) { if (tocFile.endsWith(fileName.replace('.md', '') + '-toc.json')) { console.log(`[getProjectTocByName] Found matching file: ${tocFile}`); const tocPath = path.join(tocDir, tocFile); const tocContent = await fs.promises.readFile(tocPath, 'utf8'); const currentFileName = tocFile.replace('-toc.json', '.md'); try { tocByFile[currentFileName] = JSON.parse(tocContent); toc += '### File:' + currentFileName + '\n'; toc += markdownSplitter.tocToMarkdown(tocByFile[currentFileName], { isNested: true }) + '\n'; } catch (e) { console.error(`解析TOC文件 ${tocFile} 出错:`, e); } } } } catch (error) { // TOC目录不存在或读取出错,继续处理 } return toc; } catch (error) { console.error('获取文本块出错:', error); throw error; } } ================================================ FILE: lib/i18n.js ================================================ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; // 导入翻译文件 import enTranslation from '../locales/en/translation.json'; import zhCNTranslation from '../locales/zh-CN/translation.json'; import trTranslation from '../locales/tr/translation.json'; import ptBRTranslation from '../locales/pt-BR/translation.json'; // 避免在服务器端重复初始化 const isServer = typeof window === 'undefined'; const i18nInstance = i18n.createInstance(); // 仅在客户端初始化 i18next if (!isServer && !i18nInstance.isInitialized) { i18nInstance // 检测用户语言 .use(LanguageDetector) // 将 i18n 实例传递给 react-i18next .use(initReactI18next) // 初始化 .init({ resources: { en: { translation: enTranslation }, zh: { translation: zhCNTranslation }, 'zh-CN': { translation: zhCNTranslation }, tr: { translation: trTranslation }, 'pt-BR':{ translation: ptBRTranslation } }, supportedLngs: ['en', 'zh', 'zh-CN', 'tr','pt-BR'], fallbackLng: 'en', debug: process.env.NODE_ENV === 'development', interpolation: { escapeValue: false // 不转义 HTML }, // 检测用户语言的选项 detection: { order: ['localStorage', 'navigator'], lookupLocalStorage: 'i18nextLng', caches: ['localStorage'], convertDetectedLanguage: lng => { if (!lng) return lng; const normalized = String(lng).toLowerCase(); if (normalized === 'zh' || normalized.startsWith('zh-')) { return 'zh-CN'; } return lng; } } }); } export default i18nInstance; ================================================ FILE: lib/llm/common/prompt-loader.js ================================================ import { getCustomPrompt } from '@/lib/db/custom-prompts'; /** * 获取提示词内容,优先使用项目自定义的,否则使用默认的 * @param {string} projectId 项目ID * @param {string} promptType 提示词类型 (如: question, answer等) * @param {string} promptKey 提示词键名 (如: QUESTION_PROMPT, QUESTION_PROMPT_EN等) * @param {string} language 语言 (zh-CN, en) * @param {string} defaultContent 默认提示词内容 * @returns {Promise} 提示词内容 */ export async function getPromptContent(projectId, promptType, promptKey, language, defaultContent) { try { if (!projectId) { return defaultContent; } const customPrompt = await getCustomPrompt(projectId, promptType, promptKey, language); if (customPrompt && customPrompt.isActive && customPrompt.content) { return customPrompt.content; } return defaultContent; } catch (error) { console.error('获取提示词内容失败:', error); return defaultContent; } } /** * 根据语言获取对应的提示词键名 * @param {string} language 语言 * @param {string} baseKey 基础键名 * @returns {string} 完整的提示词键名 */ export function getPromptKey(language, baseKey) { if (language === 'en') { return `${baseKey}_EN`; } if (language === 'tr') { return `${baseKey}_TR`; } return baseKey; } /** * 根据提示词键名获取对应的语言 * @param {string} promptKey 提示词键名 * @returns {string} 语言 */ export function getLanguageFromKey(promptKey) { if (promptKey.endsWith('_EN')) { return 'en'; } if (promptKey.endsWith('_TR')) { return 'tr'; } return 'zh-CN'; } /** * 通用的提示词处理函数,减少模板代码 * @param {string} language - 语言标识 * @param {string} promptType - 提示词类型 (如: dataClean, label, optimizeCot等) * @param {string} baseKey - 基础键名 (如: DATA_CLEAN_PROMPT) * @param {Object} defaultPrompts - 默认提示词对象 {zh: 中文提示词, en: 英文提示词} * @param {Object} params - 参数替换对象 * @param {string} projectId - 项目ID * @returns {Promise} - 处理后的提示词 */ export async function processPrompt(language, promptType, baseKey, defaultPrompts, params = {}, projectId = null) { const promptKey = getPromptKey(language, baseKey); let defaultPrompt; if (language === 'en') { defaultPrompt = defaultPrompts.en; } else if (language === 'tr') { defaultPrompt = defaultPrompts.tr; } else { defaultPrompt = defaultPrompts.zh; } const langCode = getLanguageFromKey(promptKey); let prompt = defaultPrompt; if (projectId) { try { prompt = await getPromptContent(projectId, promptType, promptKey, langCode, defaultPrompt); } catch (error) { console.error('获取自定义提示词失败,使用默认提示词:', error); prompt = defaultPrompt; } } // 参数替换 let result = prompt; for (const [key, value] of Object.entries(params)) { result = result.replaceAll(`{{${key}}}`, value); } return result; } ================================================ FILE: lib/llm/common/question-template.js ================================================ export function getQuestionTemplate(questionTemplate, language) { let templatePrompt = ''; let outputFormatPrompt = ''; if (questionTemplate) { const { customFormat, description, labels, answerType } = questionTemplate; if (description) { templatePrompt = `\n\n${description}`; } if (answerType === 'label') { outputFormatPrompt = language === 'en' ? ` \n\n ## Output Format \n\n Final output must be a string array, and must be selected from the following array, if the answer is not in the target array, return: ["other"] No additional information can be added: \n\n${labels}` : `\n\n ## 输出格式 \n\n 最终输出必须是一个字符串数组,而且必须在以下数组中选择,如果答案不在目标数组中,返回:["其他"] 不得额外添加任何其他信息:\n\n${labels}`; } else if (answerType === 'custom_format') { outputFormatPrompt = language === 'en' ? ` \n\n ## Output Format \n\n Final output must strictly follow the following structure, no additional information can be added: \n\n${customFormat}` : `\n\n ## 输出格式 \n\n 最终输出必须严格遵循以下结构,不得额外添加任何其他信息:\n\n${customFormat}`; } } return { templatePrompt, outputFormatPrompt }; } ================================================ FILE: lib/llm/common/util.js ================================================ import { jsonrepair } from 'jsonrepair'; export function extractJsonFromLLMOutput(output) { // console.log('LLM 输出:', output); if (output.trim().startsWith('', '']; const endTags = ['', '']; let startIndex = -1; let endIndex = -1; let usedStartTag = ''; let usedEndTag = ''; for (let i = 0; i < startTags.length; i++) { const currentStartIndex = text.indexOf(startTags[i]); if (currentStartIndex !== -1) { startIndex = currentStartIndex; usedStartTag = startTags[i]; usedEndTag = endTags[i]; break; } } if (startIndex === -1) { return ''; } endIndex = text.indexOf(usedEndTag, startIndex + usedStartTag.length); if (endIndex === -1) { return ''; } return text.slice(startIndex + usedStartTag.length, endIndex).trim(); } export function extractAnswer(text) { const startTags = ['', '']; const endTags = ['', '']; for (let i = 0; i < startTags.length; i++) { const start = startTags[i]; const end = endTags[i]; if (text.includes(start) && text.includes(end)) { const partsBefore = text.split(start); const partsAfter = partsBefore[1].split(end); return (partsBefore[0].trim() + ' ' + partsAfter[1].trim()).trim(); } } return text; } export function removeLeadingNumber(label) { const numberPrefixRegex = /^\d+(?:\.\d+)*\s+/; return label.replace(numberPrefixRegex, ''); } ================================================ FILE: lib/llm/core/index.js ================================================ /** * LLM API 统一调用工具类 * 支持多种模型提供商:OpenAI、Ollama、智谱AI等 * 支持普通输出和流式输出 */ import { DEFAULT_MODEL_SETTINGS } from '@/constant/model'; import { extractThinkChain, extractAnswer } from '@/lib/llm/common/util'; import { logLlmUsage, createLatencyTimer, extractTokenUsage } from '@/lib/llm/usageLogger'; const OllamaClient = require('./providers/ollama'); // 导入 OllamaClient const OpenAIClient = require('./providers/openai'); // 导入 OpenAIClient const ZhiPuClient = require('./providers/zhipu'); // 导入 ZhiPuClient const OpenRouterClient = require('./providers/openrouter'); const AlibailianClient = require('./providers/alibailian'); // 导入 AlibailianClient class LLMClient { /** * 创建 LLM 客户端实例 * @param {Object} config - 配置信息 * @param {string} config.provider - 提供商名称,如 'openai', 'ollama', 'zhipu' 等 * @param {string} config.endpoint - API 端点,如 'https://api.openai.com/v1/' * @param {string} config.apiKey - API 密钥(如果需要) * @param {string} config.model - 模型名称,如 'gpt-3.5-turbo', 'llama2' 等 * @param {number} config.temperature - 温度参数 * @param {string} [config.projectId] - 项目 ID(用于统计上报,可选) */ constructor(config = {}) { // 保存 projectId 用于统计上报 this.projectId = config.projectId || null; this.config = { provider: config.providerId || 'openai', endpoint: this._handleEndpoint(config.providerId, config.endpoint) || '', apiKey: config.apiKey || '', model: config.modelId || config.modelName, temperature: config.temperature || DEFAULT_MODEL_SETTINGS.temperature, maxTokens: config.maxTokens || DEFAULT_MODEL_SETTINGS.maxTokens, max_tokens: config.maxTokens || DEFAULT_MODEL_SETTINGS.maxTokens, topP: config.topP !== undefined ? config.topP : DEFAULT_MODEL_SETTINGS.topP, top_p: config.topP !== undefined ? config.topP : DEFAULT_MODEL_SETTINGS.topP }; if (config.topK !== undefined && config.topK !== 0) { this.config.topK = config.topK; } this.client = this._createClient(this.config.provider, this.config); } /** * 兼容之前版本的用户配置 */ _handleEndpoint(provider, endpoint) { const providerId = String(provider || '').toLowerCase(); let normalizedEndpoint = String(endpoint || '').trim(); if (!normalizedEndpoint) { return ''; } // 兼容误配的智谱 coding endpoint(会导致 chat/completions 返回 404) if (providerId === 'ollama') { if (normalizedEndpoint.endsWith('v1/') || normalizedEndpoint.endsWith('v1')) { return normalizedEndpoint.replace(/v1\/?$/, 'api'); } } if (normalizedEndpoint.includes('/chat/completions')) { return normalizedEndpoint.replace('/chat/completions', ''); } return normalizedEndpoint; } _createClient(provider, config) { const clientMap = { ollama: OllamaClient, openai: OpenAIClient, siliconflow: OpenAIClient, deepseek: OpenAIClient, zhipu: ZhiPuClient, openrouter: OpenRouterClient, alibailian: AlibailianClient }; const providerId = String(provider || '').toLowerCase(); // custom provider 且 endpoint 指向智谱时,优先使用 zhipu 客户端 if (providerId === 'custom' && String(config.endpoint || '').includes('open.bigmodel.cn')) { return new ZhiPuClient(config); } const ClientClass = clientMap[providerId] || OpenAIClient; return new ClientClass(config); } /** * 设置当前调用的项目 ID(用于统计上报) * @param {string} projectId - 项目 ID * @returns {LLMClient} 返回自身,支持链式调用 */ setProjectId(projectId) { this.projectId = projectId; return this; } async _callClientMethod(method, ...args) { const timer = createLatencyTimer(); let response = null; let status = 'SUCCESS'; let errorMessage = null; try { response = await this.client[method](...args); return response; } catch (error) { status = 'FAILED'; errorMessage = error.message || String(error); console.error(`${this.config.provider} API 调用出错:`, error); throw error; } finally { // 异步上报统计信息(不阻塞主流程) // 仅对非流式方法进行 Token 统计(流式方法无法直接获取 Token 数) const isStreamMethod = method === 'chatStream' || method === 'chatStreamAPI'; const { inputTokens, outputTokens } = !isStreamMethod && response ? extractTokenUsage(response) : { inputTokens: 0, outputTokens: 0 }; logLlmUsage({ projectId: this.projectId || 'unknown', provider: this.config.provider, model: this.config.model, inputTokens, outputTokens, latency: timer.getLatency(), status, errorMessage }); } } /** * 生成对话响应 * @param {string|Array} prompt - 用户输入的提示词或对话历史 * @param {Object} options - 可选参数 * @returns {Promise} 返回模型响应 */ async chat(prompt, options = {}) { const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }]; options = { ...options, ...this.config }; return this._callClientMethod('chat', messages, options); } /** * 流式生成对话响应 * @param {string|Array} prompt - 用户输入的提示词或对话历史 * @param {Object} options - 可选参数 * @returns {ReadableStream} 返回可读流 */ /** * 纯API流式生成对话响应 * @param {string|Array} prompt - 用户输入的提示词或对话历史 * @param {Object} options - 可选参数 * @returns {Response} 返回原生Response对象 */ async chatStreamAPI(prompt, options = {}) { const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }]; options = { ...options, ...this.config }; return this._callClientMethod('chatStreamAPI', messages, options); } /** * 流式生成对话响应 * @param {string|Array} prompt - 用户输入的提示词或对话历史 * @param {Object} options - 可选参数 * @returns {ReadableStream} 返回可读流 */ async chatStream(prompt, options = {}) { const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }]; options = { ...options, ...this.config }; return this._callClientMethod('chatStream', messages, options); } // 获取模型响应 async getResponse(prompt, options = {}) { const llmRes = await this.chat(prompt, options); return llmRes.text || llmRes.response.messages || ''; } // 提取答案和思维链 extractAnswerAndCOT(llmRes) { let answer = llmRes.text || ''; let cot = llmRes.reasoning || ''; if ((answer && answer.startsWith('')) || answer.startsWith('')) { cot = extractThinkChain(answer); answer = extractAnswer(answer); } else if ( llmRes?.response?.body?.choices?.length > 0 && llmRes.response.body.choices[0].message.reasoning_content ) { if (llmRes.response.body.choices[0].message.reasoning_content) { cot = llmRes.response.body.choices[0].message.reasoning_content; } if (llmRes.response.body.choices[0].message.content) { answer = llmRes.response.body.choices[0].message.content; } } if (answer.startsWith('\n\n')) { answer = answer.slice(2); } if (cot.endsWith('\n\n')) { cot = cot.slice(0, -2); } return { answer, cot }; } async getResponseWithCOT(prompt, options = {}) { const llmRes = await this.chat(prompt, options); return this.extractAnswerAndCOT(llmRes); } /** * 视觉模型响应(处理图片和文本) * @param {string} prompt - 提示词/问题 * @param {string} base64Image - base64 编码的图片数据 * @param {string|Object} mimeTypeOrOptions - MIME 类型或可选参数对象 * @param {Object} options - 可选参数(当第三个参数是 mimeType 时使用) * @returns {Promise} 返回模型响应 */ async getVisionResponse(prompt, base64Image, mimeType = 'image/jpeg') { // 构建包含图片的消息 const messages = [ { role: 'user', content: [ { type: 'text', text: prompt }, { type: 'image_url', image_url: { url: base64Image.startsWith('data:') ? base64Image : `data:${mimeType};base64,${base64Image}` } } ] } ]; const llmRes = await this._callClientMethod('chat', messages, {}); return this.extractAnswerAndCOT(llmRes); } } module.exports = LLMClient; ================================================ FILE: lib/llm/core/providers/alibailian.js ================================================ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import BaseClient from './base.js'; /** * 阿里百炼 Provider * 使用 createOpenAICompatible 来支持 providerOptions * 参考: https://github.com/vercel/ai/issues/6037 */ class AlibailianClient extends BaseClient { constructor(config) { super(config); // 使用 createOpenAICompatible,name 必须设置为 'qwen' 才能使 providerOptions.qwen 生效 this.qwen = createOpenAICompatible({ name: 'qwen', apiKey: this.apiKey, baseURL: this.endpoint }); } _getModel() { return this.qwen(this.model); } /** * 重写 chat 方法,直接调用阿里百炼 API * 支持 enable_thinking 参数和视觉模型 */ async chat(messages, options = {}) { // 转换消息格式,保持标准 OpenAI 格式(支持视觉模型) const formattedMessages = this._formatMessagesForVision(messages); // 构建请求体 const requestBody = { model: this.model, messages: formattedMessages, temperature: options.temperature || this.modelConfig.temperature, top_p: options.topP !== undefined ? options.topP : options.top_p || this.modelConfig.top_p, max_tokens: options.max_tokens || this.modelConfig.max_tokens, enable_thinking: options.enable_thinking !== undefined ? options.enable_thinking : false }; try { const response = await fetch(`${this.endpoint}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`阿里百炼 API 调用失败: ${response.status} ${errorText}`); } const data = await response.json(); // 转换为 AI SDK 格式 return { text: data.choices[0]?.message?.content || '', finishReason: data.choices[0]?.finish_reason || 'stop', usage: { promptTokens: data.usage?.prompt_tokens || 0, completionTokens: data.usage?.completion_tokens || 0, totalTokens: data.usage?.total_tokens || 0 } }; } catch (error) { console.error('阿里百炼 API 调用错误:', error); throw error; } } /** * 重写 _convertJson 方法,支持视觉模型 * 覆盖父类方法,保持标准 OpenAI 格式 */ _convertJson(messages) { return this._formatMessagesForVision(messages); } /** * 格式化消息,支持视觉模型的图片内容 * 保持标准 OpenAI 格式,不使用 experimental_attachments */ _formatMessagesForVision(messages) { return messages.map(msg => { // 非 user 角色直接返回 if (msg.role !== 'user') { return { role: msg.role, content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) }; } // 处理 user 消息 // 如果 content 是字符串,直接返回 if (typeof msg.content === 'string') { return { role: 'user', content: msg.content }; } // 如果 content 是数组(包含文本和图片),保持标准 OpenAI 格式 if (Array.isArray(msg.content)) { const formattedContent = msg.content.map(item => { if (item.type === 'text') { return { type: 'text', text: item.text }; } else if (item.type === 'image_url') { return { type: 'image_url', image_url: { url: item.image_url.url } }; } return item; }); return { role: 'user', content: formattedContent }; } // 默认情况 return msg; }); } } module.exports = AlibailianClient; ================================================ FILE: lib/llm/core/providers/base.js ================================================ import { generateText, streamText } from 'ai'; function checkOpenAIModel(endpoint, model) { // if (endpoint.includes('api.openai.com')) { return ['gpt-5', 'gpt-4', 'o1'].find(m => model.startsWith(m)); // } return false; } class BaseClient { constructor(config) { this.endpoint = config.endpoint || ''; this.apiKey = config.apiKey || ''; this.model = config.model || ''; this.provider = config.provider || ''; this.modelConfig = { temperature: config.temperature || 0.7, top_p: config.top_p !== undefined ? config.top_p : config.topP !== undefined ? config.topP : 0.9, max_tokens: config.max_tokens || 8192 }; } /** * chat(普通输出) */ async chat(messages, options) { const model = this._getModel(); const isOpenAIModel = checkOpenAIModel(this.endpoint, this.model); const maxTokens = options.max_tokens || this.modelConfig.max_tokens; const result = await generateText({ model, messages: this._convertJson(messages), ...(isOpenAIModel ? { maxCompletionTokens: maxTokens, temperature: 1 } : { maxTokens, temperature: options.temperature || this.modelConfig.temperature, topP: options.topP !== undefined ? options.topP : options.top_p || this.modelConfig.top_p }) }); return result; } /** * chat(流式输出) */ async chatStream(messages, options) { const model = this._getModel(); const isOpenAIModel = checkOpenAIModel(this.endpoint, this.model); const maxTokens = options.max_tokens || this.modelConfig.max_tokens; const stream = streamText({ model, messages: this._convertJson(messages), ...(isOpenAIModel ? { maxCompletionTokens: maxTokens, temperature: 1 } : { maxTokens, temperature: options.temperature || this.modelConfig.temperature, topP: options.topP !== undefined ? options.topP : options.top_p || this.modelConfig.top_p }) }); return stream.toTextStreamResponse(); } // 抽象方法 _getModel() { throw new Error('_getModel 子类方法必须实现'); } /** * chat(纯API流式输出) */ async chatStreamAPI(messages, options) { const model = this._getModel(); const modelName = typeof model === 'function' ? model.modelName : this.model; const isOpenAIModel = checkOpenAIModel(this.endpoint, this.model); const maxTokens = options.max_tokens || this.modelConfig.max_tokens; const payload = { model: modelName, messages: this._convertJson(messages), ...(isOpenAIModel ? { max_completion_tokens: maxTokens, temperature: 1 } : { max_tokens: maxTokens, send_reasoning: true, reasoning: true, temperature: options.temperature || this.modelConfig.temperature, top_p: options.topP !== undefined ? options.topP : options.top_p || this.modelConfig.top_p }), stream: true // 开启流式输出 }; try { // 发起流式请求 const response = await fetch( `${this.endpoint.endsWith('/') ? this.endpoint : `${this.endpoint}/`}chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify(payload) } ); if (!response.ok) { const errorText = await response.text(); throw new Error(`API请求失败: ${response.status} ${response.statusText}\n${errorText}`); } if (!response.body) { throw new Error('响应中没有可读取的数据流'); } // 处理原始数据流,实现思维链的流式输出 const reader = response.body.getReader(); const encoder = new TextEncoder(); const decoder = new TextDecoder(); // 创建一个新的可读流 const newStream = new ReadableStream({ async start(controller) { let buffer = ''; let isThinking = false; // 当前是否在输出思维链模式 let pendingReasoning = null; // 等待输出的思维链 // 输出文本内容 const sendContent = text => { if (!text) return; try { // 如果正在输出思维链,需要先关闭思维链标签 if (isThinking) { controller.enqueue(encoder.encode('')); isThinking = false; } controller.enqueue(encoder.encode(text)); } catch (e) { // 忽略流已关闭或无效状态的错误 if (e.code === 'ERR_INVALID_STATE' || e.message?.includes('closed')) return; // 其他错误只打印不抛出,避免中断整个请求流程 console.warn('流式输出警告:', e.message); } }; // 流式输出思维链 const sendReasoning = text => { if (!text) return; try { // 如果还没有开始思维链输出,需要先添加思维链标签 if (!isThinking) { controller.enqueue(encoder.encode('')); isThinking = true; } controller.enqueue(encoder.encode(text)); } catch (e) { // 忽略流已关闭或无效状态的错误 if (e.code === 'ERR_INVALID_STATE' || e.message?.includes('closed')) return; console.warn('流式输出警告:', e.message); } }; try { while (true) { const { done, value } = await reader.read(); if (done) { // 流结束时,如果还在思维链模式,关闭标签 if (isThinking) { try { controller.enqueue(encoder.encode('')); } catch (e) { /* ignore */ } } try { controller.close(); } catch (e) { /* ignore */ } break; } // 解析数据块 const chunk = decoder.decode(value, { stream: true }); buffer += chunk; // 处理数据行 let boundary = buffer.indexOf('\n'); while (boundary !== -1) { const line = buffer.substring(0, boundary).trim(); buffer = buffer.substring(boundary + 1); if (line.startsWith('data:') && !line.includes('[DONE]')) { try { // 解析JSON数据 const jsonData = JSON.parse(line.substring(5).trim()); const deltaContent = jsonData.choices?.[0]?.delta?.content; const deltaReasoning = jsonData.choices?.[0]?.delta?.reasoning_content; // 如果有思维链内容,则实时流式输出 if (deltaReasoning) { sendReasoning(deltaReasoning); } // 如果有正文内容也实时输出 if (deltaContent !== undefined && deltaContent !== null) { sendContent(deltaContent); } } catch (e) { // 忽略 JSON 解析错误,但不打印,避免日志刷屏 } } else if (line.includes('[DONE]')) { // 数据流结束,如果还在思维链模式,需要关闭思维链标签 if (isThinking) { try { controller.enqueue(encoder.encode('')); isThinking = false; } catch (e) { // 忽略 } } } boundary = buffer.indexOf('\n'); } } } catch (error) { // 如果是流关闭导致的错误,直接忽略 if (error.code === 'ERR_INVALID_STATE') { return; } console.error('处理数据流时出错:', error); // 如果出错时正在输出思维链,尝试关闭思维链标签 if (isThinking) { try { controller.enqueue(encoder.encode('')); } catch (e) { // 忽略错误 } } try { controller.error(error); } catch (e) { // 忽略错误 } } } }); // 最终返回响应流 return new Response(newStream, { headers: { 'Content-Type': 'text/plain', // 纯文本格式 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); } catch (error) { console.error('流式API调用出错:', error); throw error; } } _convertJson(data) { return data.map(item => { // 只处理 role 为 "user" 的项 if (item.role !== 'user') return item; const newItem = { role: 'user', content: '', experimental_attachments: [], parts: [] }; // 情况1:content 是字符串 if (typeof item.content === 'string') { newItem.content = item.content; newItem.parts.push({ type: 'text', text: item.content }); } // 情况2:content 是数组 else if (Array.isArray(item.content)) { item.content.forEach(contentItem => { if (contentItem.type === 'text') { // 文本内容 newItem.content = contentItem.text; newItem.parts.push({ type: 'text', text: contentItem.text }); } else if (contentItem.type === 'image_url') { // 图片内容 const imageUrl = contentItem.image_url.url; // 提取文件名(如果没有则使用默认名) let fileName = 'image.jpg'; if (imageUrl.startsWith('data:')) { // 如果是 base64 数据,尝试从 content type 获取扩展名 const match = imageUrl.match(/^data:image\/(\w+);base64/); if (match) { fileName = `image.${match[1]}`; } } newItem.experimental_attachments.push({ url: imageUrl, name: fileName, contentType: imageUrl.startsWith('data:') ? imageUrl.split(';')[0].replace('data:', '') : 'image/jpeg' // 默认为 jpeg }); } }); } return newItem; }); } } module.exports = BaseClient; ================================================ FILE: lib/llm/core/providers/ollama.js ================================================ import { createOllama } from 'ollama-ai-provider'; import BaseClient from './base.js'; class OllamaClient extends BaseClient { constructor(config) { super(config); this.ollama = createOllama({ baseURL: this.endpoint, apiKey: this.apiKey }); } _getModel() { return this.ollama(this.model); } /** * 获取本地可用的模型列表 * @returns {Promise} 返回模型列表 */ async getModels() { try { const response = await fetch(this.endpoint + '/tags'); const data = await response.json(); // 处理响应,提取模型名称 if (data && data.models) { return data.models.map(model => ({ name: model.name, modified_at: model.modified_at, size: model.size })); } return []; } catch (error) { console.error('Fetch error:', error); } } async chatStreamAPI(messages, options) { const model = this._getModel(); const modelName = typeof model === 'function' ? model.modelName : this.model; // 构建符合 Ollama API 的请求数据 const payload = { model: modelName, messages: this._convertJson(messages), stream: true, // 开启流式输出 options: { temperature: options.temperature || this.modelConfig.temperature, top_p: options.top_p || this.modelConfig.top_p, num_predict: options.max_tokens || this.modelConfig.max_tokens } }; if (this.endpoint.endsWith('/api')) { this.endpoint = this.endpoint.slice(0, -4); } try { // 发起流式请求 const response = await fetch(`${this.endpoint.endsWith('/') ? this.endpoint : `${this.endpoint}/`}api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API请求失败: ${response.status} ${response.statusText}\n${errorText}`); } if (!response.body) { throw new Error('响应中没有可读取的数据流'); } // 处理原始数据流,实现思维链的流式输出 const reader = response.body.getReader(); const encoder = new TextEncoder(); const decoder = new TextDecoder(); // 创建一个新的可读流 const newStream = new ReadableStream({ async start(controller) { let buffer = ''; let isThinking = false; // 当前是否在输出思维链模式 let pendingReasoning = null; // 等待输出的思维链 // 输出文本内容 const sendContent = text => { if (!text) return; // 如果正在输出思维链,需要先关闭思维链标签 if (isThinking) { controller.enqueue(encoder.encode('')); isThinking = false; } controller.enqueue(encoder.encode(text)); }; // 流式输出思维链 const sendReasoning = text => { if (!text) return; // 如果还没有开始思维链输出,需要先添加思维链标签 if (!isThinking) { controller.enqueue(encoder.encode('')); isThinking = true; } controller.enqueue(encoder.encode(text)); }; try { while (true) { const { done, value } = await reader.read(); if (done) { // 流结束时,如果还在思维链模式,关闭标签 if (isThinking) { controller.enqueue(encoder.encode('')); } controller.close(); break; } // 解析数据块 const chunk = decoder.decode(value, { stream: true }); buffer += chunk; // 处理数据行 let boundary = buffer.indexOf('\n'); while (boundary !== -1) { const line = buffer.substring(0, boundary).trim(); buffer = buffer.substring(boundary + 1); if (line) { try { // 解析JSON数据 const jsonData = JSON.parse(line); const deltaContent = jsonData.message?.content; const deltaReasoning = jsonData.message?.thinking; // 如果有思维链内容,则实时流式输出 if (deltaReasoning) { sendReasoning(deltaReasoning); } // 如果有正文内容也实时输出 if (deltaContent !== undefined && deltaContent !== null) { sendContent(deltaContent); } } catch (e) { // 忽略 JSON 解析错误 console.error('解析响应数据出错:', e); } } boundary = buffer.indexOf('\n'); } } } catch (error) { console.error('处理数据流时出错:', error); // 如果出错时正在输出思维链,要关闭思维链标签 if (isThinking) { try { controller.enqueue(encoder.encode('')); } catch (e) { console.error('关闭思维链标签出错:', e); } } controller.error(error); } } }); // 最终返回响应流 return new Response(newStream, { headers: { 'Content-Type': 'text/plain', // 纯文本格式 'Cache-Control': 'no-cache', Connection: 'keep-alive' } }); } catch (error) { console.error('流式API调用出错:', error); throw error; } } } module.exports = OllamaClient; ================================================ FILE: lib/llm/core/providers/openai.js ================================================ import { createOpenAI } from '@ai-sdk/openai'; import BaseClient from './base.js'; class OpenAIClient extends BaseClient { constructor(config) { super(config); this.openai = createOpenAI({ baseURL: this.endpoint, apiKey: this.apiKey }); } _getModel() { return this.openai(this.model); } } module.exports = OpenAIClient; ================================================ FILE: lib/llm/core/providers/openrouter.js ================================================ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import BaseClient from './base.js'; class OpenRouterClient extends BaseClient { constructor(config) { super(config); this.openrouter = createOpenRouter({ baseURL: this.endpoint, apiKey: this.apiKey }); } _getModel() { return this.openrouter(this.model); } } module.exports = OpenRouterClient; ================================================ FILE: lib/llm/core/providers/zhipu.js ================================================ import { createZhipu } from 'zhipu-ai-provider'; import BaseClient from './base.js'; class ZhiPuClient extends BaseClient { constructor(config) { super(config); this.zhipu = createZhipu({ baseURL: this.endpoint, apiKey: this.apiKey }); } _getModel() { return this.zhipu(this.model); } } module.exports = ZhiPuClient; ================================================ FILE: lib/llm/prompts/addLabel.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const ADD_LABEL_PROMPT = ` # Role: 标签匹配专家 - Description: 你是一名标签匹配专家,擅长根据给定的标签数组和问题数组,将问题打上最合适的领域标签。你熟悉标签的层级结构,并能根据问题的内容优先匹配二级标签,若无法匹配则匹配一级标签,最后打上“其他”标签。 ## Skill: 1. 熟悉标签层级结构,能够准确识别一级和二级标签。 2. 能够根据问题的内容,智能匹配最合适的标签。 3. 能够处理复杂的标签匹配逻辑,确保每个问题都能被打上正确的标签。 4. 能够按照规定的输出格式生成结果,确保不改变原有数据结构。 5. 能够处理大规模数据,确保高效准确的标签匹配。 ## Goals: 1. 将问题数组中的每个问题打上最合适的领域标签。 2. 优先匹配二级标签,若无法匹配则匹配一级标签,最后打上“其他”标签。 3. 确保输出格式符合要求,不改变原有数据结构。 4. 提供高效的标签匹配算法,确保处理大规模数据时的性能。 5. 确保标签匹配的准确性和一致性。 ## OutputFormat: 1. 输出结果必须是一个数组,每个元素包含 question、和 label 字段。 2. label 字段必须是根据标签数组匹配到的标签,若无法匹配则打上“其他”标签。 3. 不改变原有数据结构,只新增 label 字段。 ## 标签数组: {{label}} ## 问题数组: {{question}} ## Workflow: 1. Take a deep breath and work on this problem step-by-step. 2. 首先,读取标签数组和问题数组。 3. 然后,遍历问题数组中的每个问题,根据问题的内容匹配标签数组中的标签。 4. 优先匹配二级标签,若无法匹配则匹配一级标签,最后打上“其他”标签。 5. 将匹配到的标签添加到问题对象中,确保不改变原有数据结构。 6. 最后,输出结果数组,确保格式符合要求。 ## Constrains: 1. 只新增一个 label 字段,不改变其他任何格式和数据。 2. 必须按照规定格式返回结果。 3. 优先匹配二级标签,若无法匹配则匹配一级标签,最后打上“其他”标签。 4. 确保标签匹配的准确性和一致性。 5. 匹配的标签必须在标签数组中存在,如果不存在,就打上 其他 7. 输出结果必须是一个数组,每个元素包含 question、label 字段(只输出这个,不要输出任何其他无关内容) ## Output Example: \`\`\`json [ { "question": "XSS为什么会在2003年后引起人们更多关注并被OWASP列为威胁榜首?", "label": "2.2 XSS攻击" } ] \`\`\` `; export const ADD_LABEL_PROMPT_EN = ` # Role: Label Matching Expert - Description: You are a label matching expert, proficient in assigning the most appropriate domain labels to questions based on the given label array and question array.You are familiar with the hierarchical structure of labels and can prioritize matching secondary labels according to the content of the questions.If a secondary label cannot be matched, you will match a primary label.Finally, if no match is found, you will assign the "Other" label. ## Skill: 1. Be familiar with the label hierarchical structure and accurately identify primary and secondary labels. 2. Be able to intelligently match the most appropriate label based on the content of the question. 3. Be able to handle complex label matching logic to ensure that each question is assigned the correct label. 4. Be able to generate results in the specified output format without changing the original data structure. 5. Be able to handle large - scale data to ensure efficient and accurate label matching. ## Goals: 1. Assign the most appropriate domain label to each question in the question array. 2. Prioritize matching secondary labels.If no secondary label can be matched, match a primary label.Finally, assign the "Other" label. 3. Ensure that the output format meets the requirements without changing the original data structure. 4. Provide an efficient label matching algorithm to ensure performance when processing large - scale data. 5. Ensure the accuracy and consistency of label matching. ## OutputFormat: 1. The output result must be an array, and each element contains the "question" and "label" fields. 2. The "label" field must be the label matched from the label array.If no match is found, assign the "Other" label. 3. Do not change the original data structure, only add the "label" field. ## Label Array: {{label}} ## Question Array: {{question}} ## Workflow: 1. Take a deep breath and work on this problem step - by - step. 2. First, read the label array and the question array. 3. Then, iterate through each question in the question array and match the labels in the label array according to the content of the question. 4. Prioritize matching secondary labels.If no secondary label can be matched, match a primary label.Finally, assign the "Other" label. 5. Add the matched label to the question object without changing the original data structure. 6. Finally, output the result array, ensuring that the format meets the requirements. ## Constrains: 1. Only add one "label" field without changing any other format or data. 2. Must return the result in the specified format. 3. Prioritize matching secondary labels.If no secondary label can be matched, match a primary label.Finally, assign the "Other" label. 4. Ensure the accuracy and consistency of label matching. 5. The matched label must exist in the label array.If it does not exist, assign the "Other" label. 7. The output result must be an array, and each element contains the "question" and "label" fields(only output this, do not output any other irrelevant content). ## Output Example: \`\`\`json [ { "question": "XSS Attack why was more attention attracted by people after 2003 and was listed as the top threat by OWASP?", "label": "2.2 XSS Attack" } \`\`\` `; export const ADD_LABEL_PROMPT_TR = ` # Rol: Etiket Eşleştirme Uzmanı - Açıklama: Bir etiket eşleştirme uzmanısınız, verilen etiket dizisi ve soru dizisine dayalı olarak sorulara en uygun alan etiketlerini atama konusunda uzmanlaşmışsınız. Etiketlerin hiyerarşik yapısına aşinasınız ve soruların içeriğine göre ikinci seviye etiketleri öncelikli olarak eşleştirebilirsiniz. Eğer bir ikinci seviye etiket eşleştirilemezse, birinci seviye etiket eşleştirirsiniz. Son olarak, eşleşme bulunamazsa "Diğer" etiketini atarsınız. ## Yetenek: 1. Etiket hiyerarşik yapısına aşina olmak ve birinci ve ikinci seviye etiketleri doğru şekilde tanımlamak. 2. Sorunun içeriğine dayalı olarak en uygun etiketi akıllıca eşleştirebilmek. 3. Her sorunun doğru etiketle atanmasını sağlamak için karmaşık etiket eşleştirme mantığını ele alabilmek. 4. Orijinal veri yapısını değiştirmeden belirtilen çıktı formatında sonuç üretebilmek. 5. Verimli ve doğru etiket eşleştirmesini sağlamak için büyük ölçekli verileri işleyebilmek. ## Hedefler: 1. Soru dizisindeki her soruya en uygun alan etiketini atamak. 2. İkinci seviye etiketleri öncelikli olarak eşleştirmek. Eğer ikinci seviye etiket eşleştirilemezse, birinci seviye etiket eşleştirmek. Son olarak, "Diğer" etiketini atamak. 3. Orijinal veri yapısını değiştirmeden çıktı formatının gereksinimleri karşıladığından emin olmak. 4. Büyük ölçekli verileri işlerken performansı sağlamak için verimli bir etiket eşleştirme algoritması sağlamak. 5. Etiket eşleştirmesinin doğruluğunu ve tutarlılığını sağlamak. ## ÇıktıFormatı: 1. Çıktı sonucu bir dizi olmalıdır ve her öğe "question" ve "label" alanlarını içermelidir. 2. "label" alanı, etiket dizisinden eşleşen etiket olmalıdır. Eşleşme bulunamazsa, "Diğer" etiketini atayın. 3. Orijinal veri yapısını değiştirmeyin, yalnızca "label" alanını ekleyin. ## Etiket Dizisi: {{label}} ## Soru Dizisi: {{question}} ## İş Akışı: 1. Derin bir nefes alın ve bu problemi adım adım çözün. 2. İlk olarak, etiket dizisini ve soru dizisini okuyun. 3. Sonra, soru dizisindeki her soruyu tekrarlayın ve sorunun içeriğine göre etiket dizisindeki etiketlerle eşleştirin. 4. İkinci seviye etiketleri öncelikli olarak eşleştirin. Eğer ikinci seviye etiket eşleştirilemezse, birinci seviye etiket eşleştirin. Son olarak, "Diğer" etiketini atayın. 5. Orijinal veri yapısını değiştirmeden eşleşen etiketi soru nesnesine ekleyin. 6. Son olarak, sonuç dizisini çıktı alın, formatın gereksinimleri karşıladığından emin olun. ## Kısıtlamalar: 1. Yalnızca bir "label" alanı ekleyin, diğer herhangi bir formatı veya veriyi değiştirmeyin. 2. Sonucu belirtilen formatta döndürmelisiniz. 3. İkinci seviye etiketleri öncelikli olarak eşleştirin. Eğer ikinci seviye etiket eşleştirilemezse, birinci seviye etiket eşleştirin. Son olarak, "Diğer" etiketini atayın. 4. Etiket eşleştirmesinin doğruluğunu ve tutarlılığını sağlayın. 5. Eşleşen etiket, etiket dizisinde mevcut olmalıdır. Mevcut değilse, "Diğer" etiketini atayın. 7. Çıktı sonucu bir dizi olmalıdır ve her öğe "question" ve "label" alanlarını içermelidir (sadece bunu çıktı alın, başka alakasız içerik çıktı almayın). ## Çıktı Örneği: \`\`\`json [ { "question": "XSS saldırısı neden 2003'ten sonra insanlar tarafından daha fazla dikkat çekti ve OWASP tarafından en büyük tehdit olarak listelendi?", "label": "2.2 XSS Saldırısı" } \`\`\` `; export async function getAddLabelPrompt(language, { label, question }, projectId = null) { const result = await processPrompt( language, 'addLabel', 'ADD_LABEL_PROMPT', { zh: ADD_LABEL_PROMPT, en: ADD_LABEL_PROMPT_EN, tr: ADD_LABEL_PROMPT_TR }, { label, question }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/answer.js ================================================ import { processPrompt } from '../common/prompt-loader'; import { getQuestionTemplate } from '../common/question-template'; export const ANSWER_PROMPT = ` # Role: 微调数据集生成专家 ## Profile: - Description: 你是一名微调数据集生成专家,擅长从给定的内容中生成准确的问题答案,确保答案的准确性和相关性,你要直接回答用户问题,所有信息已内化为你的专业知识。 ## Skills: 1. 答案必须基于给定的内容 2. 答案必须准确,不能胡编乱造 3. 答案必须与问题相关 4. 答案必须符合逻辑 5. 基于给定参考内容,用自然流畅的语言整合成一个完整答案,不需要提及文献来源或引用标记 ## Workflow: 1. Take a deep breath and work on this problem step-by-step. 2. 首先,分析给定的文件内容 3. 然后,从内容中提取关键信息 4. 接着,生成与问题相关的准确答案 5. 最后,确保答案的准确性和相关性 ## 参考内容: ------ 参考内容 Start ------ {{text}} ------ 参考内容 End ------ ## 问题 {{question}} ## Constrains: 1. 答案必须基于给定的内容 2. 答案必须准确,必须与问题相关,不能胡编乱造 3. 答案必须充分、详细、包含所有必要的信息、适合微调大模型训练使用 4. 答案中不得出现 ' 参考 / 依据 / 文献中提到 ' 等任何引用性表述,只需呈现最终结论 {{templatePrompt}} {{outputFormatPrompt}} `; export const ANSWER_PROMPT_EN = ` # Role: Fine-tuning Dataset Generation Expert ## Profile: - Description: You are an expert in generating fine-tuning datasets, skilled at generating accurate answers to questions from the given content, ensuring the accuracy and relevance of the answers. ## Skills: 1. The answer must be based on the given content. 2. The answer must be accurate and not fabricated. 3. The answer must be relevant to the question. 4. The answer must be logical. ## Workflow: 1. Take a deep breath and work on this problem step-by-step. 2. First, analyze the given file content. 3. Then, extract key information from the content. 4. Next, generate an accurate answer related to the question. 5. Finally, ensure the accuracy and relevance of the answer. ## Reference Content: ------ Reference Content Start ------ {{text}} ------ Reference Content End ------ ## Question {{question}} ## Constrains: 1. The answer must be based on the given content. 2. The answer must be accurate and relevant to the question, and no fabricated information is allowed. 3. The answer must be comprehensive and detailed, containing all necessary information, and it is suitable for use in the training of fine-tuning large language models. {{templatePrompt}} {{outputFormatPrompt}} `; export const ANSWER_PROMPT_TR = ` # Rol: İnce Ayar Veri Seti Üretim Uzmanı ## Profil: - Açıklama: İnce ayar veri setleri oluşturmada uzman, verilen içerikten sorulara doğru cevaplar üretmede yetenekli, cevapların doğruluğunu ve alaka düzeyini sağlayan bir uzmansınız. ## Yetenekler: 1. Cevap verilen içeriğe dayanmalıdır. 2. Cevap doğru ve uydurma olmamalıdır. 3. Cevap soruyla alakalı olmalıdır. 4. Cevap mantıklı olmalıdır. ## İş Akışı: 1. Derin bir nefes alın ve bu problem üzerinde adım adım çalışın. 2. İlk olarak, verilen dosya içeriğini analiz edin. 3. Ardından, içerikten temel bilgileri çıkarın. 4. Sonra, soruyla ilgili doğru bir cevap oluşturun. 5. Son olarak, cevabın doğruluğunu ve alaka düzeyini sağlayın. ## Referans İçerik: ------ Referans İçerik Başlangıç ------ {{text}} ------ Referans İçerik Bitiş ------ ## Soru {{question}} ## Kısıtlamalar: 1. Cevap verilen içeriğe dayanmalıdır. 2. Cevap doğru ve soruyla alakalı olmalı, uydurma bilgiye izin verilmez. 3. Cevap kapsamlı ve detaylı olmalı, tüm gerekli bilgileri içermeli ve büyük dil modellerinin ince ayar eğitiminde kullanıma uygun olmalıdır. {{templatePrompt}} {{outputFormatPrompt}} `; export async function getAnswerPrompt(language, { text, question, questionTemplate }, projectId = null) { const { templatePrompt, outputFormatPrompt } = getQuestionTemplate(questionTemplate, language); const result = await processPrompt( language, 'answer', 'ANSWER_PROMPT', { zh: ANSWER_PROMPT, en: ANSWER_PROMPT_EN, tr: ANSWER_PROMPT_TR }, { text, question, templatePrompt, outputFormatPrompt }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/dataClean.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const DATA_CLEAN_PROMPT = ` # Role: 数据清洗专家 ## Profile: - Description: 你是一位专业的数据清洗专家,擅长识别和清理文本中的噪声、重复、错误等"脏数据",提升数据准确性、一致性与可用性。 ## 核心任务 对用户提供的文本(长度:{{textLength}} 字)进行全面的数据清洗,去除噪声数据,提升文本质量。 ## 清洗目标 1. **去除噪声数据**:删除无意义的符号、乱码、重复内容 2. **格式标准化**:统一格式、修正编码错误、规范标点符号 3. **内容优化**:修正错别字、语法错误、逻辑不通顺的表述 4. **结构整理**:优化段落结构、去除冗余信息 5. **保持原意**:确保清洗后的内容与原文意思一致 ## 清洗原则 - 保持原文的核心信息和语义不变 - 删除明显的噪声和无用信息 - 修正格式和编码问题 - 提升文本的可读性和一致性 - 不添加原文中不存在的信息 ## 常见清洗场景 1. **格式问题**:多余空格、换行符、特殊字符 2. **编码错误**:乱码字符、编码转换错误 3. **重复内容**:重复的句子、段落、词汇 4. **标点错误**:错误或不规范的标点符号使用 5. **语法问题**:明显的语法错误、错别字 6. **结构混乱**:段落划分不合理、层次不清晰 ## 输出要求 - 直接输出清洗后的文本内容 - 不要添加任何解释说明或标记 - 保持原文的段落结构和逻辑顺序 - 确保输出内容完整且连贯 ## 限制 - 必须保持原文的核心意思不变 - 不要过度修改,只清理明显的问题 - 输出纯净的文本内容,不包含任何其他信息 ## 待清洗文本 {{text}} `; export const DATA_CLEAN_PROMPT_EN = ` # Role: Data Cleaning Expert ## Profile: - Description: You are a professional data cleaning expert, skilled in identifying and cleaning "dirty data" such as noise, duplicates, and errors in text, so as to improve data accuracy, consistency, and usability. ## Core Task Perform comprehensive data cleaning on the user-provided text (length: {{textLength}} characters), remove noisy data, and improve text quality. ## Cleaning Objectives 1. **Remove Noisy Data**: Delete meaningless symbols, garbled characters, and duplicate content 2. **Format Standardization**: Unify formats, correct encoding errors, and standardize punctuation marks 3. **Content Optimization**: Correct typos, grammatical errors, and illogical expressions 4. **Structure Organization**: Optimize paragraph structure and remove redundant information 5. **Preserve Original Meaning**: Ensure the cleaned content is consistent with the meaning of the original text ## Cleaning Principles - Maintain the core information and semantics of the original text unchanged - Delete obvious noise and useless information - Correct format and encoding issues - Improve the readability and consistency of the text - Do not add information that does not exist in the original text ## Common Cleaning Scenarios 1. **Format Issues**: Extra spaces, line breaks, and special characters 2. **Encoding Errors**: Garbled characters and encoding conversion errors 3. **Duplicate Content**: Repeated sentences, paragraphs, and words 4. **Punctuation Errors**: Incorrect or non-standard use of punctuation marks 5. **Grammar Issues**: Obvious grammatical errors and typos 6. **Structure Confusion**: Unreasonable paragraph division and unclear hierarchy ## Output Requirements - Output the cleaned text content directly - Do not add any explanations or marks - Maintain the paragraph structure and logical order of the original text - Ensure the output content is complete and coherent ## Restrictions - Must keep the core meaning of the original text unchanged - Do not over-modify; only clean obvious issues - Output pure text content without any other information ## Text to be Cleaned {{text}} `; export const DATA_CLEAN_PROMPT_TR = ` # Rol: Veri Temizleme Uzmanı ## Profil: - Açıklama: Metindeki gürültü, tekrar ve hatalar gibi "kirli verileri" tespit etme ve temizlemede uzman, veri doğruluğunu, tutarlılığını ve kullanılabilirliğini artıran profesyonel bir veri temizleme uzmanısınız. ## Ana Görev Kullanıcının sağladığı metin üzerinde (uzunluk: {{textLength}} karakter) kapsamlı veri temizliği gerçekleştirin, gürültülü verileri kaldırın ve metin kalitesini iyileştirin. ## Temizleme Hedefleri 1. **Gürültülü Verileri Kaldırma**: Anlamsız sembolleri, bozuk karakterleri ve tekrarlayan içerikleri silme 2. **Format Standardizasyonu**: Formatları birleştirme, kodlama hatalarını düzeltme ve noktalama işaretlerini standardize etme 3. **İçerik Optimizasyonu**: Yazım hatalarını, dilbilgisi hatalarını ve mantıksız ifadeleri düzeltme 4. **Yapı Düzenleme**: Paragraf yapısını optimize etme ve gereksiz bilgileri kaldırma 5. **Orijinal Anlamı Koruma**: Temizlenmiş içeriğin orijinal metinle tutarlı olmasını sağlama ## Temizleme İlkeleri - Orijinal metnin temel bilgisini ve anlamını değiştirmeden koruyun - Açık gürültü ve gereksiz bilgileri silin - Format ve kodlama sorunlarını düzeltin - Metnin okunabilirliğini ve tutarlılığını iyileştirin - Orijinal metinde olmayan bilgi eklemeyin ## Yaygın Temizleme Senaryoları 1. **Format Sorunları**: Fazla boşluklar, satır sonları ve özel karakterler 2. **Kodlama Hataları**: Bozuk karakterler ve kodlama dönüşüm hataları 3. **Tekrarlayan İçerik**: Tekrarlanan cümleler, paragraflar ve kelimeler 4. **Noktalama Hataları**: Yanlış veya standart olmayan noktalama işareti kullanımı 5. **Dilbilgisi Sorunları**: Bariz dilbilgisi hataları ve yazım hataları 6. **Yapı Karmaşası**: Mantıksız paragraf bölümleme ve belirsiz hiyerarşi ## Çıktı Gereksinimleri - Temizlenmiş metin içeriğini doğrudan çıktı olarak verin - Herhangi bir açıklama veya işaret eklemeyin - Orijinal metnin paragraf yapısını ve mantıksal sırasını koruyun - Çıktı içeriğinin eksiksiz ve tutarlı olmasını sağlayın ## Kısıtlamalar - Orijinal metnin temel anlamını değiştirmeden korumalısınız - Aşırı değişiklik yapmayın; yalnızca açık sorunları temizleyin - Başka hiçbir bilgi içermeyen saf metin içeriği çıktısı verin ## Temizlenecek Metin {{text}} `; /** * 数据清洗提示模板 * @param {string} language - 语言标识 * @param {Object} params - 参数对象 * @param {string} params.text - 待清洗的文本 * @param {string} projectId - 项目ID,用于获取自定义提示词 * @returns {Promise} - 完整的提示词 */ export async function getDataCleanPrompt(language, { text }, projectId = null) { const result = await processPrompt( language, 'dataClean', 'DATA_CLEAN_PROMPT', { zh: DATA_CLEAN_PROMPT, en: DATA_CLEAN_PROMPT_EN, tr: DATA_CLEAN_PROMPT_TR }, { textLength: text.length, text }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/datasetEvaluation.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const DATASET_EVALUATION_PROMPT = ` # Role: 数据集质量评估专家 ## Profile: - Description: 你是一名专业的数据集质量评估专家,擅长从多个维度对问答数据集进行质量评估,为机器学习模型训练提供高质量的数据筛选建议。具备深度学习、自然语言处理和数据科学的专业背景。 ## Skills: 1. 能够从问题质量、答案质量、文本相关性等多个维度进行综合评估 2. 擅长识别数据集中的潜在问题,如答案不准确、问题模糊、文本不匹配、逻辑错误等 3. 能够给出具体的改进建议和质量评分,并提供可操作的优化方案 4. 熟悉机器学习训练数据的质量标准和最佳实践 5. 能够区分不同类型的问题(事实性、推理性、创造性)并采用相应的评估标准 ## 评估维度: ### 1. 问题质量 (25%) **评分标准:** - 5分:问题表述清晰准确,语法完美,具有明确的答案期望,难度适中 - 4分:问题基本清晰,语法正确,偶有轻微歧义但不影响理解 - 3分:问题可理解,但存在一定歧义或表达不够精确 - 2分:问题模糊,存在明显歧义或语法错误 - 1分:问题表述严重不清,难以理解意图 - 0分:问题完全无法理解或存在严重错误 **具体评估点:** - 问题是否清晰明确,没有歧义 - 问题是否具有适当的难度和深度 - 问题表达是否规范,语法是否正确 - 问题类型识别(事实性/推理性/创造性) ### 2. 答案质量 (35%) **评分标准:** - 5分:答案完全准确,内容详尽,逻辑清晰,结构完整 - 4分:答案基本准确,内容较完整,逻辑清晰 - 3分:答案大致正确,但缺少部分细节或逻辑略有不足 - 2分:答案部分正确,但存在明显错误或遗漏 - 1分:答案大部分错误,仅有少量正确信息 - 0分:答案完全错误或与问题无关 **具体评估点:** - 答案是否准确回答了问题的核心要求 - 答案内容是否完整、详细、逻辑清晰 - 答案是否基于提供的文本内容,没有虚构信息 - 答案的专业性和可信度 ### 3. 文本相关性 (25%) **有原始文本时:** - 5分:问题和答案与原始文本高度相关,文本完全支撑答案 - 4分:问题和答案与文本相关性强,文本基本支撑答案 - 3分:问题和答案与文本相关,但支撑度一般 - 2分:问题和答案与文本相关性较弱 - 1分:问题和答案与文本相关性很弱 - 0分:问题和答案与文本完全无关 **无原始文本时(蒸馏内容):** - 重点评估问题和答案的逻辑一致性 - 答案是否合理回答了问题 - 知识的准确性和可靠性 ### 4. 整体一致性 (15%) **评分标准:** - 5分:问题、答案、文本形成完美的逻辑闭环,完全适合模型训练 - 4分:整体一致性良好,适合模型训练 - 3分:基本一致,可用于模型训练但需要轻微调整 - 2分:存在一定不一致,需要修改后才能用于训练 - 1分:不一致问题较多,不建议直接用于训练 - 0分:严重不一致,完全不适合用于训练 **具体评估点:** - 问题、答案、原始文本三者之间是否形成良好的逻辑闭环 - 数据集是否适合用于模型训练 - 是否存在明显的错误或不一致 ## 原始文本块内容: {{chunkContent}} ## 问题: {{question}} ## 答案: {{answer}} ## 评估说明: 1. **数据集类型识别**:如果原始文本块内容为空或显示"Distilled Content",说明这是一个蒸馏数据集,没有原始文本参考。请重点评估问题的质量、答案的合理性和逻辑性,以及问答的一致性。 2. **评估原则**:采用严格的评估标准,确保筛选出的数据集能够有效提升模型性能。 3. **权重应用**:最终评分 = 问题质量×25% + 答案质量×35% + 文本相关性×25% + 整体一致性×15% ## 输出要求: 请按照以下JSON格式输出评估结果,评分范围为0-5分,精确到0.5分: \`\`\`json { "score": 4.5, "evaluation": "这是一个高质量的问答数据集。问题表述清晰具体,答案准确完整且逻辑性强,与原始文本高度相关。建议:可以进一步丰富答案的细节描述。" } \`\`\` ## 注意事项: - 评分标准严格,满分5分代表近乎完美的数据集 - 评估结论要具体指出优点和不足,提供可操作的改进建议 - 如果发现严重问题(如答案错误、文不对题等),评分应在2分以下 - 评估结论控制在150字以内,简洁明了但要涵盖关键信息 `; export const DATASET_EVALUATION_PROMPT_EN = ` # Role: Dataset Quality Evaluation Expert ## Profile: - Description: You are a professional dataset quality evaluation expert, skilled in evaluating Q&A datasets from multiple dimensions and providing high-quality data screening recommendations for machine learning model training. You have expertise in deep learning, natural language processing, and data science. ## Skills: 1. Ability to conduct comprehensive evaluation from multiple dimensions including question quality, answer quality, text relevance, etc. 2. Skilled at identifying potential issues in datasets, such as inaccurate answers, ambiguous questions, text mismatches, logical errors, etc. 3. Ability to provide specific improvement suggestions and quality scores, along with actionable optimization solutions 4. Familiar with quality standards and best practices for machine learning training data 5. Ability to distinguish different types of questions (factual, reasoning, creative) and apply corresponding evaluation criteria ## Evaluation Dimensions: ### 1. Question Quality (25%) **Scoring Standards:** - 5 points: Question is clearly and accurately stated, perfect grammar, clear answer expectations, appropriate difficulty - 4 points: Question is basically clear, correct grammar, occasional slight ambiguity but doesn't affect understanding - 3 points: Question is understandable but has some ambiguity or imprecise expression - 2 points: Question is vague, obvious ambiguity or grammatical errors - 1 point: Question is seriously unclear, difficult to understand intent - 0 points: Question is completely incomprehensible or has serious errors **Specific Evaluation Points:** - Whether the question is clear and unambiguous - Whether the question has appropriate difficulty and depth - Whether the question expression is standardized with correct grammar - Question type identification (factual/reasoning/creative) ### 2. Answer Quality (35%) **Scoring Standards:** - 5 points: Answer is completely accurate, content is comprehensive, logic is clear, structure is complete - 4 points: Answer is basically accurate, content is relatively complete, logic is clear - 3 points: Answer is generally correct but lacks some details or logic is slightly insufficient - 2 points: Answer is partially correct but has obvious errors or omissions - 1 point: Answer is mostly wrong with only a small amount of correct information - 0 points: Answer is completely wrong or irrelevant to the question **Specific Evaluation Points:** - Whether the answer accurately responds to the core requirements of the question - Whether the answer content is complete, detailed, and logically clear - Whether the answer is based on the provided text content without fabricated information - Professionalism and credibility of the answer ### 3. Text Relevance (25%) **When there is original text:** - 5 points: Question and answer are highly relevant to original text, text fully supports the answer - 4 points: Question and answer have strong relevance to text, text basically supports the answer - 3 points: Question and answer are related to text, but support is moderate - 2 points: Question and answer have weak relevance to text - 1 point: Question and answer have very weak relevance to text - 0 points: Question and answer are completely unrelated to text **When there is no original text (distilled content):** - Focus on evaluating logical consistency between question and answer - Whether the answer reasonably responds to the question - Accuracy and reliability of knowledge ### 4. Overall Consistency (15%) **Scoring Standards:** - 5 points: Question, answer, and text form perfect logical loop, completely suitable for model training - 4 points: Overall consistency is good, suitable for model training - 3 points: Basically consistent, can be used for model training but needs slight adjustment - 2 points: Some inconsistency exists, needs modification before training - 1 point: Many inconsistency issues, not recommended for direct training - 0 points: Serious inconsistency, completely unsuitable for training **Specific Evaluation Points:** - Whether the question, answer, and original text form a good logical loop - Whether the dataset is suitable for model training - Whether there are obvious errors or inconsistencies ## Original Text Chunk Content: {{chunkContent}} ## Question: {{question}} ## Answer: {{answer}} ## Evaluation Notes: 1. **Dataset Type Identification**: If the original text chunk content is empty or shows "Distilled Content", this indicates a distilled dataset without original text reference. Please focus on evaluating the quality of the question, reasonableness and logic of the answer, and consistency of the Q&A pair. 2. **Evaluation Principles**: Apply strict evaluation standards to ensure that the selected datasets can effectively improve model performance. 3. **Weight Application**: Final score = Question Quality×25% + Answer Quality×35% + Text Relevance×25% + Overall Consistency×15% ## Output Requirements: Please output the evaluation results in the following JSON format, with scores ranging from 0-5, accurate to 0.5: \`\`\`json { "score": 4.5, "evaluation": "This is a high-quality Q&A dataset. The question is clearly and specifically stated, the answer is accurate, complete, and logically strong, highly relevant to the original text. Suggestion: Could further enrich the detailed description of the answer." } \`\`\` ## Notes: - Strict scoring standards, a perfect score of 5 represents a nearly perfect dataset - Evaluation conclusions should specifically point out strengths and weaknesses, providing actionable improvement suggestions - If serious problems are found (such as wrong answers, irrelevant content, etc.), the score should be below 2 - Keep evaluation conclusions within 150 words, concise and clear but covering key information `; export const DATASET_EVALUATION_PROMPT_TR = ` # Rol: Veri Seti Kalite Değerlendirme Uzmanı ## Profil: - Açıklama: Profesyonel bir veri seti kalite değerlendirme uzmanısınız, S&C veri setlerini birden fazla boyuttan değerlendirme ve makine öğrenimi model eğitimi için yüksek kaliteli veri tarama önerileri sağlama konusunda yeteneklisiniz. Derin öğrenme, doğal dil işleme ve veri biliminde uzmanlığa sahipsiniz. ## Yetenekler: 1. Soru kalitesi, cevap kalitesi, metin ilgisi vb. dahil olmak üzere birden fazla boyuttan kapsamlı değerlendirme yapma yeteneği 2. Veri setlerindeki potansiyel sorunları tanımlama konusunda yetenekli, yanlış cevaplar, belirsiz sorular, metin uyuşmazlıkları, mantık hataları vb. 3. Spesifik iyileştirme önerileri ve kalite puanları sağlama, eylem yapılabilir optimizasyon çözümleri sunma yeteneği 4. Makine öğrenimi eğitim verileri için kalite standartları ve en iyi uygulamalara aşina 5. Farklı soru türlerini (olgusal, akıl yürütme, yaratıcı) ayırt etme ve ilgili değerlendirme kriterlerini uygulama yeteneği ## Değerlendirme Boyutları: ### 1. Soru Kalitesi (%25) **Puanlama Standartları:** - 5 puan: Soru net ve doğru ifade edilmiş, mükemmel dilbilgisi, net cevap beklentileri, uygun zorluk - 4 puan: Soru temel olarak net, doğru dilbilgisi, ara sıra hafif belirsizlik ancak anlamayı etkilemiyor - 3 puan: Soru anlaşılabilir ancak belirli belirsizlik veya kesin olmayan ifade var - 2 puan: Soru belirsiz, açık belirsizlik veya dilbilgisi hataları - 1 puan: Soru ciddi şekilde net değil, niyeti anlamak zor - 0 puan: Soru tamamen anlaşılmaz veya ciddi hatalara sahip **Spesifik Değerlendirme Noktaları:** - Sorunun net ve belirsiz olup olmadığı - Sorunun uygun zorluk ve derinliğe sahip olup olmadığı - Soru ifadesinin doğru dilbilgisiyle standardize edilmiş olup olmadığı - Soru türü tanımlama (olgusal/akıl yürütme/yaratıcı) ### 2. Cevap Kalitesi (%35) **Puanlama Standartları:** - 5 puan: Cevap tamamen doğru, içerik kapsamlı, mantık net, yapı eksiksiz - 4 puan: Cevap temel olarak doğru, içerik nispeten eksiksiz, mantık net - 3 puan: Cevap genel olarak doğru ancak bazı detaylar eksik veya mantık biraz yetersiz - 2 puan: Cevap kısmen doğru ancak bariz hatalar veya eksiklikler var - 1 puan: Cevap çoğunlukla yanlış, yalnızca az miktarda doğru bilgi var - 0 puan: Cevap tamamen yanlış veya soruyla ilgisiz **Spesifik Değerlendirme Noktaları:** - Cevabın sorunun temel gereksinimlerine doğru şekilde yanıt verip vermediği - Cevap içeriğinin eksiksiz, ayrıntılı ve mantıksal olarak net olup olmadığı - Cevabın uydurulmuş bilgi olmadan sağlanan metin içeriğine dayalı olup olmadığı - Cevabın profesyonelliği ve güvenilirliği ### 3. Metin İlgisi (%25) **Orijinal metin olduğunda:** - 5 puan: Soru ve cevap orijinal metinle yüksek oranda ilgili, metin cevabı tamamen destekliyor - 4 puan: Soru ve cevap metinle güçlü ilgiye sahip, metin temelde cevabı destekliyor - 3 puan: Soru ve cevap metinle ilgili, ancak destek orta düzeyde - 2 puan: Soru ve cevap metinle zayıf ilgiye sahip - 1 puan: Soru ve cevap metinle çok zayıf ilgiye sahip - 0 puan: Soru ve cevap metinle tamamen ilgisiz **Orijinal metin olmadığında (damıtılmış içerik):** - Soru ve cevap arasındaki mantıksal tutarlılığı değerlendirmeye odaklanın - Cevabın soruya makul şekilde yanıt verip vermediği - Bilginin doğruluğu ve güvenilirliği ### 4. Genel Tutarlılık (%15) **Puanlama Standartları:** - 5 puan: Soru, cevap ve metin mükemmel mantıksal döngü oluşturuyor, model eğitimi için tamamen uygun - 4 puan: Genel tutarlılık iyi, model eğitimi için uygun - 3 puan: Temel olarak tutarlı, model eğitimi için kullanılabilir ancak hafif ayarlama gerekiyor - 2 puan: Belirli tutarsızlık mevcut, eğitimden önce değişiklik gerekiyor - 1 puan: Birçok tutarsızlık sorunu var, doğrudan eğitim için önerilmiyor - 0 puan: Ciddi tutarsızlık, eğitim için tamamen uygun değil **Spesifik Değerlendirme Noktaları:** - Soru, cevap ve orijinal metnin iyi bir mantıksal döngü oluşturup oluşturmadığı - Veri setinin model eğitimi için uygun olup olmadığı - Bariz hata veya tutarsızlık olup olmadığı ## Orijinal Metin Parçası İçeriği: {{chunkContent}} ## Soru: {{question}} ## Cevap: {{answer}} ## Değerlendirme Notları: 1. **Veri Seti Türü Tanımlama**: Eğer orijinal metin parçası içeriği boşsa veya "Damıtılmış İçerik" gösteriyorsa, bu orijinal metin referansı olmayan bir damıtılmış veri setini gösterir. Lütfen sorunun kalitesini, cevabın mantığını ve S&C çiftinin tutarlılığını değerlendirmeye odaklanın. 2. **Değerlendirme İlkeleri**: Seçilen veri setlerinin model performansını etkili şekilde iyileştirebilmesini sağlamak için katı değerlendirme standartları uygulayın. 3. **Ağırlık Uygulaması**: Nihai puan = Soru Kalitesi×%25 + Cevap Kalitesi×%35 + Metin İlgisi×%25 + Genel Tutarlılık×%15 ## Çıktı Gereksinimleri: Lütfen değerlendirme sonuçlarını aşağıdaki JSON formatında çıktı verin, puanlar 0-5 arasında, 0,5'e kadar hassas: \`\`\`json { "score": 4.5, "evaluation": "Bu yüksek kaliteli bir S&C veri setidir. Soru net ve özel olarak ifade edilmiş, cevap doğru, eksiksiz ve mantıksal olarak güçlü, orijinal metinle yüksek oranda ilgili. Öneri: Cevabın ayrıntılı açıklamasını daha da zenginleştirebilir." } \`\`\` ## Notlar: - Katı puanlama standartları, 5 tam puan neredeyse mükemmel bir veri setini temsil eder - Değerlendirme sonuçları güçlü ve zayıf yönleri özellikle belirtmeli, eylem yapılabilir iyileştirme önerileri sağlamalıdır - Ciddi sorunlar bulunursa (yanlış cevaplar, ilgisiz içerik, vb.), puan 2'nin altında olmalıdır - Değerlendirme sonuçlarını 150 kelime içinde tutun, kısa ve net ama anahtar bilgileri kapsayan `; /** * 获取数据集质量评估提示词 * @param {string} language - 语言,'en' 或 '中文' * @param {Object} params - 参数对象 * @param {string} params.chunkContent - 原始文本块内容 * @param {string} params.question - 问题 * @param {string} params.answer - 答案 * @param {string} projectId - 项目ID(可选) * @returns {Promise} - 完整的提示词 */ export async function getDatasetEvaluationPrompt(language, { chunkContent, question, answer }, projectId = null) { const result = await processPrompt( language, 'datasetEvaluation', 'DATASET_EVALUATION_PROMPT', { zh: DATASET_EVALUATION_PROMPT, en: DATASET_EVALUATION_PROMPT_EN, tr: DATASET_EVALUATION_PROMPT_TR }, { chunkContent, question, answer }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/distillQuestions.js ================================================ import { removeLeadingNumber } from '../common/util'; import { processPrompt } from '../common/prompt-loader'; export const DISTILL_QUESTIONS_PROMPT = ` # Role: 领域问题蒸馏专家 ## Profile: - Description: 你是一个专业的知识问题生成助手,精通{{currentTag}}领域的知识。 - Task: 为标签"{{currentTag}}"生成{{count}}个高质量、多样化的问题。 - Context: 标签完整链路是:{{tagPath}} ## Skills: 1. 深入理解领域知识,能够识别和提取核心概念与关键知识点 2. 设计多样化的问题类型,覆盖不同难度和认知层次 3. 确保问题的准确性、清晰性和专业性 4. 避免重复或高度相似的问题,保证问题集的多样性 ## Workflow: 1. 分析"{{currentTag}}"的核心主题和知识结构 2. 规划问题的难度分布,确保覆盖基础、中级和高级各个层次 3. 设计多种类型的问题,确保类型多样性 4. 检查问题质量,确保表述清晰、准确、专业 5. 输出最终的问题集,确保格式符合要求 ## Constraints: 1. 问题主题相关性: - 生成的问题必须与"{{currentTag}}"主题紧密相关 - 确保全面覆盖该主题的核心知识点和关键概念 2. 难度分布均衡 (每个级别至少占20%): - 基础级:适合入门者,关注基本概念、定义和简单应用 - 中级:需要一定领域知识,涉及原理解释、案例分析和应用场景 - 高级:需要深度思考,包括前沿发展、跨领域联系、复杂问题解决方案等 3. 问题类型多样化(可灵活调整,不必局限于以下类型): - 概念解释类:"什么是..."、"如何定义..." - 原理分析类:"为什么..."、"如何解释..." - 比较对比类:"...与...有何区别"、"...相比...的优势是什么" - 应用实践类:"如何应用...解决..."、"...的最佳实践是什么" - 发展趋势类:"...的未来发展方向是什么"、"...面临的挑战有哪些" - 案例分析类:"请分析...案例中的..." - 启发思考类:"如果...会怎样"、"如何评价..." 4. 问题质量要求: - 避免模糊或过于宽泛的表述 - 避免可以简单用"是/否"回答的封闭性问题 - 避免包含误导性假设的问题 - 避免重复或高度相似的问题 5. 问题深度和广度(可灵活调整): - 覆盖主题的历史、现状、理论基础和实际应用 - 包含该领域的主流观点和争议话题 - 考虑该主题与相关领域的交叉关联 - 关注该领域的新兴技术、方法或趋势 ## Existing Questions (Optional): {{existingQuestions}} ## Output Format: - 返回JSON数组格式,不包含额外解释或说明 - 格式示例:["问题1", "问题2", "问题3", ...] - 每个问题应该是完整的、自包含的,无需依赖其他上下文即可理解和回答 `; export const DISTILL_QUESTIONS_PROMPT_EN = ` # Role: Domain Question Distillation Expert ## Profile: - Description: You are a professional knowledge question generation assistant, proficient in the field of {{currentTag}}. - Task: Generate {{count}} high-quality, diverse questions for the tag "{{currentTag}}". - Context: The complete tag path is: {{tagPath}} ## Skills: 1. In-depth understanding of domain knowledge to identify and extract core concepts and key knowledge points 2. Design diverse question types covering different difficulty levels and cognitive dimensions 3. Ensure accuracy, clarity, and professionalism in question formulation 4. Avoid repetitive or highly similar questions to maintain diversity in the question set ## Workflow: 1. Analyze the core themes and knowledge structure of "{{currentTag}}" 2. Plan the difficulty distribution of questions, ensuring coverage across basic, intermediate, and advanced levels 3. Design various types of questions to ensure type diversity 4. Check question quality to ensure clear, accurate, and professional wording 5. Output the final question set in the required format ## Constraints: 1. Question topic relevance: - Generated questions must be closely related to the topic of "{{currentTag}}" - Ensure comprehensive coverage of core knowledge points and key concepts of this topic 2. Balanced difficulty distribution (each level should account for at least 20%): - Basic: Suitable for beginners, focusing on basic concepts, definitions, and simple applications - Intermediate: Requires some domain knowledge, involving principle explanations, case analyses, and application scenarios - Advanced: Requires in-depth thinking, including cutting-edge developments, cross-domain connections, complex problem solutions 3. Question type diversity (can be flexibly adjusted, not limited to the following types): - Conceptual explanation: "What is...", "How to define..." - Principle analysis: "Why...", "How to explain..." - Comparison and contrast: "What is the difference between... and...", "What are the advantages of... compared to..." - Application practice: "How to apply... to solve...", "What is the best practice for..." - Development trends: "What is the future development direction of...", "What challenges does... face?" - Case analysis: "Please analyze... in the case of..." - Thought-provoking: "What would happen if...", "How to evaluate..." 4. Question quality requirements: - Avoid vague or overly broad phrasing - Avoid closed-ended questions that can be answered with "yes/no" - Avoid questions containing misleading assumptions - Avoid repetitive or highly similar questions 5. Question depth and breadth (can be flexibly adjusted): - Cover the history, current situation, theoretical basis, and practical applications of the topic - Include mainstream views and controversial topics in the field - Consider the cross-associations between this topic and related fields - Focus on emerging technologies, methods, or trends in this field ## Existing Questions (Optional): {{existingQuestionsText}} ## Output Format: - Return a JSON array format without additional explanations or notes - Format example: ["Question 1", "Question 2", "Question 3", ...] - Each question should be complete and self-contained, understandable and answerable without relying on other contexts `; export const DISTILL_QUESTIONS_PROMPT_TR = ` # Rol: Alan Sorusu Damıtma Uzmanı ## Profil: - Açıklama: {{currentTag}} alanında uzman, profesyonel bir bilgi sorusu oluşturma asistanısınız. - Görev: "{{currentTag}}" etiketi için {{count}} adet yüksek kaliteli, çeşitli soru oluşturun. - Bağlam: Tam etiket yolu: {{tagPath}} ## Yetenekler: 1. Alan bilgisini derinlemesine anlama, temel kavramları ve anahtar bilgi noktalarını tanımlama ve çıkarma 2. Farklı zorluk seviyelerini ve bilişsel boyutları kapsayan çeşitli soru türleri tasarlama 3. Soru formülasyonunda doğruluk, netlik ve profesyonellik sağlama 4. Soru setinde çeşitliliği korumak için tekrarlayan veya çok benzer sorulardan kaçınma ## İş Akışı: 1. "{{currentTag}}" temel temalarını ve bilgi yapısını analiz edin 2. Temel, orta ve ileri seviyeleri kapsayacak şekilde soruların zorluk dağılımını planlayın 3. Tür çeşitliliğini sağlamak için çeşitli türde sorular tasarlayın 4. Net, doğru ve profesyonel ifadeler sağlamak için soru kalitesini kontrol edin 5. Gerekli formatta nihai soru setini çıktı olarak verin ## Kısıtlamalar: 1. Soru konusu ilgisi: - Oluşturulan sorular "{{currentTag}}" konusuyla yakından ilgili olmalıdır - Bu konunun temel bilgi noktalarının ve anahtar kavramlarının kapsamlı olarak ele alınmasını sağlayın 2. Dengeli zorluk dağılımı (her seviye en az %20 oranında olmalıdır): - Temel: Yeni başlayanlar için uygun, temel kavramlara, tanımlara ve basit uygulamalara odaklanma - Orta: Belirli alan bilgisi gerektirir, ilke açıklamaları, vaka analizleri ve uygulama senaryolarını içerir - İleri: Derinlemesine düşünme gerektirir, son gelişmeleri, çapraz alan bağlantılarını, karmaşık problem çözümlerini içerir 3. Soru türü çeşitliliği (esnek şekilde ayarlanabilir, aşağıdaki türlerle sınırlı değildir): - Kavramsal açıklama: "Nedir...", "Nasıl tanımlanır..." - İlke analizi: "Neden...", "Nasıl açıklanır..." - Karşılaştırma ve zıtlık: "... ile ... arasındaki fark nedir", "...'nın ...'ya kıyasla avantajları nelerdir" - Uygulama pratiği: "... çözmek için nasıl uygulanır", "... için en iyi uygulama nedir" - Gelişme trendleri: "...'nın gelecekteki gelişme yönü nedir", "... hangi zorluklarla karşılaşıyor" - Vaka analizi: "Lütfen ... vakasında ... analiz edin" - Düşündürücü: "Eğer ... olsaydı ne olurdu", "Nasıl değerlendirilir..." 4. Soru kalitesi gereksinimleri: - Belirsiz veya aşırı geniş ifadelerden kaçının - "Evet/hayır" ile cevaplanabilecek kapalı uçlu sorulardan kaçının - Yanıltıcı varsayımlar içeren sorulardan kaçının - Tekrarlayan veya çok benzer sorulardan kaçının 5. Soru derinliği ve genişliği (esnek şekilde ayarlanabilir): - Konunun tarihini, mevcut durumunu, teorik temelini ve pratik uygulamalarını kapsayın - Alandaki ana görüşleri ve tartışmalı konuları dahil edin - Bu konu ile ilgili alanlar arasındaki çapraz ilişkileri göz önünde bulundurun - Bu alandaki yeni teknolojilere, yöntemlere veya trendlere odaklanın ## Mevcut Sorular (İsteğe Bağlı): {{existingQuestionsText}} ## Çıktı Formatı: - Ek açıklamalar veya notlar olmadan JSON dizi formatında döndürün - Format örneği: ["Soru 1", "Soru 2", "Soru 3", ...] - Her soru tam ve bağımsız olmalı, diğer bağlamlara güvenmeden anlaşılabilir ve cevaplanabilir olmalıdır `; /** * 根据标签构造问题的提示词 * @param {string} tagPath - 标签链路,例如 "体育->足球->足球先生" * @param {string} currentTag - 当前子标签,例如 "足球先生" * @param {number} count - 希望生成问题的数量,例如:10 * @param {Array} existingQuestions - 当前标签已经生成的问题(避免重复) * @param {string} globalPrompt - 项目全局提示词 * @returns {string} 提示词 */ export async function distillQuestionsPrompt( language, { tagPath, currentTag, count = 10, existingQuestions = [] }, projectId = null ) { currentTag = removeLeadingNumber(currentTag); const existingQuestionsText = existingQuestions.length > 0 ? `已有的问题包括:\n${existingQuestions.map(q => `- ${q}`).join('\n')}\n请不要生成与这些重复或高度相似的问题。` : ''; const existingQuestionsTextEn = existingQuestions.length > 0 ? `Existing questions include: \n${existingQuestions.map(q => `- ${q}`).join('\n')}\nPlease do not generate duplicate or highly similar questions.` : ''; const existingQuestionsTextTr = existingQuestions.length > 0 ? `Mevcut sorular: \n${existingQuestions.map(q => `- ${q}`).join('\n')}\nLütfen bunlarla tekrarlayan veya çok benzer sorular üretmeyin.` : ''; const result = await processPrompt( language, 'distillQuestions', 'DISTILL_QUESTIONS_PROMPT', { zh: DISTILL_QUESTIONS_PROMPT, en: DISTILL_QUESTIONS_PROMPT_EN, tr: DISTILL_QUESTIONS_PROMPT_TR }, { currentTag, count, tagPath, existingQuestions: language === 'tr' ? existingQuestionsTextTr : language === 'en' ? existingQuestionsTextEn : existingQuestionsText }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/distillTags.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const DISTILL_TAGS_PROMPT = ` # Role: 知识标签蒸馏专家 ## Profile: - Description: 你是一个专业的知识标签生成助手,专长于为特定主题创建细分的子标签体系。 - Task: 为主题"{{parentTag}}"生成{{count}}个专业的子标签。 - Context: 标签完整链路是:{{path}} ## Skills: 1. 深入理解主题领域,识别其核心子类别和专业细分方向 2. 设计简洁明确的标签命名,确保表意准确且易于理解 3. 规划标签间的差异化分布,避免重叠或模糊边界 4. 确保标签的实用性,能够有效支撑后续的问题生成工作 ## Workflow: 1. **主题分析**:深入理解"{{parentTag}}"的领域范围和核心要素 2. **子类识别**:识别该主题下的主要子类别和专业方向 3. **标签设计**:为每个子类别设计简洁明确的标签名称 4. **序号分配**:根据主题层级正确分配标签序号 5. **质量检查**:确保标签间区分明显,覆盖不同方面 ## Constraints: 1. 标签内容要求: - 生成的标签应该是"{{parentTag}}"领域内的专业子类别或子主题 - 标签之间应该有明显的区分,覆盖不同的方面 - 标签应该具有实用性,能够作为问题生成的基础 2. 标签格式要求: - 每个标签应该简洁、明确,通常为2-6个字 - 标签应该是名词或名词短语,不要使用动词或形容词 - 标签必须有明显的序号 3. 序号规则: - 若父标签有序号(如"1 汽车"),子标签格式为:"1.1 汽车品牌"、"1.2 汽车型号"、"1.3 汽车价格"等 - 若父标签无序号(如"汽车"),说明当前在生成顶级标签,子标签格式为:"1 汽车品牌"、"2 汽车型号"、"3 汽车价格"等 4. 避重要求: - 不与已有标签重复或高度相似 ## Existing Tags (Optional): {{existingTagsText}} ## Output Format: - 仅返回JSON数组格式,不包含额外解释或说明 - 格式示例:["序号 标签1", "序号 标签2", "序号 标签3", ...] `; export const DISTILL_TAGS_PROMPT_EN = ` # Role: Knowledge Tag Distillation Expert ## Profile: - Description: You are a professional knowledge tag generation assistant, specializing in creating refined sub-tag systems for specific topics. - Task: Generate {{count}} professional sub-tags for the topic "{{parentTag}}". - Context: The full tag chain is: {{path}} ## Skills: 1. Deeply understand topic domains and identify core sub-categories and professional subdivisions 2. Design concise and clear tag naming that ensures accurate meaning and easy understanding 3. Plan differentiated distribution among tags to avoid overlap or blurred boundaries 4. Ensure tag practicality to effectively support subsequent question generation work ## Workflow: 1. **Topic Analysis**: Deeply understand the domain scope and core elements of "{{parentTag}}" 2. **Sub-category Identification**: Identify major sub-categories and professional directions under this topic 3. **Tag Design**: Design concise and clear tag names for each sub-category 4. **Numbering Assignment**: Correctly assign tag numbers according to topic hierarchy 5. **Quality Check**: Ensure clear distinctions between tags, covering different aspects ## Constraints: 1. Tag content requirements: - Generated tags should be professional sub-categories or sub-topics within the "{{parentTag}}" domain - Tags should be clearly distinguishable, covering different aspects - Tags should be practical and serve as a basis for question generation 2. Tag format requirements: - Each tag should be concise and clear, typically 2-6 characters - Tags should be nouns or noun phrases; avoid verbs or adjectives - Tags must have explicit numbering 3. Numbering rules: - If parent tag has numbering (e.g., "1 Automobiles"), sub-tags format: "1.1 Car Brands", "1.2 Car Models", "1.3 Car Prices", etc. - If parent tag is unnumbered (e.g., "Automobiles"), indicating top-level tag generation, sub-tags format: "1 Car Brands", "2 Car Models", "3 Car Prices", etc. 4. Duplication avoidance: - Do not duplicate or highly resemble existing tags ## Existing Tags (Optional): {{existingTagsText}} ## Output Format: - Return only JSON array format without additional explanations or descriptions - Format example: ["Number Tag 1", "Number Tag 2", "Number Tag 3", ...] `; export const DISTILL_TAGS_PROMPT_TR = ` # Rol: Bilgi Etiketi Damıtma Uzmanı ## Profil: - Açıklama: Belirli konular için rafine edilmiş alt etiket sistemleri oluşturma konusunda uzmanlaşmış, profesyonel bir bilgi etiketi oluşturma asistanısınız. - Görev: "{{parentTag}}" konusu için {{count}} adet profesyonel alt etiket oluşturun. - Bağlam: Tam etiket zinciri: {{path}} ## Yetenekler: 1. Konu alanlarını derinlemesine anlama ve temel alt kategorileri ve profesyonel alt bölümleri tanımlama 2. Doğru anlam ve kolay anlaşılırlık sağlayan özlü ve net etiket isimlendirmesi tasarlama 3. Örtüşmeyi veya belirsiz sınırları önlemek için etiketler arasında farklılaştırılmış dağılım planlama 4. Sonraki soru oluşturma çalışmalarını etkili bir şekilde desteklemek için etiket pratikliğini sağlama ## İş Akışı: 1. **Konu Analizi**: "{{parentTag}}" alan kapsamını ve temel öğelerini derinlemesine anlayın 2. **Alt Kategori Tanımlama**: Bu konu altında ana alt kategorileri ve profesyonel yönleri tanımlayın 3. **Etiket Tasarımı**: Her alt kategori için özlü ve net etiket isimleri tasarlayın 4. **Numaralama Ataması**: Konu hiyerarşisine göre etiket numaralarını doğru şekilde atayın 5. **Kalite Kontrolü**: Etiketler arasında net ayrımlar olduğundan, farklı yönleri kapsadığından emin olun ## Kısıtlamalar: 1. Etiket içeriği gereksinimleri: - Oluşturulan etiketler "{{parentTag}}" alanı içinde profesyonel alt kategoriler veya alt konular olmalıdır - Etiketler açıkça ayırt edilebilir olmalı, farklı yönleri kapsamalıdır - Etiketler pratik olmalı ve soru oluşturma için temel oluşturmalıdır 2. Etiket format gereksinimleri: - Her etiket özlü ve net olmalı, genellikle 2-6 karakter - Etiketler isim veya isim öbekleri olmalı; fiil veya sıfatlardan kaçının - Etiketler açık numaralandırmaya sahip olmalıdır 3. Numaralama kuralları: - Eğer üst etiket numaralandırmaya sahipse (örn. "1 Otomobiller"), alt etiket formatı: "1.1 Araba Markaları", "1.2 Araba Modelleri", "1.3 Araba Fiyatları", vb. - Eğer üst etiket numaralandırılmamışsa (örn. "Otomobiller"), üst düzey etiket oluşturulduğunu gösterir, alt etiket formatı: "1 Araba Markaları", "2 Araba Modelleri", "3 Araba Fiyatları", vb. 4. Tekrardan kaçınma: - Mevcut etiketleri çoğaltmayın veya çok benzememelidir ## Mevcut Etiketler (İsteğe Bağlı): {{existingTagsText}} ## Çıktı Formatı: - Yalnızca ek açıklamalar veya tanımlamalar olmadan JSON dizi formatında döndürün - Format örneği: ["Numara Etiket 1", "Numara Etiket 2", "Numara Etiket 3", ...] `; /** * 根据标签构造子标签的提示词 * @param {string} parentTag - 主题标签名称,例如"体育" * @param {Array} existingTags - 该标签下已经创建的子标签(避免重复),例如 ["足球", "乒乓球"] * @param {number} count - 希望生成子标签的数量,例如:10 * @returns {string} 提示词 */ export async function distillTagsPrompt( language, { tagPath, parentTag, existingTags = [], count = 10 }, projectId = null ) { const existingTagsText = existingTags.length > 0 ? `已有的子标签包括:${existingTags.join('、')},请不要生成与这些重复的标签。` : ''; const existingTagsTextEn = existingTags.length > 0 ? `Existing sub-tags include: ${existingTags.join(', ')},please do not generate duplicate tags.` : ''; const existingTagsTextTr = existingTags.length > 0 ? `Mevcut alt etiketler: ${existingTags.join(', ')},lütfen bunlarla tekrarlayan etiketler üretmeyin.` : ''; const path = tagPath || parentTag; const result = await processPrompt( language, 'distillTags', 'DISTILL_TAGS_PROMPT', { zh: DISTILL_TAGS_PROMPT, en: DISTILL_TAGS_PROMPT_EN, tr: DISTILL_TAGS_PROMPT_TR }, { parentTag, count, tagPath, path, existingTagsText: language === 'tr' ? existingTagsTextTr : language === 'en' ? existingTagsTextEn : existingTagsText }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/enhancedAnswer.js ================================================ import { processPrompt } from '../common/prompt-loader'; import { getQuestionTemplate } from '../common/question-template'; export const ENHANCED_ANSWER_PROMPT = ` # Role: 微调数据集生成专家 (MGA增强版) ## Profile: - Description: 你是一名微调数据集生成专家,擅长从给定的内容中生成准确的问题答案,并能根据体裁与受众(Genre-Audience)组合调整回答风格,确保答案的准确性、相关性和针对性。 ## Skills: 1. 答案必须基于给定的内容 2. 答案必须准确,不能胡编乱造 3. 答案必须与问题相关 4. 答案必须符合逻辑 5. 基于给定参考内容,用自然流畅的语言整合成一个完整答案,不需要提及文献来源或引用标记 6. 能够根据指定的体裁与受众组合调整回答风格和深度 7. 在保持内容准确性的同时,增强答案的针对性和适用性 {{gaPrompt}} ## Workflow: 1. Take a deep breath and work on this problem step-by-step. 2. 首先,分析给定的文件内容和问题类型 3. 然后,从内容中提取关键信息 4. 如果有指定的体裁与受众组合,分析如何调整回答风格 5. 接着,生成与问题相关的准确答案,并根据体裁受众要求调整表达方式 6. 最后,确保答案的准确性、相关性和风格适配性 ## 参考内容: ------ 参考内容 Start ------- {{text}} ------ 参考内容 End ------- ## 问题 {{question}} ## Constrains: 1. 答案必须基于给定的内容 2. 答案必须准确,必须与问题相关,不能胡编乱造 3. 答案必须充分、详细、包含所有必要的信息、适合微调大模型训练使用 4. 答案中不得出现 ' 参考 / 依据 / 文献中提到 ' 等任何引用性表述,只需呈现最终结果 5. 如果指定了体裁与受众组合,必须在保持内容准确性的前提下,调整表达风格和深度 6. 答案必须直接回应问题, 确保答案的准确性和逻辑性。 {{templatePrompt}} {{outputFormatPrompt}} `; export const ENHANCED_ANSWER_PROMPT_EN = ` # Role: Fine-tuning Dataset Generation Expert (MGA Enhanced) ## Profile: - Description: You are an expert in generating fine-tuning datasets, skilled at generating accurate answers to questions from the given content, and capable of adjusting response style according to Genre-Audience combinations to ensure accuracy, relevance, and specificity of answers. ## Skills: 1. The answer must be based on the given content. 2. The answer must be accurate and not fabricated. 3. The answer must be relevant to the question. 4. The answer must be logical. 5. Based on the given reference content, integrate into a complete answer using natural and fluent language, without mentioning literature sources or citation marks. 6. Ability to adjust response style and depth according to specified genre and audience combinations. 7. While maintaining content accuracy, enhance the specificity and applicability of answers. {{gaPrompt}} ## Workflow: 1. Take a deep breath and work on this problem step-by-step. 2. First, analyze the given file content and question type. 3. Then, extract key information from the content. 4. If a specific genre and audience combination is specified, analyze how to adjust the response style. 5. Next, generate an accurate answer related to the question, adjusting expression according to genre-audience requirements. 6. Finally, ensure the accuracy, relevance, and style compatibility of the answer. ## Reference Content: ------ Reference Content Start ------ {{text}} ------ Reference Content End ------ ## Question {{question}} ## Constraints: 1. The answer must be based on the given content. 2. The answer must be accurate and relevant to the question, and no fabricated information is allowed. 3. The answer must be comprehensive and detailed, containing all necessary information, and it is suitable for use in the training of fine-tuning large language models. 4. The answer must not contain any referential expressions like 'according to the reference/based on/literature mentions', only present the final results. 5. If a genre and audience combination is specified, the expression style and depth must be adjusted while maintaining content accuracy. 6. The answer must directly address the question, ensuring its accuracy and logicality. {{templatePrompt}} {{outputFormatPrompt}} `; export const ENHANCED_ANSWER_PROMPT_TR = ` # Rol: İnce Ayar Veri Seti Oluşturma Uzmanı (MGA Geliştirilmiş) ## Profil: - Açıklama: İnce ayar veri setleri oluşturma konusunda uzman, verilen içerikten sorulara doğru cevaplar üretme becerisine sahip ve Tür-Hedef Kitle kombinasyonlarına göre yanıt stilini ayarlayarak cevapların doğruluğunu, ilgisini ve özgünlüğünü sağlayan bir uzmansınız. ## Yetenekler: 1. Cevap verilen içeriğe dayalı olmalıdır. 2. Cevap doğru olmalı ve uydurulmamalıdır. 3. Cevap soruyla ilgili olmalıdır. 4. Cevap mantıklı olmalıdır. 5. Verilen referans içeriğe dayanarak, doğal ve akıcı dil kullanarak eksiksiz bir cevap entegre edin, literatür kaynaklarından veya alıntı işaretlerinden bahsetmeyin. 6. Belirtilen tür ve hedef kitle kombinasyonlarına göre yanıt stilini ve derinliğini ayarlama yeteneği. 7. İçerik doğruluğunu korurken, cevapların özgüllüğünü ve uygulanabilirliğini artırın. {{gaPrompt}} ## İş Akışı: 1. Derin bir nefes alın ve bu problem üzerinde adım adım çalışın. 2. İlk olarak, verilen dosya içeriğini ve soru türünü analiz edin. 3. Sonra, içerikten anahtar bilgileri çıkarın. 4. Belirli bir tür ve hedef kitle kombinasyonu belirtilmişse, yanıt stilini nasıl ayarlayacağınızı analiz edin. 5. Ardından, soruyla ilgili doğru bir cevap oluşturun, tür-hedef kitle gereksinimlerine göre ifadeyi ayarlayın. 6. Son olarak, cevabın doğruluğunu, ilgisini ve stil uyumluluğunu sağlayın. ## Referans İçerik: ------ Referans İçerik Başlangıç ------ {{text}} ------ Referans İçerik Bitiş ------ ## Soru {{question}} ## Kısıtlamalar: 1. Cevap verilen içeriğe dayalı olmalıdır. 2. Cevap doğru ve soruyla ilgili olmalı, uydurulmuş bilgiye izin verilmez. 3. Cevap kapsamlı ve ayrıntılı olmalı, tüm gerekli bilgileri içermeli ve büyük dil modellerinin ince ayar eğitiminde kullanıma uygun olmalıdır. 4. Cevap 'referansa göre/dayanarak/literatürde bahsedilen' gibi herhangi bir referans ifade içermemeli, yalnızca nihai sonuçları sunmalıdır. 5. Bir tür ve hedef kitle kombinasyonu belirtilmişse, içerik doğruluğunu korurken ifade stili ve derinliği ayarlanmalıdır. 6. Cevap doğrudan soruyu ele almalı, doğruluğunu ve mantığını sağlamalıdır. {{templatePrompt}} {{outputFormatPrompt}} `; export async function getEnhancedAnswerPrompt( language, { text, question, activeGaPair = null, questionTemplate }, projectId = null ) { const gaPromptText = getGAPrompt(language, { activeGaPair }); const { templatePrompt, outputFormatPrompt } = getQuestionTemplate(questionTemplate, language); const result = await processPrompt( language, 'enhancedAnswer', 'ENHANCED_ANSWER_PROMPT', { zh: ENHANCED_ANSWER_PROMPT, en: ENHANCED_ANSWER_PROMPT_EN, tr: ENHANCED_ANSWER_PROMPT_TR }, { gaPrompt: gaPromptText, text, question, templatePrompt, outputFormatPrompt }, projectId ); return result; } export const GA_PROMPT = ` ## 特殊要求 - 体裁与受众适配(MGA): 根据以下体裁与受众组合,调整你的回答风格和深度: **当前体裁**: {{genre}} **目标受众**: {{audience}} 请确保: 1. 答案的组织、风格、详略程度和语言应完全符合「{{genre}}」的要求。 2. 答案应考虑到「{{audience}}」的理解能力和知识背景,力求清晰易懂。 3. 用词选择和解释详细程度匹配目标受众的知识背景。 4. 保持内容的准确性和专业性,同时增强针对性。 5. 如果{{genre}}或{{audience}}暗示需要,答案可以适当包含解释、示例或步骤。 6. 答案应直接回应问题,确保问答的逻辑性和连贯性,不要包含无关信息或引用标记如GA对中提到的内容防止污染数据生成的效果。 `; export const GA_PROMPT_EN = ` ## Special Requirements - Genre & Audience Adaptation (MGA): Adjust your response style and depth according to the following genre and audience combination: **Current Genre**: {{genre}} **Target Audience**: {{audience}} Please ensure: 1. The organization, style, level of detail, and language of the answer should fully comply with the requirements of "{{genre}}". 2. The answer should consider the comprehension ability and knowledge background of "{{audience}}", striving for clarity and ease of understanding. 3. Word choice and explanation detail match the target audience's knowledge background. 4. Maintain content accuracy and professionalism while enhancing specificity. 5. If "{{genre}}" or "{{audience}}" suggests the need, the answer can appropriately include explanations, examples, or steps. 6. The answer should directly address the question, ensuring the logic and coherence of the Q&A. It should not include irrelevant information or citation marks, such as content mentioned in GA pairs, to prevent contaminating the data generation results. `; export const GA_PROMPT_TR = ` ## Özel Gereksinimler - Tür ve Hedef Kitle Uyarlaması (MGA): Aşağıdaki tür ve hedef kitle kombinasyonuna göre yanıt tarzınızı ve derinliğinizi ayarlayın: **Mevcut Tür**: {{genre}} **Hedef Kitle**: {{audience}} Lütfen şunları sağlayın: 1. Yanıtın organizasyonu, stili, ayrıntı düzeyi ve dili "{{genre}}" gereksinimlerine tam olarak uygun olmalıdır. 2. Yanıt "{{audience}}" hedef kitlesinin anlayış yeteneğini ve bilgi birikiminigöz önünde bulundurmalı, netlik ve anlaşılırlık için çaba göstermelidir. 3. Kelime seçimi ve açıklama detayı, hedef kitlenin bilgi birikimiyle eşleşmelidir. 4. Özelleştiriciliği artırırken içerik doğruluğunu ve profesyonelliği koruyun. 5. Eğer "{{genre}}" veya "{{audience}}" öneriyorsa, yanıt uygun şekilde açıklamalar, örnekler veya adımlar içerebilir. 6. Yanıt doğrudan soruyu ele almalı, S&C'nin mantığını ve tutarlılığını sağlamalıdır. GA çiftlerinde belirtilen içerik gibi alakasız bilgiler veya alıntı işaretleri içermemeli, veri üretim sonuçlarının kirlenmesini önlemelidir. `; export function getGAPrompt(language, { activeGaPair }) { if (!activeGaPair || !activeGaPair.active) { return ''; } const promptMap = { zh: GA_PROMPT, en: GA_PROMPT_EN, tr: GA_PROMPT_TR }; const prompt = promptMap[language] || GA_PROMPT; return prompt.replaceAll('{{genre}}', activeGaPair.genre).replaceAll('{{audience}}', activeGaPair.audience); } ================================================ FILE: lib/llm/prompts/evalQuestion.js ================================================ import { processPrompt } from '../common/prompt-loader'; // ==================== 是否题 (True/False) ==================== export const EVAL_TRUE_FALSE_PROMPT = ` # Role: 判断题生成专家 ## Profile: - Description: 你是一名专业的测评题目设计专家,擅长根据文本内容生成高质量的是否判断题,用于评估模型对知识的理解程度。 - Input Length: {{textLength}} 字 - Output Goal: 生成 {{number}} 道是否判断题,每道题需包含题目和正确答案。 ## Skills: 1. 能够从文本中提取明确的事实性陈述。 2. 擅长设计具有明确对错判断的题目。 3. 善于设计干扰性题目,避免题目过于简单。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **文本解析**:通读全文,识别关键事实、定义、结论。 2. **题目设计**: - 提取明确的事实性陈述作为正确题目。 - 设计部分错误陈述作为干扰题目。 - 确保题目覆盖文本的不同方面。 3. **质量检查**: - 每道题的答案必须明确(是或否)。 - 题目不能模棱两可。 - 避免使用"可能"、"也许"等不确定词汇。 ## Constraints: 1. 所有题目必须严格依据原文内容,不得添加外部信息。 2. 题目需覆盖文本的不同主题,避免集中于单一片段。 3. 禁止输出与材料元信息相关的题目(如作者、章节等)。 4. 题目表述需简洁明了,避免冗长复杂的句式。 5. 必须生成 {{number}} 道题目。 ## Output Format: - 使用合法的 JSON 数组,每个元素包含 question 和 correctAnswer 字段。 - correctAnswer 必须是 "❌" 或 "✅"。 - 严格遵循以下结构: \`\`\`json [ { "question": "题目内容", "correctAnswer": "✅" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "人工智能是计算机科学的一个分支", "correctAnswer": "✅" }, { "question": "深度学习不需要大量数据进行训练", "correctAnswer": "❌" } ] \`\`\` ## Text to Analyze: {{text}} `; export const EVAL_TRUE_FALSE_PROMPT_EN = ` # Role: True/False Question Generation Expert ## Profile: - Description: You are an expert in designing true/false questions based on text content to assess model comprehension. - Input Length: {{textLength}} characters - Output Goal: Generate {{number}} true/false questions with correct answers. ## Skills: 1. Extract clear factual statements from text. 2. Design questions with definitive true/false answers. 3. Create challenging distractors to avoid overly simple questions. 4. Ensure strict formatting for programmatic processing. ## Workflow: 1. **Text Parsing**: Read the passage and identify key facts, definitions, and conclusions. 2. **Question Design**: - Extract clear factual statements as true questions. - Design false statements as distractors. - Ensure questions cover different aspects of the text. 3. **Quality Check**: - Each question must have a definitive answer (true or false). - Questions must be unambiguous. - Avoid uncertain words like "might", "perhaps". ## Constraints: 1. All questions must be strictly based on the provided text. 2. Cover diverse topics from the text; avoid clustering. 3. Do not include meta information questions (author, chapters, etc.). 4. Keep questions concise and clear. 5. Must generate exactly {{number}} questions. ## Output Format: - Return a valid JSON array with question and correctAnswer fields. - correctAnswer must be "✅" or "❌". - Follow this exact structure: \`\`\`json [ { "question": "Question content", "correctAnswer": "✅" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "Artificial intelligence is a branch of computer science", "correctAnswer": "✅" }, { "question": "Deep learning does not require large amounts of data for training", "correctAnswer": "❌" } ] \`\`\` ## Text to Analyze: {{text}} `; // ==================== 单选题 (Single Choice) ==================== export const EVAL_SINGLE_CHOICE_PROMPT = ` # Role: 单选题生成专家 ## Profile: - Description: 你是一名专业的测评题目设计专家,擅长根据文本内容生成高质量的单选题,用于评估模型的知识掌握程度。 - Input Length: {{textLength}} 字 - Output Goal: 生成 {{number}} 道单选题,每道题需包含题目、4个选项和正确答案。 ## Skills: 1. 能够从文本中提取关键信息点作为考查对象。 2. 擅长设计合理的干扰选项,增加题目难度。 3. 善于控制题目难度,确保题目具有区分度。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **文本解析**:通读全文,识别关键概念、定义、分类、数据等。 2. **题目设计**: - 选择重要知识点作为题干。 - 设计4个选项(A、B、C、D),其中1个正确,3个干扰。 - 干扰选项应具有一定迷惑性,但明确错误。 3. **质量检查**: - 确保只有一个选项完全正确。 - 干扰选项不能模棱两可。 - 选项长度尽量均衡。 ## Constraints: 1. 所有题目必须严格依据原文内容,不得添加外部信息。 2. 题目需覆盖文本的不同主题,避免集中于单一片段。 3. 禁止输出与材料元信息相关的题目。 4. 每道题必须有且仅有4个选项。 5. 必须生成 {{number}} 道题目。 ## Output Format: - 使用合法的 JSON 数组,每个元素包含 question、options 和 correctAnswer 字段。 - options 是包含4个选项的数组。 - correctAnswer 是正确选项的索引标识(A、B、C、D),对应 options 数组中的第 0、1、2、3 个元素。 - 严格遵循以下结构: \`\`\`json [ { "question": "题目内容", "options": ["选项A", "选项B", "选项C", "选项D"], "correctAnswer": "B" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "以下哪项是深度学习的核心特征?", "options": ["需要人工特征工程", "自动学习特征表示", "只能处理结构化数据", "不需要大量数据"], "correctAnswer": "B" } ] \`\`\` ## Text to Analyze: {{text}} `; export const EVAL_SINGLE_CHOICE_PROMPT_EN = ` # Role: Single Choice Question Generation Expert ## Profile: - Description: You are an expert in designing single-choice questions to assess model knowledge comprehension. - Input Length: {{textLength}} characters - Output Goal: Generate {{number}} single-choice questions with 4 options and correct answers. ## Skills: 1. Extract key information points from text as examination targets. 2. Design reasonable distractors to increase difficulty. 3. Control question difficulty to ensure discrimination. 4. Ensure strict formatting for programmatic processing. ## Workflow: 1. **Text Parsing**: Read the passage and identify key concepts, definitions, classifications, data, etc. 2. **Question Design**: - Select important knowledge points as question stems. - Design 4 options (A, B, C, D) with 1 correct and 3 distractors. - Distractors should be plausible but clearly incorrect. 3. **Quality Check**: - Ensure only one option is completely correct. - Distractors must not be ambiguous. - Options should be roughly equal in length. ## Constraints: 1. All questions must be strictly based on the provided text. 2. Cover diverse topics; avoid clustering. 3. Do not include meta information questions. 4. Each question must have exactly 4 options. 5. Must generate exactly {{number}} questions. ## Output Format: - Return a valid JSON array with question, options, and correctAnswer fields. - options is an array of 4 choices. - correctAnswer is the option index identifier (A, B, C, D), corresponding to the 0th, 1st, 2nd, 3rd element in the options array. - Follow this exact structure: \`\`\`json [ { "question": "Question content", "options": ["Option A", "Option B", "Option C", "Option D"], "correctAnswer": "B" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "Which of the following is a core feature of deep learning?", "options": ["Requires manual feature engineering", "Automatically learns feature representations", "Can only process structured data", "Does not require large amounts of data"], "correctAnswer": "B" } ] \`\`\` ## Text to Analyze: {{text}} `; // ==================== 多选题 (Multiple Choice) ==================== export const EVAL_MULTIPLE_CHOICE_PROMPT = ` # Role: 多选题生成专家 ## Profile: - Description: 你是一名专业的测评题目设计专家,擅长根据文本内容生成高质量的多选题,用于评估模型的综合理解能力。 - Input Length: {{textLength}} 字 - Output Goal: 生成 {{number}} 道多选题,每道题需包含题目、4-6个选项和正确答案(2个或以上)。 ## Skills: 1. 能够从文本中提取多个相关的知识点。 2. 擅长设计具有多个正确答案的题目。 3. 善于设计合理的干扰选项。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **文本解析**:通读全文,识别可以组合的多个知识点。 2. **题目设计**: - 选择包含多个要点的知识作为题干。 - 设计4-6个选项,其中2-4个正确,其余为干扰项。 - 确保正确选项之间有逻辑关联。 3. **质量检查**: - 确保至少有2个正确选项。 - 干扰选项应具有迷惑性。 - 避免"以上都对"、"以上都错"等选项。 ## Constraints: 1. 所有题目必须严格依据原文内容,不得添加外部信息。 2. 题目需覆盖文本的不同主题。 3. 每道题必须有2个或以上的正确答案。 4. 必须生成 {{number}} 道题目。 ## Output Format: - 使用合法的 JSON 数组,每个元素包含 question、options 和 correctAnswer 字段。 - options 是包含4-6个选项的数组。 - correctAnswer 是包含所有正确选项索引标识的数组(如 ["A", "C"]),对应 options 数组中的元素位置。 - 严格遵循以下结构: \`\`\`json [ { "question": "题目内容", "options": ["选项A", "选项B", "选项C", "选项D"], "correctAnswer": ["A", "C"] } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "以下哪些是深度学习的常用框架?", "options": ["TensorFlow", "PyTorch", "Excel", "Keras", "Word"], "correctAnswer": ["A", "B", "D"] } ] \`\`\` ## Text to Analyze: {{text}} `; export const EVAL_MULTIPLE_CHOICE_PROMPT_EN = ` # Role: Multiple Choice Question Generation Expert ## Profile: - Description: You are an expert in designing multiple-choice questions to assess comprehensive understanding. - Input Length: {{textLength}} characters - Output Goal: Generate {{number}} multiple-choice questions with 4-6 options and 2+ correct answers. ## Skills: 1. Extract multiple related knowledge points from text. 2. Design questions with multiple correct answers. 3. Create reasonable distractors. 4. Ensure strict formatting for programmatic processing. ## Workflow: 1. **Text Parsing**: Read the passage and identify combinable knowledge points. 2. **Question Design**: - Select knowledge with multiple key points as question stem. - Design 4-6 options with 2-4 correct and others as distractors. - Ensure logical connection between correct options. 3. **Quality Check**: - Ensure at least 2 correct options. - Distractors should be plausible. - Avoid options like "all of the above" or "none of the above". ## Constraints: 1. All questions must be strictly based on the provided text. 2. Cover diverse topics. 3. Each question must have 2 or more correct answers. 4. Must generate exactly {{number}} questions. ## Output Format: - Return a valid JSON array with question, options, and correctAnswer fields. - options is an array of 4-6 choices. - correctAnswer is an array containing all correct option index identifiers (e.g., ["A", "C"]), corresponding to positions in the options array. - Follow this exact structure: \`\`\`json [ { "question": "Question content", "options": ["Option A", "Option B", "Option C", "Option D"], "correctAnswer": ["A", "C"] } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "Which of the following are commonly used deep learning frameworks?", "options": ["TensorFlow", "PyTorch", "Excel", "Keras", "Word"], "correctAnswer": ["A", "B", "D"] } ] \`\`\` ## Text to Analyze: {{text}} `; // ==================== 固定短答案 (Short Answer) ==================== export const EVAL_SHORT_ANSWER_PROMPT = ` # Role: 短答案题生成专家 ## Profile: - Description: 你是一名专业的测评题目设计专家,擅长根据文本内容生成需要简短答案的题目,用于评估模型的信息提取能力。 - Input Length: {{textLength}} 字 - Output Goal: 生成 {{number}} 道短答案题,每道题需包含题目和标准答案(极短)。 ## Skills: 1. 能够从文本中提取关键事实、定义、数据等。 2. 擅长设计答案明确、简短的题目。 3. 善于将答案控制到极短,便于客观评判。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **文本解析**:通读全文,识别关键事实、定义、数据、结论等。 2. **题目设计**: - 选择有明确答案的知识点作为题干。 - 题目优先选择可用极短答案回答的事实点(实体、数值、时间、地点、名称、术语等)。 - 题目应该是"是什么/是谁/哪里/何时/多少/哪个/哪一项"等类型,避免需要长解释的"为什么/如何"。 3. **质量检查**: - 答案必须明确、极短,且可从原文直接找到或直接归纳得出。 - 答案可以在原文中直接找到或总结得出。 - 避免需要解释、论证、比较、举例的题目。 ## Constraints: 1. 所有题目必须严格依据原文内容,不得添加外部信息。 2. 题目需覆盖文本的不同主题。 3. correctAnswer 必须满足以下三选一(且只能选其一): - 一个词或一个短语(不要分点、不要换行、不要解释) - 一个数字或一个数值(可包含小数/百分号/单位) - 一句简单的话(单句,不要分号/冒号/并列要点,不要解释原因) 4. 必须生成 {{number}} 道题目。 ## Output Format: - 使用合法的 JSON 数组,每个元素包含 question 和 correctAnswer 字段。 - correctAnswer 是极短答案文本(符合 Constraints 第3条)。 - 严格遵循以下结构: \`\`\`json [ { "question": "题目内容", "correctAnswer": "简短答案" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "深度学习使用的典型模型结构是什么?", "correctAnswer": "神经网络" }, { "question": "文中提到的最大样本量是多少?", "correctAnswer": "1000" } ] \`\`\` ## Text to Analyze: {{text}} `; export const EVAL_SHORT_ANSWER_PROMPT_EN = ` # Role: Short Answer Question Generation Expert ## Profile: - Description: You are an expert in designing short-answer questions to assess information extraction ability. - Input Length: {{textLength}} characters - Output Goal: Generate {{number}} short-answer questions with ultra-short answers. ## Skills: 1. Extract key facts, definitions, data from text. 2. Design questions with clear, concise answers. 3. Control answer length for objective evaluation. 4. Ensure strict formatting for programmatic processing. ## Workflow: 1. **Text Parsing**: Read the passage and identify key facts, definitions, data, conclusions. 2. **Question Design**: - Select knowledge points with clear answers as question stems. - Prioritize facts answerable with an ultra-short response (entity, name, number, date, place, term). - Prefer "what/who/where/when/how many/which" types; avoid "why/how" that require explanations. 3. **Quality Check**: - Answers must be clear, ultra-short, and grounded in the text. - Answers can be found or summarized from the text. - Avoid questions requiring long explanations, argumentation, or comparisons. ## Constraints: 1. All questions must be strictly based on the provided text. 2. Cover diverse topics. 3. Each correctAnswer must be exactly one of the following (and only one): - A single word (no spaces), or a short phrase (no lists, no line breaks, no explanation) - A single number or numeric value (decimals/percent/units allowed) - One simple sentence (single sentence only; no lists; no explanation) 4. Must generate exactly {{number}} questions. ## Output Format: - Return a valid JSON array with question and correctAnswer fields. - correctAnswer is an ultra-short answer that follows the constraints above. - Follow this exact structure: \`\`\`json [ { "question": "Question content", "correctAnswer": "Concise answer" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "What model structure is typically used for deep learning in the text?", "correctAnswer": "neural network" }, { "question": "What is the maximum sample size mentioned in the text?", "correctAnswer": "1000" } ] \`\`\` ## Text to Analyze: {{text}} `; // ==================== 开放式回答 (Open-ended) ==================== export const EVAL_OPEN_ENDED_PROMPT = ` # Role: 开放式问题生成专家 ## Profile: - Description: 你是一名专业的测评题目设计专家,擅长根据文本内容生成需要深入分析和论述的开放式问题,用于评估模型的理解和推理能力。 - Input Length: {{textLength}} 字 - Output Goal: 生成 {{number}} 道开放式问题,每道题需包含题目和参考答案(形式不限)。 ## Skills: 1. 能够从文本中提取需要深入理解的主题。 2. 擅长设计需要分析、比较、评价的题目。 3. 善于提供基于原文的参考答案,能体现推理与论证过程。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **文本解析**:通读全文,识别核心主题、观点、论证逻辑等。 2. **题目设计**: - 选择需要深入理解的主题作为题干。 - 题目应该是"如何"、"为什么"、"分析"、"比较"等类型。 - 参考答案不要求固定要点数、句子数或固定结构;可用段落、分点、步骤、对比等任意合适形式表达。 3. **质量检查**: - 题目应该有一定的开放性,允许多角度回答。 - 参考答案应基于原文信息组织论述,逻辑自洽,能够覆盖题干核心要求。 - 避免过于简单或过于宽泛的题目。 ## Constraints: 1. 所有题目必须严格依据原文内容,不得添加外部信息。 2. 题目需覆盖文本的核心主题。 4. 必须生成 {{number}} 道题目。 ## Output Format: - 使用合法的 JSON 数组,每个元素包含 question 和 correctAnswer 字段。 - correctAnswer 是参考答案(形式不限,但需基于原文、逻辑自洽,直接回应问题)。 - 严格遵循以下结构: \`\`\`json [ { "question": "题目内容", "correctAnswer": "参考答案,包含多个要点" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "分析深度学习在计算机视觉领域取得成功的主要原因。", "correctAnswer": "深度学习在计算机视觉的成功可以从模型、数据与算力三个维度解释:一方面,卷积等结构更适合提取图像的层次化特征;另一方面,大规模标注数据让模型能够学习到更稳健的表示;同时,GPU等硬件与工程优化使得更大模型与更长训练成为可行。结合迁移学习等方法,落地成本进一步降低,从而推动了效果与应用的快速提升。" } ] \`\`\` ## Text to Analyze: {{text}} `; export const EVAL_OPEN_ENDED_PROMPT_EN = ` # Role: Open-ended Question Generation Expert ## Profile: - Description: You are an expert in designing open-ended questions requiring deep analysis to assess understanding and reasoning. - Input Length: {{textLength}} characters - Output Goal: Generate {{number}} open-ended questions with reference answers (no fixed format). ## Skills: 1. Extract topics requiring deep understanding from text. 2. Design questions requiring analysis, comparison, evaluation. 3. Provide grounded reference answers that show reasoning and justification. 4. Ensure strict formatting for programmatic processing. ## Workflow: 1. **Text Parsing**: Read the passage and identify core themes, viewpoints, reasoning logic. 2. **Question Design**: - Select topics requiring deep understanding as question stems. - Questions should be "how", "why", "analyze", "compare" types. - Reference answers should NOT be forced into a fixed number of points or sentences; use whatever structure best fits the question. 3. **Quality Check**: - Questions should be somewhat open-ended, allowing multiple perspectives. - Reference answers should be grounded in the text, logical, and directly address the question. - Avoid overly simple or overly broad questions. ## Constraints: 1. All questions must be strictly based on the provided text. 2. Cover core themes of the text. 4. Must generate exactly {{number}} questions. ## Output Format: - Return a valid JSON array with question and correctAnswer fields. - correctAnswer is a reference answer (no fixed format), grounded in the text and logically coherent. - Follow this exact structure: \`\`\`json [ { "question": "Question content", "correctAnswer": "Reference answer with multiple points" } ] \`\`\` ## Output Example: \`\`\`json [ { "question": "Analyze the main reasons for deep learning's success in computer vision.", "correctAnswer": "Deep learning's success in computer vision can be explained by the interaction of model inductive biases, data scale, and compute. Architectures such as convolutional networks are well-suited to learning hierarchical visual features; large labeled datasets enable robust representation learning; and GPU-driven training makes large models practical. Together with engineering advances and transfer learning, these factors translate into strong performance and broad applicability across tasks." } ] \`\`\` ## Text to Analyze: {{text}} `; // ==================== 主函数 ==================== /** * 根据题型生成评估题目的提示词 * @param {string} language - 语言,'zh-CN' 或 'en' * @param {string} questionType - 题型: true_false, single_choice, multiple_choice, short_answer, open_ended * @param {Object} params - 参数对象 * @param {string} params.text - 待处理的文本 * @param {number} params.number - 题目数量 * @param {string} projectId - 项目ID(用于自定义提示词) * @returns {Promise} - 完整的提示词 */ export async function getEvalQuestionPrompt(language, questionType, { text, number = 5 }, projectId = null) { // 根据题型选择对应的提示词模板 const promptMap = { true_false: { promptType: 'evalQuestion', promptKey: 'EVAL_TRUE_FALSE_PROMPT', templates: { zh: EVAL_TRUE_FALSE_PROMPT, en: EVAL_TRUE_FALSE_PROMPT_EN } }, single_choice: { promptType: 'evalQuestion', promptKey: 'EVAL_SINGLE_CHOICE_PROMPT', templates: { zh: EVAL_SINGLE_CHOICE_PROMPT, en: EVAL_SINGLE_CHOICE_PROMPT_EN } }, multiple_choice: { promptType: 'evalQuestion', promptKey: 'EVAL_MULTIPLE_CHOICE_PROMPT', templates: { zh: EVAL_MULTIPLE_CHOICE_PROMPT, en: EVAL_MULTIPLE_CHOICE_PROMPT_EN } }, short_answer: { promptType: 'evalQuestion', promptKey: 'EVAL_SHORT_ANSWER_PROMPT', templates: { zh: EVAL_SHORT_ANSWER_PROMPT, en: EVAL_SHORT_ANSWER_PROMPT_EN } }, open_ended: { promptType: 'evalQuestion', promptKey: 'EVAL_OPEN_ENDED_PROMPT', templates: { zh: EVAL_OPEN_ENDED_PROMPT, en: EVAL_OPEN_ENDED_PROMPT_EN } } }; const config = promptMap[questionType]; if (!config) { throw new Error(`不支持的题型: ${questionType}`); } const result = await processPrompt( language, config.promptType, config.promptKey, config.templates, { textLength: text.length, number, text }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/ga-generation.js ================================================ /** * Genre-Audience (GA) 对生成提示词 (中文版) * 基于 MGA (Massive Genre-Audience) 数据增强方法 */ import { processPrompt } from '../common/prompt-loader'; export const GA_GENERATION_PROMPT = ` # Role: 体裁与受众设计专家 ## Profile: - Description: 你是一名擅长内容分析与创意提炼的专家,能够依据文本内容设计出多样且高质量的 [体裁]-[受众] 组合,以支撑问题生成与风格化回答。 - Output Goal: 生成 5 对独特且互相区分的 [体裁]-[受众] 组合。 ## Skills: 1. 深入理解原文的主题、结构、语调与潜在价值。 2. 具备丰富的体裁知识,能够从事实概念、分析推理、评估创造、操作指导等角度设计差异化提问风格。 3. 善于刻画受众画像,涵盖不同年龄、背景、动机与学习需求,避免单一视角。 4. 输出信息清晰、完整,且便于下游系统直接使用。 ## Workflow: 1. **文本洞察**:通读原文,分析写作风格、信息密度、可延展方向。 2. **场景构思**:设想至少 5 种学习或探究场景,思考如何在保留核心信息的前提下拓展体裁与受众的多样性。 3. **组合设计**:为每个场景分别生成独立的体裁与对应受众描述,确保两者之间具有明确匹配逻辑。 4. **重复校验**:确认 5 对组合在体裁类型、表达风格、受众画像上均无重复或高度相似项。 ## Constraints: 1. 每对组合必须包含详细的体裁标题与 2-3 句描述,突出语言风格、情绪基调、表达形式等要素;禁止使用视觉类体裁(如漫画、视频)。 2. 每个受众需提供 2 句描述,涵盖其背景特征、认知水平、兴趣点与期望目标;需兼顾积极与冷淡受众,体现多元化。 3. 体裁与受众的匹配需自然合理,能够指导后续的问题风格与回答方式。 4. 输出不得包含与原文无关的臆测,不得沿用已有组合模板。 5. 必须严格返回 5 对组合,顺序不限,但禁止出现额外说明文字。 ## Output Format: - 仅返回合法 JSON 数组,数组长度为 5。 - 每个元素包含 \`genre\` 与 \`audience\` 两个对象,均需包含 \`title\` 与 \`description\` 字段。 - 参考结构如下: \`\`\` [ { "genre": {"title": "体裁标题", "description": "体裁描述"}, "audience": {"title": "受众标题", "description": "受众描述"} } ] \`\`\` ## Examples: - 体裁示例:“深究原因型” —— 描述聚焦于“为什么/如何”类提问,强调逻辑链条与原理阐述。 - 受众示例:“对技术细节好奇的工程师实习生” —— 描述其背景、动机与学习目标。 ## Source Text to Analyze: {{text}} `; export const GA_GENERATION_PROMPT_EN = ` # Role: Genre & Audience Design Specialist ## Profile: - Description: You are an expert in content analysis and creative abstraction, capable of crafting diverse, high-quality [Genre]-[Audience] pairings based on the source text to support question generation and stylized responses. - Output Goal: Produce 5 distinctive [Genre]-[Audience] pairs with clear differentiation. ## Skills: 1. Derive deep insights about the topic, structure, tone, and potential value of the source text. 2. Possess extensive genre knowledge, spanning factual recall, conceptual understanding, analytical reasoning, evaluative creation, instructional guidance, etc., to design varied questioning styles. 3. Portray audiences across age, expertise, motivation, and engagement levels, ensuring multi-perspective coverage. 4. Communicate clearly and precisely so downstream systems can consume the output directly. ## Workflow: 1. **Text Insight**: Read the passage thoroughly to analyze style, information density, and extensibility. 2. **Scenario Ideation**: Imagine at least 5 learning or inquiry scenarios that broaden genre and audience diversity while preserving core information. 3. **Pair Construction**: For each scenario, create a dedicated genre and a matching audience description with an explicit logical connection. 4. **Redundancy Check**: Ensure all 5 pairs are distinct in genre style, tone, and audience profile with no repetition or near-duplicates. ## Constraints: 1. Each genre must include a title and a 2-3 sentence description emphasizing language style, emotional tone, delivery format, etc.; exclude visual formats (e.g., comics, video). 2. Each audience must include a two-sentence profile describing background traits, knowledge level, motivations, and desired outcomes; represent both enthusiastic and lukewarm audiences to highlight diversity. 3. Genre and audience within each pair must be naturally aligned to guide subsequent question style and answer adaptation. 4. Do not infer content unrelated to the source text or reuse existing pair templates; ensure originality. 5. Return exactly 5 pairs with no additional commentary or formatting beyond the specified JSON structure. ## Output Format: - Respond with a valid JSON array of length 5. - Each element must contain \`genre\` and \`audience\` objects, both with \`title\` and \`description\` fields. - Follow the example structure: \`\`\` [ { "genre": {"title": "Genre Title", "description": "Genre description"}, "audience": {"title": "Audience Title", "description": "Audience description"} } ] \`\`\` ## Examples: - Genre Example: "Root Cause Analysis" — Focused on "why/how" questioning with logical, principle-driven exploration. - Audience Example: "Aspiring Engineers Curious About Technical Details" — Highlighting background, motivations, and learning objectives. ## Source Text to Analyze: {{text}} `; export const GA_GENERATION_PROMPT_TR = ` # Rol: Tür ve Hedef Kitle Tasarım Uzmanı ## Profil: - Açıklama: İçerik analizi ve yaratıcı soyutlama konusunda uzman, soru oluşturma ve stilize yanıtları desteklemek için kaynak metne dayalı çeşitli, yüksek kaliteli [Tür]-[Hedef Kitle] eşleştirmeleri oluşturma yeteneğine sahipsiniz. - Çıktı Hedefi: Net ayrıma sahip 5 farklı [Tür]-[Hedef Kitle] çifti üretin. ## Yetenekler: 1. Kaynak metnin konusu, yapısı, tonu ve potansiyel değeri hakkında derin içgörüler elde edin. 2. Çeşitli sorgulama stillerini tasarlamak için olgusal hatırlama, kavramsal anlama, analitik akıl yürütme, değerlendirici yaratım, öğretici rehberlik vb. kapsayan geniş tür bilgisine sahip olun. 3. Çok perspektifli kapsama sağlamak için yaş, uzmanlık, motivasyon ve katılım seviyelerinde hedef kitleleri betimleyin. 4. Alt sistemlerin çıktıyı doğrudan tüketebilmesi için net ve kesin iletişim kurun. ## İş Akışı: 1. **Metin İçgörüsü**: Stili, bilgi yoğunluğunu ve genişletilebilirliği analiz etmek için pasajı kapsamlı şekilde okuyun. 2. **Senaryo Fikir Üretimi**: Temel bilgileri koruyarak tür ve hedef kitle çeşitliliğini genişleten en az 5 öğrenme veya araştırma senaryosu hayal edin. 3. **Çift Oluşturma**: Her senaryo için özel bir tür ve açık mantıksal bağlantıya sahip eşleşen hedef kitle açıklaması oluşturun. 4. **Artıklık Kontrolü**: Tür stili, tonu ve hedef kitle profilinde tekrar veya neredeyse çoğaltma olmadan tüm 5 çiftin farklı olduğundan emin olun. ## Kısıtlamalar: 1. Her tür, dil stili, duygusal ton, sunum formatı vb. vurgulayan bir başlık ve 2-3 cümlelik açıklama içermelidir; görsel formatları (örn. çizgi roman, video) hariç tutun. 2. Her hedef kitle, geçmiş özellikleri, bilgi düzeyini, motivasyonları ve istenen sonuçları açıklayan iki cümlelik bir profil içermelidir; çeşitliliği vurgulamak için hem hevesli hem de ilgisiz hedef kitleleri temsil edin. 3. Her çiftteki tür ve hedef kitle, sonraki soru stilini ve cevap uyarlamasını yönlendirmek için doğal olarak hizalanmalıdır. 4. Kaynak metinle ilgisi olmayan içerik çıkarsamayın veya mevcut çift şablonlarını yeniden kullanmayın; özgünlük sağlayın. 5. Belirtilen JSON yapısının ötesinde ek yorum veya biçimlendirme olmadan tam olarak 5 çift döndürün. ## Çıktı Formatı: - Uzunluğu 5 olan geçerli bir JSON dizisiyle yanıt verin. - Her öğe, her ikisi de \`title\` ve \`description\` alanlarına sahip \`genre\` ve \`audience\` nesnelerini içermelidir. - Örnek yapıyı izleyin: \`\`\` [ { "genre": {"title": "Tür Başlığı", "description": "Tür açıklaması"}, "audience": {"title": "Hedef Kitle Başlığı", "description": "Hedef kitle açıklaması"} } ] \`\`\` ## Örnekler: - Tür Örneği: "Kök Neden Analizi" — Mantıksal, ilke odaklı keşifle "neden/nasıl" sorgulamaya odaklanır. - Hedef Kitle Örneği: "Teknik Detaylara Meraklı Aday Mühendisler" — Geçmişi, motivasyonları ve öğrenme hedeflerini vurgular. ## Analiz Edilecek Kaynak Metin: {{text}} `; /** * 获取 GA 组合生成提示词 * @param {string} language - 语言标识 * @param {Object} params - 参数对象 * @param {string} params.text - 待分析的文本内容 * @param {string} projectId - 项目ID,用于获取自定义提示词 * @returns {Promise} - 完整的提示词 */ export async function getGAGenerationPrompt(language, { text }, projectId = null) { const result = await processPrompt( language, 'ga-generation', 'GA_GENERATION_PROMPT', { zh: GA_GENERATION_PROMPT, en: GA_GENERATION_PROMPT_EN, tr: GA_GENERATION_PROMPT_TR }, { text }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/imageAnswer.js ================================================ import { processPrompt } from '../common/prompt-loader'; import { getQuestionTemplate } from '../common/question-template'; // 原始问题就是默认提示词,templatePrompt、outputFormatPrompt 只有在定义问题模版时才会存在 export const IMAGE_ANSWER_PROMPT = `{{question}}{{templatePrompt}}{{outputFormatPrompt}}`; export const IMAGE_ANSWER_PROMPT_EN = IMAGE_ANSWER_PROMPT; export const IMAGE_ANSWER_PROMPT_TR = IMAGE_ANSWER_PROMPT; /** * 生成图像答案提示词 * @param {string} language - 语言,'en' 或 'zh-CN' * @param {Object} params - 参数对象 * @param {number} params.number - 问题数量 * @param {string} projectId - 项目ID(用于自定义提示词) * @returns {string} - 完整的提示词 */ export async function getImageAnswerPrompt(language, { question, questionTemplate }, projectId = null) { const { templatePrompt, outputFormatPrompt } = getQuestionTemplate(questionTemplate, language); const result = await processPrompt( language, 'imageAnswer', 'IMAGE_ANSWER_PROMPT', { zh: IMAGE_ANSWER_PROMPT, en: IMAGE_ANSWER_PROMPT_EN, tr: IMAGE_ANSWER_PROMPT_TR }, { question, templatePrompt, outputFormatPrompt }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/imageQuestion.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const IMAGE_QUESTION_PROMPT = ` # Role: 图像问题生成专家 ## Profile: - Description: 你是一名专业的视觉内容分析与问题设计专家,能够从图像中提炼关键信息并产出可用于视觉模型微调的高质量问题集合。 - Output Goal: 生成 {{number}} 个高质量问题,用于构建视觉问答训练数据集。 ## Skills: 1. 能够全面理解图像内容,识别核心对象、场景、关系与细节。 2. 擅长设计具有明确答案指向性的问题,覆盖图像多个层面。 3. 善于控制问题难度与类型,保证多样性与代表性。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **图像解析**:仔细观察图像,识别主要对象、场景、颜色、位置关系、动作、情感等要素。 2. **问题设计**:基于图像内容的丰富程度和重要性选择最佳提问切入点,涵盖: - 内容描述(What):图像中有什么对象、场景 - 细节分析(Detail):颜色、形状、数量、位置等具体特征 - 场景理解(Where/When):场景类型、时间、地点等背景信息 - 关系推理(Relation):对象之间的关系、空间位置 - 情感表达(Emotion):图像传达的情感、氛围 - 深度理解(Why/How):可能的原因、目的、方式 3. **质量检查**:逐条校验问题,确保: - 问题答案可在图像中直接观察或合理推断。 - 问题之间主题不重复、角度不雷同。 - 语言表述准确、无歧义且符合常规问句形式。 - 问题具有一定深度,避免过于简单的是非问题。 ## Constraints: 1. 所有问题必须严格基于图像内容,不得添加图像中不存在的信息。 2. 问题需覆盖图像的不同方面(对象、场景、细节、关系等),避免集中于单一元素。 3. 禁止输出与图像元信息相关的问题(如拍摄设备、文件格式等)。 4. 问题不得包含"图片中/照片中/画面中"等冗余表述,直接提问即可。 5. 输出恰好 {{number}} 个问题,且保持格式一致。 6. 避免简单的是非问题,鼓励开放性和描述性问题。 7. 问题必须要自然,不能在问题中出现:"图片中/照片中/画面中/文中/这段文字/这张图片" 这样的表述。 ## Output Format: - 使用合法的 JSON 数组,仅包含字符串元素。 - 字段必须使用英文双引号。 - 严格遵循以下结构: \`\`\`json ["问题1", "问题2", "问题3"] \`\`\` ## Output Example: \`\`\`json ["画面的主要内容是什么?", "前景中的人物在做什么?", "这个场景最可能发生在什么时间?"] \`\`\` 请仔细观察图像,生成 {{number}} 个高质量问题。 `; export const IMAGE_QUESTION_PROMPT_EN = ` # Role: Image Question Generation Expert ## Profile: - Description: You are an expert in visual content analysis and question design, capable of extracting key information from images and producing high-quality questions for vision model fine-tuning datasets. - Output Goal: Generate {{number}} high-quality questions suitable for visual question-answering training data. ## Skills: 1. Comprehend image content thoroughly and identify core objects, scenes, relationships, and details. 2. Design questions with clear answer orientation that cover multiple aspects of the image. 3. Balance difficulty and variety to ensure representative coverage of the visual content. 4. Enforce strict formatting so the output can be consumed programmatically. ## Workflow: 1. **Image Analysis**: Carefully observe the image and identify main objects, scenes, colors, spatial relationships, actions, emotions, and other elements. 2. **Question Design**: Select the most informative focal points based on the richness and importance of image content, covering: - Content Description (What): What objects and scenes are in the image - Detail Analysis (Detail): Specific features like colors, shapes, quantities, positions - Scene Understanding (Where/When): Scene type, time, location, and background information - Relationship Reasoning (Relation): Relationships between objects, spatial positions - Emotional Expression (Emotion): Emotions and atmosphere conveyed by the image - Deep Understanding (Why/How): Possible reasons, purposes, methods 3. **Quality Check**: Validate each question to ensure: - The answer can be directly observed or reasonably inferred from the image. - Questions do not duplicate topics or angles. - Wording is precise, unambiguous, and uses natural interrogative phrasing. - Questions have sufficient depth, avoiding overly simple yes/no questions. ## Constraints: 1. Every question must be strictly based on image content; no information not present in the image. 2. Cover diverse aspects of the image (objects, scenes, details, relationships, etc.); avoid clustering around a single element. 3. Do not include questions about image metadata (camera, file format, etc.). 4. Avoid redundant phrases like "in the image/photo/picture"; ask questions directly. 5. Produce exactly {{number}} questions with consistent formatting. 6. Avoid simple yes/no questions; encourage open-ended and descriptive questions. 7. Questions must be natural, cannot contain phrases like "in the image/photo/picture/this text/this image". ## Output Format: - Return a valid JSON array containing only strings. - Use double quotes for all strings. - Follow this exact structure: \`\`\`json ["Question 1", "Question 2", "Question 3"] \`\`\` ## Output Example: \`\`\`json ["What is the main content of the scene?", "What is the person in the foreground doing?", "When is this scene most likely taking place?"] \`\`\` Please carefully observe the image and generate {{number}} high-quality questions. `; export const IMAGE_QUESTION_PROMPT_TR = ` # Rol: Görsel Soru Oluşturma Uzmanı ## Profil: - Açıklama: Görsel içerik analizi ve soru tasarımında uzman, görüntülerden anahtar bilgileri çıkarabilen ve görme modeli ince ayar veri setleri için yüksek kaliteli sorular üretebilen bir uzmansınız. - Çıktı Hedefi: Görsel soru-cevap eğitim verileri için uygun {{number}} adet yüksek kaliteli soru oluşturun. ## Yetenekler: 1. Görüntü içeriğini kapsamlı şekilde anlayın ve temel nesneleri, sahneleri, ilişkileri ve detayları tanımlayın. 2. Görüntünün birden fazla yönünü kapsayan net cevap yönelimli sorular tasarlayın. 3. Görsel içeriğin temsili kapsamını sağlamak için zorluk ve çeşitliliği dengeleyin. 4. Çıktının programatik olarak tüketilebilmesi için katı biçimlendirme uygulayın. ## İş Akışı: 1. **Görüntü Analizi**: Görüntüyü dikkatlice gözlemleyin ve ana nesneleri, sahneleri, renkleri, mekansal ilişkileri, eylemleri, duyguları ve diğer öğeleri tanımlayın. 2. **Soru Tasarımı**: Görüntü içeriğinin zenginliğine ve önemine göre en bilgilendirici odak noktalarını seçin, kapsayacak alanlar: - İçerik Tanımı (Ne): Görüntüde hangi nesneler ve sahneler var - Detay Analizi (Detay): Renkler, şekiller, miktarlar, pozisyonlar gibi belirli özellikler - Sahne Anlayışı (Nerede/Ne Zaman): Sahne türü, zaman, konum ve arka plan bilgisi - İlişki Akıl Yürütme (İlişki): Nesneler arasındaki ilişkiler, mekansal pozisyonlar - Duygusal İfade (Duygu): Görüntünün ilettiği duygular ve atmosfer - Derin Anlayış (Neden/Nasıl): Olası nedenler, amaçlar, yöntemler 3. **Kalite Kontrolü**: Her soruyu doğrulayarak şunları sağlayın: - Cevap görüntüden doğrudan gözlemlenebilir veya makul şekilde çıkarılabilir. - Sorular konuları veya açıları tekrarlamıyor. - İfadeler kesin, belirsizlikten uzak ve doğal soru cümlesi kullanıyor. - Sorular yeterli derinliğe sahip, aşırı basit evet/hayır sorularından kaçınılıyor. ## Kısıtlamalar: 1. Her soru kesinlikle görüntü içeriğine dayalı olmalı; görüntüde olmayan bilgi eklenmemeli. 2. Görüntünün çeşitli yönlerini kapsayın (nesneler, sahneler, detaylar, ilişkiler, vb.); tek bir öğe etrafında kümelenmekten kaçının. 3. Görüntü meta verileri hakkında sorular eklemeyin (kamera, dosya formatı, vb.). 4. "Görüntüde/fotoğrafta/resimde" gibi gereksiz ifadelerden kaçının; doğrudan soru sorun. 5. Tutarlı biçimlendirmeyle tam olarak {{number}} soru üretin. 6. Basit evet/hayır sorularından kaçının; açık uçlu ve tanımlayıcı soruları teşvik edin. 7. Sorular doğal olmalı, "görüntüde/fotoğrafta/resimde/bu metinde/bu görüntüde" gibi ifadeler içeremez. ## Çıktı Formatı: - Yalnızca dizeler içeren geçerli bir JSON dizisi döndürün. - Tüm dizeler için çift tırnak kullanın. - Bu tam yapıyı izleyin: \`\`\`json ["Soru 1", "Soru 2", "Soru 3"] \`\`\` ## Çıktı Örneği: \`\`\`json ["Sahnenin ana içeriği nedir?", "Ön plandaki kişi ne yapıyor?", "Bu sahne büyük olasılıkla ne zaman gerçekleşiyor?"] \`\`\` Lütfen görüntüyü dikkatlice gözlemleyin ve {{number}} adet yüksek kaliteli soru oluşturun. `; /** * 生成图像问题提示词 * @param {string} language - 语言,'en' 或 'zh-CN' * @param {Object} params - 参数对象 * @param {number} params.number - 问题数量 * @param {string} projectId - 项目ID(用于自定义提示词) * @returns {string} - 完整的提示词 */ export async function getImageQuestionPrompt(language, { number = 3 }, projectId = null) { const result = await processPrompt( language, 'imageQuestion', 'IMAGE_QUESTION_PROMPT', { zh: IMAGE_QUESTION_PROMPT, en: IMAGE_QUESTION_PROMPT_EN, tr: IMAGE_QUESTION_PROMPT_TR }, { number }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/label.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const LABEL_PROMPT = ` # Role: 领域分类专家 & 知识图谱专家 - Description: 作为一名资深的领域分类专家和知识图谱专家,擅长从文本内容中提取核心主题,构建分类体系,并输出规定 JSON 格式的标签树。 ## Skills: 1. 精通文本主题分析和关键词提取 2. 擅长构建分层知识体系 3. 熟练掌握领域分类方法论 4. 具备知识图谱构建能力 5. 精通JSON数据结构 ## Goals: 1. 分析书籍目录内容 2. 识别核心主题和关键领域 3. 构建两级分类体系 4. 确保分类逻辑合理 5. 生成规范的JSON输出 ## Workflow: 1. 仔细阅读完整的书籍目录内容 2. 提取关键主题和核心概念 3. 对主题进行分组和归类 4. 构建一级领域标签 5. 为适当的一级标签添加二级标签 6. 检查分类逻辑的合理性 7. 生成符合格式的JSON输出 ## 需要分析的目录 {{text}} ## 限制 1. 一级领域标签数量5-10个 2. 二级领域标签数量1-10个 3. 最多两层分类层级 4. 分类必须与原始目录内容相关 5. 输出必须符合指定 JSON 格式,不要输出 JSON 外其他任何不相关内容 6. 标签的名字最多不要超过 6 个字 7. 在每个标签前加入序号(序号不计入字数) ## OutputFormat: \`\`\`json [ { "label": "1 一级领域标签", "child": [ {"label": "1.1 二级领域标签1"}, {"label": "1.2 二级领域标签2"} ] }, { "label": "2 一级领域标签(无子标签)" } ] \`\`\` `; export const LABEL_PROMPT_EN = ` # Role: Domain Classification Expert & Knowledge Graph Expert - Description: As a senior domain classification expert and knowledge graph expert, you are skilled at extracting core themes from text content, constructing classification systems, and performing knowledge categorization and labeling. ## Skills: 1. Proficient in text theme analysis and keyword extraction. 2. Good at constructing hierarchical knowledge systems. 3. Skilled in domain classification methodologies. 4. Capable of building knowledge graphs. 5. Proficient in JSON data structures. ## Goals: 1. Analyze the content of the book catalog. 2. Identify core themes and key domains. 3. Construct a two - level classification system. 4. Ensure the classification logic is reasonable. 5. Generate a standardized JSON output. ## Workflow: 1. Carefully read the entire content of the book catalog. 2. Extract key themes and core concepts. 3. Group and categorize the themes. 4. Construct primary domain labels (ensure no more than 10). 5. Add secondary labels to appropriate primary labels (no more than 5 per group). 6. Check the rationality of the classification logic. 7. Generate a JSON output that conforms to the format. ## Catalog to be analyzed {{text}} ## Constraints 1. The number of primary domain labels should be between 5 and 10. 2. The number of secondary domain labels ≤ 5 per primary label. 3. There should be at most two classification levels. 4. The classification must be relevant to the original catalog content. 5. The output must conform to the specified JSON format. 6. The names of the labels should not exceed 6 characters. 7. Do not output any content other than the JSON. 8. Add a serial number before each label (the serial number does not count towards the character limit). 9. Use English ## OutputFormat: \`\`\`json [ { "label": "1 Primary Domain Label", "child": [ {"label": "1.1 Secondary Domain Label 1"}, {"label": "1.2 Secondary Domain Label 2"} ] }, { "label": "2 Primary Domain Label (No Sub - labels)" } ] \`\`\` `; export const LABEL_PROMPT_TR = ` # Rol: Alan Sınıflandırma Uzmanı & Bilgi Grafiği Uzmanı - Açıklama: Kıdemli bir alan sınıflandırma uzmanı ve bilgi grafiği uzmanı olarak, metin içeriğinden temel temaları çıkarmada, sınıflandırma sistemleri oluşturmada ve bilgi kategorizasyonu ve etiketlemesinde yeteneklisiniz. ## Yetenekler: 1. Metin tema analizi ve anahtar kelime çıkarımında yetkin. 2. Hiyerarşik bilgi sistemleri oluşturmada iyi. 3. Alan sınıflandırma metodolojilerinde yetenekli. 4. Bilgi grafikleri oluşturma yeteneği. 5. JSON veri yapılarında yetkin. ## Hedefler: 1. Kitap kataloğunun içeriğini analiz edin. 2. Temel temaları ve anahtar alanları tanımlayın. 3. İki seviyeli bir sınıflandırma sistemi oluşturun. 4. Sınıflandırma mantığının makul olduğundan emin olun. 5. Standart bir JSON çıktısı oluşturun. ## İş Akışı: 1. Kitap kataloğunun tüm içeriğini dikkatlice okuyun. 2. Temel temaları ve çekirdek kavramları çıkarın. 3. Temaları gruplandırın ve kategorize edin. 4. Birincil alan etiketleri oluşturun (10'dan fazla olmadığından emin olun). 5. Uygun birincil etiketlere ikincil etiketler ekleyin (grup başına 5'ten fazla olmayacak şekilde). 6. Sınıflandırma mantığının mantıklılığını kontrol edin. 7. Formata uygun bir JSON çıktısı oluşturun. ## Analiz edilecek katalog {{text}} ## Kısıtlamalar 1. Birincil alan etiketi sayısı 5 ile 10 arasında olmalıdır. 2. İkincil alan etiketi sayısı ≤ birincil etiket başına 5. 3. En fazla iki sınıflandırma seviyesi olmalıdır. 4. Sınıflandırma orijinal katalog içeriğiyle alakalı olmalıdır. 5. Çıktı belirtilen JSON formatına uygun olmalıdır. 6. Etiket adları 6 karakteri geçmemelidir. 7. JSON dışında başka herhangi bir içerik çıktılamayın. 8. Her etiketin önüne bir seri numarası ekleyin (seri numarası karakter sınırına dahil değildir). 9. Türkçe kullanın ## ÇıktıFormatı: \`\`\`json [ { "label": "1 Birincil Alan Etiketi", "child": [ {"label": "1.1 İkincil Alan Etiketi 1"}, {"label": "1.2 İkincil Alan Etiketi 2"} ] }, { "label": "2 Birincil Alan Etiketi (Alt etiket yok)" } ] \`\`\` `; /** * 获取领域标签生成提示词 * @param {string} language - 语言标识 * @param {Object} params - 参数对象 * @param {string} params.text - 待分析的目录文本 * @param {string} projectId - 项目ID,用于获取自定义提示词 * @returns {Promise} - 完整的提示词 */ export async function getLabelPrompt(language, { text }, projectId = null) { const result = await processPrompt( language, 'label', 'LABEL_PROMPT', { zh: LABEL_PROMPT, en: LABEL_PROMPT_EN, tr: LABEL_PROMPT_TR }, { text }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/labelRevise.js ================================================ /** * 领域树增量修订提示词 * 用于在已有领域树的基础上,针对新增/删除的文献内容,对领域树进行增量调整 */ import { processPrompt } from '../common/prompt-loader'; export const LABEL_REVISE_PROMPT = ` # Role: 领域树修订专家 ## Profile: - Description: 你是一位专业的知识分类与领域树管理专家,擅长根据内容变化对现有领域树结构进行增量修订。 - Task: 分析内容变化并修订现有的领域树结构,确保其准确反映当前文献的主题分布。 ## Skills: 1. 深度分析现有领域树结构与实际内容的匹配关系 2. 准确评估内容变化对领域分类的影响程度 3. 设计稳定且合理的领域树增量调整方案 4. 确保修订后的分类体系具有良好的层次性和逻辑性 ## Workflow: 1. **现状分析**:梳理已有领域树结构和当前所有文献目录 2. **变化识别**:分析删除内容和新增内容对标签体系的影响 3. **策略制定**:确定保留、删除、新增标签的具体策略 4. **结构调整**:执行增量修订,保持整体稳定性 5. **质量验证**:确保修订后的领域树符合层次结构要求 ## Constraints: 1. 结构稳定性原则: - 保持领域树的总体结构稳定,避免大规模重构 - 优先使用现有标签,最小化变动 2. 内容关联性处理: - 删除内容相关标签:仅与删除内容相关且无其他支持的标签应移除,与其他内容相关的标签予以保留 - 新增内容处理:优先归入现有标签,确实无法归类时才创建新标签 3. 标签质量要求: - 每个标签必须对应目录中的实际内容,不创建空标签 - 标签名称简洁明确,最多6个字(不含序号) - 必须在标签前加入序号(序号不计入字数) 4. 层次结构限制: - 一级领域标签数量:5-10个 - 二级领域标签数量:每个一级标签下1-10个 - 最多两层分类层级 - 确保标签间具有合理的父子关系 5. 输出格式要求: - 严格按照JSON格式输出 - 不输出任何解释性文字 - 确保JSON结构完整有效 ## Data Sources: ### 现有领域树结构: {{existingTags}} ### 当前文献目录总览: {{text}} {{deletedContent}} {{newContent}} ## Output Format: - 仅返回修订后的完整领域树JSON结构 - 格式示例: \`\`\`json [ { "label": "1 一级领域标签", "child": [ {"label": "1.1 二级领域标签1"}, {"label": "1.2 二级领域标签2"} ] }, { "label": "2 一级领域标签(无子标签)" } ] \`\`\` `; export const LABEL_REVISE_PROMPT_EN = ` # Role: Domain Tree Revision Expert ## Profile: - Description: You are a professional knowledge classification and domain tree management expert, specialized in incrementally revising existing domain tree structures based on content changes. - Task: Analyze content changes and revise the existing domain tree structure to accurately reflect the current distribution of literature topics. ## Skills: 1. Deeply analyze the matching relationship between existing domain tree structures and actual content 2. Accurately assess the impact of content changes on domain classification 3. Design stable and reasonable incremental adjustment strategies for domain trees 4. Ensure the revised classification system has good hierarchy and logic ## Workflow: 1. **Current State Analysis**: Organize existing domain tree structure and current literature catalogs 2. **Change Identification**: Analyze the impact of deleted and added content on the tag system 3. **Strategy Development**: Determine specific strategies for retaining, deleting, and adding tags 4. **Structure Adjustment**: Execute incremental revisions while maintaining overall stability 5. **Quality Verification**: Ensure the revised domain tree meets hierarchical structure requirements ## Constraints: 1. Structural stability principles: - Maintain overall domain tree structure stability, avoiding large-scale reconstruction - Prioritize using existing tags to minimize changes 2. Content association handling: - Tags related to deleted content: Remove tags only related to deleted content with no other support; retain tags related to other content - New content handling: Prioritize classification into existing tags; create new tags only when classification is impossible 3. Tag quality requirements: - Each tag must correspond to actual content in the catalog; do not create empty tags - Tag names should be concise and clear, maximum 6 characters (excluding serial numbers) - Must add serial numbers before tags (serial numbers do not count toward character limit) 4. Hierarchical structure limitations: - Primary domain tag count: 5-10 - Secondary domain tag count: 1-10 per primary tag - Maximum two classification levels - Ensure reasonable parent-child relationships between tags 5. Output format requirements: - Strictly output in JSON format - No explanatory text - Ensure complete and valid JSON structure ## Data Sources: ### Existing Domain Tree Structure: {{existingTags}} ### Current Literature Catalog Overview: {{text}} {{deletedContent}} {{newContent}} ## Output Format: - Return only the revised complete domain tree JSON structure - Format example: \`\`\`json [ { "label": "1 Primary Domain Label", "child": [ {"label": "1.1 Secondary Domain Label 1"}, {"label": "1.2 Secondary Domain Label 2"} ] }, { "label": "2 Primary Domain Label (No Sub-labels)" } ] \`\`\` `; export const LABEL_REVISE_PROMPT_TR = ` # Rol: Alan Ağacı Revizyon Uzmanı ## Profil: - Açıklama: Bilgi sınıflandırması ve alan ağacı yönetiminde uzman, içerik değişikliklerine dayalı olarak mevcut alan ağacı yapılarını aşamalı olarak revize etme konusunda uzmanlaşmış profesyonel bir uzmansınız. - Görev: İçerik değişikliklerini analiz edin ve mevcut literatür konularının dağılımını doğru şekilde yansıtmak için alan ağacı yapısını revize edin. ## Yetenekler: 1. Mevcut alan ağacı yapıları ile gerçek içerik arasındaki eşleşme ilişkisini derinlemesine analiz etme 2. İçerik değişikliklerinin alan sınıflandırması üzerindeki etkisini doğru şekilde değerlendirme 3. Alan ağaçları için istikrarlı ve makul aşamalı ayarlama stratejileri tasarlama 4. Revize edilen sınıflandırma sisteminin iyi hiyerarşi ve mantığa sahip olmasını sağlama ## İş Akışı: 1. **Mevcut Durum Analizi**: Mevcut alan ağacı yapısını ve güncel literatür kataloglarını düzenleyin 2. **Değişiklik Tanımlama**: Silinen ve eklenen içeriğin etiket sistemi üzerindeki etkisini analiz edin 3. **Strateji Geliştirme**: Etiketleri koruma, silme ve ekleme için belirli stratejiler belirleyin 4. **Yapı Ayarlaması**: Genel istikrarı koruyarak aşamalı revizyonları uygulayın 5. **Kalite Doğrulama**: Revize edilen alan ağacının hiyerarşik yapı gereksinimlerini karşıladığından emin olun ## Kısıtlamalar: 1. Yapısal istikrar ilkeleri: - Genel alan ağacı yapısının istikrarını koruyun, büyük ölçekli yeniden yapılandırmadan kaçının - Değişiklikleri en aza indirmek için mevcut etiketleri kullanmaya öncelik verin 2. İçerik ilişkilendirme işleme: - Silinen içerikle ilgili etiketler: Yalnızca silinen içerikle ilgili ve başka desteği olmayan etiketleri kaldırın; diğer içerikle ilgili etiketleri koruyun - Yeni içerik işleme: Mevcut etiketlere sınıflandırmaya öncelik verin; sınıflandırma imkansız olduğunda yalnızca yeni etiketler oluşturun 3. Etiket kalite gereksinimleri: - Her etiket katalogdaki gerçek içeriğe karşılık gelmelidir; boş etiketler oluşturmayın - Etiket adları kısa ve net olmalıdır, maksimum 6 karakter (seri numaraları hariç) - Etiketlerin önüne seri numaraları eklenmelidir (seri numaraları karakter limitine dahil değildir) 4. Hiyerarşik yapı kısıtlamaları: - Birincil alan etiketi sayısı: 5-10 - İkincil alan etiketi sayısı: Her birincil etiket altında 1-10 - Maksimum iki sınıflandırma seviyesi - Etiketler arasında makul ebeveyn-çocuk ilişkileri sağlayın 5. Çıktı formatı gereksinimleri: - Katı şekilde JSON formatında çıktı verin - Açıklayıcı metin yok - Tam ve geçerli JSON yapısını sağlayın ## Veri Kaynakları: ### Mevcut Alan Ağacı Yapısı: {{existingTags}} ### Güncel Literatür Kataloğu Genel Görünümü: {{text}} {{deletedContent}} {{newContent}} ## Çıktı Formatı: - Yalnızca revize edilmiş tam alan ağacı JSON yapısını döndürün - Format örneği: \`\`\`json [ { "label": "1 Birincil Alan Etiketi", "child": [ {"label": "1.1 İkincil Alan Etiketi 1"}, {"label": "1.2 İkincil Alan Etiketi 2"} ] }, { "label": "2 Birincil Alan Etiketi (Alt Etiket Yok)" } ] \`\`\` `; export async function getLabelRevisePrompt( language, { text, existingTags, deletedContent, newContent }, projectId = null ) { let deletedContentText = ''; let newContentText = ''; console.log(9992222, deletedContent); if (deletedContent) { const messages = { en: `## Deleted Content \n Here are the table of contents from the deleted literature:\n ${deletedContent}`, tr: `## Silinen İçerik \n İşte silinen literatürdeki içindekiler tablosu:\n ${deletedContent}`, zh: `## 被删除的内容 \n 以下是本次要删除的文献目录信息:\n ${deletedContent}` }; deletedContentText = messages[language] || messages.zh; } if (newContent) { const messages = { en: `## New Content \n Here are the table of contents from the newly added literature:\n ${newContent}`, tr: `## Yeni İçerik \n İşte yeni eklenen literatürdeki içindekiler tablosu:\n ${newContent}`, zh: `## 新增的内容 \n 以下是本次新增的文献目录信息:\n ${newContent}` }; newContentText = messages[language] || messages.zh; } const result = await processPrompt( language, 'labelRevise', 'LABEL_REVISE_PROMPT', { zh: LABEL_REVISE_PROMPT, en: LABEL_REVISE_PROMPT_EN, tr: LABEL_REVISE_PROMPT_TR }, { existingTags: JSON.stringify(existingTags, null, 2), text, deletedContent: deletedContentText, newContent: newContentText }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/llmJudge.js ================================================ import { processPrompt } from '../common/prompt-loader'; /** * LLM 评估提示词 * 用于评估模型在主观题目上的回答质量 */ // ============ 默认评分规则常量 ============ // 简答题默认评分规则(中文) export const DEFAULT_SHORT_ANSWER_SCORE_ANCHORS_ZH = [ { range: '1.0', description: '完全等价正确(数值/范围/实体/要点都对);无冲突或编造。' }, { range: '0.8-0.9', description: '基本正确;仅有轻微不严谨或漏次要点;无关键错误。' }, { range: '0.6-0.7', description: '部分正确但存在明显遗漏;核心方向仍对;无重大错误。' }, { range: '0.3-0.5', description: '仅命中少量信息;或存在多处不准确;难以认为回答到位。' }, { range: '0.0-0.2', description: '答非所问/关键事实错误/大量编造/空答。' } ]; // 简答题默认评分规则(英文) export const DEFAULT_SHORT_ANSWER_SCORE_ANCHORS_EN = [ { range: '1.0', description: 'Fully equivalent correct; no contradictions or fabrication.' }, { range: '0.8-0.9', description: 'Mostly correct; only minor omissions/imprecision; no key errors.' }, { range: '0.6-0.7', description: 'Partly correct with clear omissions; core direction correct; no major errors.' }, { range: '0.3-0.5', description: 'Only small parts correct and/or multiple inaccuracies; inadequate.' }, { range: '0.0-0.2', description: 'Off-topic / key factual wrong / heavy fabrication / empty.' } ]; // 开放题默认评分规则(中文) export const DEFAULT_OPEN_ENDED_SCORE_ANCHORS_ZH = [ { range: '1.0', description: '关键点充分且正确;论证扎实;无明显错误;细节具体。' }, { range: '0.8-0.9', description: '整体很强;仅有轻微遗漏/措辞不严谨;无关键错误。' }, { range: '0.6-0.7', description: '能回答问题但不够完整或偏泛;论证一般;可能有小错误。' }, { range: '0.3-0.5', description: '明显不完整、偏题或泛泛而谈;或包含多处不准确。' }, { range: '0.0-0.2', description: '答非所问/大量编造/严重错误/空答。' } ]; // 开放题默认评分规则(英文) export const DEFAULT_OPEN_ENDED_SCORE_ANCHORS_EN = [ { range: '1.0', description: 'Correct, thorough, well-justified, concrete; no notable errors.' }, { range: '0.8-0.9', description: 'Strong overall; only minor omissions/wording; no key errors.' }, { range: '0.6-0.7', description: 'Adequate but incomplete or generic; average reasoning; maybe minor errors.' }, { range: '0.3-0.5', description: 'Incomplete, off-topic, or overly generic; and/or multiple inaccuracies.' }, { range: '0.0-0.2', description: 'Irrelevant, largely fabricated, severely wrong, or empty.' } ]; /** * 获取默认评分规则 * @param {string} questionType - 题目类型:'short_answer' 或 'open_ended' * @param {string} language - 语言:'zh-CN' 或 'en' * @returns {Array} 评分规则数组 */ export function getDefaultScoreAnchors(questionType, language = 'zh-CN') { const isEn = language === 'en'; if (questionType === 'open_ended') { return isEn ? DEFAULT_OPEN_ENDED_SCORE_ANCHORS_EN : DEFAULT_OPEN_ENDED_SCORE_ANCHORS_ZH; } return isEn ? DEFAULT_SHORT_ANSWER_SCORE_ANCHORS_EN : DEFAULT_SHORT_ANSWER_SCORE_ANCHORS_ZH; } /** * 将评分规则数组格式化为提示词文本 * @param {Array} scoreAnchors - 评分规则数组 * @returns {string} 格式化后的文本 */ export function formatScoreAnchors(scoreAnchors) { if (!scoreAnchors || scoreAnchors.length === 0) { return ''; } return scoreAnchors.map(anchor => `- ${anchor.range}:${anchor.description}`).join('\n'); } // ============ LLM 评分提示词 ============ export const SHORT_ANSWER_JUDGE_PROMPT = ` # Role: 严谨的阅卷教师(短答案) ## Task 对短答案进行严格评分:答案通常是数值/百分比/范围/实体名/短语/要点列表。只要有关键缺失或事实错误就要明显扣分,不要默认给高分。 ## Grading Rules - 先从参考答案中提炼关键点(短答案通常 1-5 个即可):事实/数值/单位/范围/对象/限定条件/结论。 - 对照学生答案逐条判断:命中/部分命中/未命中,并检查是否出现“冲突点”。 - 允许同义改写与等价表达,但不允许改变事实(例如把 84% 写成 84 台、把“近 3 倍”写成“3%”)。 - 发现编造、与参考答案冲突、概念性错误:必须显著扣分。 - 句子更长≠更好;短答案看信息是否对、是否全、是否精确。 ## Normalization (评分前先做一致化理解) - 忽略空格、全半角、大小写差异;百分号“%”与“百分之”视为等价。 - 数值允许轻微四舍五入(例如 83.9%≈84%),但单位必须一致或可等价换算。 - 范围/区间必须落在参考范围内(如 “30%~50%”),越界视为错误。 - 列表/多要点答案:必须覆盖所有关键项;漏 1 项是“缺失”,写错 1 项是“错误”。 ## Score Anchors (0.0-1.0, step 0.1) {{scoreAnchors}} ## Caps (用于防止高分泛滥) - 缺失任何“核心关键点”(问题必须回答的点):score ≤ 0.7 - 出现 1 个“重大事实错误/与参考冲突”:score ≤ 0.4 - 出现 2 个及以上重大错误或大量编造:score ≤ 0.2 ## Question {{question}} ## Reference Answer {{correctAnswer}} ## Student Answer {{modelAnswer}} ## Output 请严格按照以下 JSON 格式输出评分结果,不要添加任何其他内容: \`\`\`json { "score": <根据 Score Anchors 评定的分数>, "reason": "<评分理由>" } \`\`\` 要求: - score 为 0.0-1.0,精确到 0.1,严格依据上述 Score Anchors 标准评定 - reason ≤ 100 字,必须点出:命中情况 + 主要缺失/错误 + 总评 - 只输出可解析 JSON `; export const SHORT_ANSWER_JUDGE_PROMPT_EN = ` # Role: Strict Grader (Short Answer) ## Task Grade strictly. Short answers are usually numbers/percentages/ranges/entities/phrases/bullets. Do not default to high scores. ## Grading Rules - Extract key points from the reference answer (usually 1-5 for short answers): value/unit/range/entity/constraints/conclusion. - Check each point as hit / partial / miss, and also look for contradictions. - Accept equivalent paraphrases, but not changed facts (e.g., 84% ≠ 84 units). - Heavily penalize hallucinations, contradictions, and concept errors. - Longer wording is not better; short answers are judged by correctness and completeness. ## Normalization - Ignore whitespace/case/locale punctuation differences; treat “%” and “percent” equivalently. - Allow minor rounding (e.g., 83.9% ≈ 84%), but units must match or be equivalent. - Ranges must fall within the reference range; out-of-range is wrong. - Lists must cover all required items; missing = incomplete, wrong item = error. ## Score Anchors (0.0-1.0, step 0.1) {{scoreAnchors}} ## Caps - Missing any essential key point: score ≤ 0.7 - One major factual contradiction/hallucination: score ≤ 0.4 - Two+ major errors or lots of fabrication: score ≤ 0.2 ## Question {{question}} ## Reference Answer {{correctAnswer}} ## Student Answer {{modelAnswer}} ## Output Strictly output the result in the following JSON format, with no extra text: \`\`\`json { "score": , "reason": "" } \`\`\` Requirements: - score in [0.0, 1.0], step 0.1, strictly follow the Score Anchors above - reason ≤ 100 words: hits + main misses/errors + overall - JSON only `; export const OPEN_ENDED_JUDGE_PROMPT = ` # Role: 严谨的评估专家(开放题/长答案) ## Task 评估长答案的质量与可靠性,避免“看起来不错就给 0.8”。参考答案用于核对关键事实与覆盖面,但允许合理的等价表达与不同论证路径。 ## What to Judge 1) 关键正确性:核心事实/概念是否正确,是否自相矛盾或编造。 2) 覆盖与针对性:是否回答了题目要求,是否遗漏关键部分或跑题。 3) 论证与结构:是否有清晰结构、因果链/依据,是否只给空泛结论。 4) 可用性:是否给出具体、可执行/可验证的说明(视题目而定)。 ## Score Anchors (0.0-1.0, step 0.1) {{scoreAnchors}} ## Caps - 出现 1 个“重大事实错误/与参考关键事实冲突/关键推理错误”:score ≤ 0.4 - 出现 2 个及以上重大错误或大量编造:score ≤ 0.2 - 明显跑题或只给泛泛总结:score ≤ 0.5 ## Question {{question}} ## Reference Answer (for checking) {{correctAnswer}} ## Student Answer {{modelAnswer}} ## Output 请严格按照以下 JSON 格式输出评分结果,不要添加任何其他内容: \`\`\`json { "score": <根据 Score Anchors 评定的分数>, "reason": "<评分理由>" } \`\`\` 要求: - score 为 0.0-1.0,精确到 0.1,严格依据上述 Score Anchors 标准评定 - reason ≤ 150 字,必须包含:优点 + 主要问题(遗漏/错误/空泛/跑题)+ 总评 - 只输出可解析 JSON `; export const OPEN_ENDED_JUDGE_PROMPT_EN = ` # Role: Strict Evaluator (Open-ended / Long Answer) ## Task Judge quality and reliability. The reference answer helps validate key facts/coverage, but allow equivalent correct approaches. Do not default to 0.8. ## What to Judge 1) Key correctness: core facts/concepts are correct; no contradictions or fabricated claims. 2) Coverage & focus: answers what the question asks; avoids drifting; covers essential parts. 3) Reasoning & structure: clear structure and justification; not just vague conclusions. 4) Usefulness: concrete, actionable/verifiable details when applicable. ## Score Anchors (0.0-1.0, step 0.1) {{scoreAnchors}} ## Caps - One major factual contradiction/hallucination or critical reasoning error: score ≤ 0.4 - Two+ major errors or heavy fabrication: score ≤ 0.2 - Clearly off-topic or purely generic summary: score ≤ 0.5 ## Question {{question}} ## Reference Answer (for checking) {{correctAnswer}} ## Student Answer {{modelAnswer}} ## Output Strictly output the result in the following JSON format, with no extra text: \`\`\`json { "score": , "reason": "" } \`\`\` Requirements: - score in [0.0, 1.0], step 0.1, strictly follow the Score Anchors above - reason ≤ 150 words: strengths + main issues (missing/error/generic/off-topic) + overall - JSON only `; // ============ 提示词获取函数 ============ /** * 构建评分提示词(使用 processPrompt 规范化处理) * @param {string} questionType - 题型 * @param {string} question - 题目 * @param {string} correctAnswer - 正确答案 * @param {string} modelAnswer - 模型答案 * @param {string} language - 语言 * @param {Array} customScoreAnchors - 自定义评分规则(可选) * @param {string} projectId - 项目ID(可选) * @returns {Promise} - 完整的评分提示词 */ export async function buildJudgePrompt( questionType, question, correctAnswer, modelAnswer, language = 'zh-CN', customScoreAnchors = null, projectId = null ) { let promptType, baseKey, defaultPrompts; switch (questionType) { case 'short_answer': promptType = 'shortAnswerJudge'; baseKey = 'SHORT_ANSWER_JUDGE_PROMPT'; defaultPrompts = { zh: SHORT_ANSWER_JUDGE_PROMPT, en: SHORT_ANSWER_JUDGE_PROMPT_EN }; break; case 'open_ended': promptType = 'openEndedJudge'; baseKey = 'OPEN_ENDED_JUDGE_PROMPT'; defaultPrompts = { zh: OPEN_ENDED_JUDGE_PROMPT, en: OPEN_ENDED_JUDGE_PROMPT_EN }; break; default: promptType = 'shortAnswerJudge'; baseKey = 'SHORT_ANSWER_JUDGE_PROMPT'; defaultPrompts = { zh: SHORT_ANSWER_JUDGE_PROMPT, en: SHORT_ANSWER_JUDGE_PROMPT_EN }; } // 获取评分规则文本(自定义或默认) let scoreAnchorsText; if (customScoreAnchors && Array.isArray(customScoreAnchors) && customScoreAnchors.length === 5) { scoreAnchorsText = formatScoreAnchors(customScoreAnchors); } else { const defaultScoreAnchors = getDefaultScoreAnchors(questionType, language); scoreAnchorsText = formatScoreAnchors(defaultScoreAnchors); } const params = { question, correctAnswer, modelAnswer, scoreAnchors: scoreAnchorsText }; return await processPrompt(language, promptType, baseKey, defaultPrompts, params, projectId); } export default { buildJudgePrompt, getDefaultScoreAnchors, formatScoreAnchors, SHORT_ANSWER_JUDGE_PROMPT, SHORT_ANSWER_JUDGE_PROMPT_EN, OPEN_ENDED_JUDGE_PROMPT, OPEN_ENDED_JUDGE_PROMPT_EN }; ================================================ FILE: lib/llm/prompts/modelEvaluation.js ================================================ import { processPrompt } from '../common/prompt-loader'; /** * 模型评估 - 答题提示词 * 用于模型回答各类测评题目 * * 注意:LLM 评分提示词已移至 judgePrompt.js */ // ============ 题目回答提示词 ============ export const TRUE_FALSE_ANSWER_PROMPT = ` # Role: 判断题回答助手 ## Profile: - Description: 你是一个专业的测评助手,需要根据你的知识准确回答是否判断题。 ## Task: 请回答以下是否判断题。 ## 题目: {{question}} ## 回答要求: 1. 仅输出 "✅" 或 "❌" 2. ✅ 表示题目陈述正确 3. ❌ 表示题目陈述错误 4. 不要添加任何解释、说明或额外内容 5. 不要使用其他符号或文字 ## 回答格式: ✅ 或 ❌ `; export const TRUE_FALSE_ANSWER_PROMPT_EN = ` # Role: True/False Question Answering Assistant ## Profile: - Description: You are a professional assessment assistant who needs to accurately answer true/false questions based on your knowledge. ## Task: Please answer the following true/false question. ## Question: {{question}} ## Answer Requirements: 1. Only output "✅" or "❌" 2. ✅ means the statement is correct 3. ❌ means the statement is incorrect 4. Do not add any explanation or additional content 5. Do not use other symbols or text ## Answer Format: ✅ or ❌ `; export const SINGLE_CHOICE_ANSWER_PROMPT = ` # Role: 单选题回答助手 ## Profile: - Description: 你是一个专业的测评助手,需要根据你的知识从给定选项中选择唯一正确答案。 ## Task: 请回答以下单选题。 ## 题目: {{question}} ## 选项: {{options}} ## 回答要求: 1. 仅输出正确选项的字母标识(A、B、C、D 中的一个) 2. 只能选择一个选项 3. 不要添加任何解释、说明或额外内容 4. 不要输出选项内容,只输出字母 ## 回答格式: B (示例:如果认为选项B正确,则只输出字母 B) `; export const SINGLE_CHOICE_ANSWER_PROMPT_EN = ` # Role: Single Choice Question Answering Assistant ## Profile: - Description: You are a professional assessment assistant who needs to select the only correct answer from given options based on your knowledge. ## Task: Please answer the following single-choice question. ## Question: {{question}} ## Options: {{options}} ## Answer Requirements: 1. Only output the letter identifier of the correct option (one of A, B, C, D) 2. Can only select one option 3. Do not add any explanation or additional content 4. Do not output option content, only the letter ## Answer Format: B (Example: If you think option B is correct, only output the letter B) `; export const MULTIPLE_CHOICE_ANSWER_PROMPT = ` # Role: 多选题回答助手 ## Profile: - Description: 你是一个专业的测评助手,需要根据你的知识从给定选项中选择所有正确答案。 ## Task: 请回答以下多选题。 ## 题目: {{question}} ## 选项: {{options}} ## 回答要求: 1. 输出所有正确选项的字母标识,使用 JSON 数组格式 2. 必须选择 2 个或以上的选项 3. 字母按升序排列(如 ["A", "C", "D"]) 4. 不要添加任何解释、说明或额外内容 5. 严格遵循 JSON 数组格式 ## 回答格式: ["A", "C"] (示例:如果认为选项A和C正确,则输出 ["A", "C"]) `; export const MULTIPLE_CHOICE_ANSWER_PROMPT_EN = ` # Role: Multiple Choice Question Answering Assistant ## Profile: - Description: You are a professional assessment assistant who needs to select all correct answers from given options based on your knowledge. ## Task: Please answer the following multiple-choice question. ## Question: {{question}} ## Options: {{options}} ## Answer Requirements: 1. Output all correct option letter identifiers in JSON array format 2. Must select 2 or more options 3. Letters in ascending order (e.g., ["A", "C", "D"]) 4. Do not add any explanation or additional content 5. Strictly follow JSON array format ## Answer Format: ["A", "C"] (Example: If you think options A and C are correct, output ["A", "C"]) `; export const SHORT_ANSWER_PROMPT = ` # Role: 短答案题回答助手 ## Profile: - Description: 你是一个专业的测评助手,需要根据你的知识提供简短、准确的答案。 ## Task: 请回答以下短答案题。 ## 题目: {{question}} ## 回答要求: 1. 答案必须极短,符合以下三种形式之一: - 一个词或一个短语(不要分点、不要换行、不要解释) - 一个数字或一个数值(可包含小数/百分号/单位) - 一句简单的话(单句,不要分号/冒号/并列要点,不要解释原因) 2. 直接回答问题的核心要点 3. 不要添加"答案是"、"根据"等前缀 4. 不要添加任何解释或补充说明 ## 回答格式示例: 神经网络 或 1000 或 深度学习是机器学习的一个分支 `; export const SHORT_ANSWER_PROMPT_EN = ` # Role: Short Answer Question Answering Assistant ## Profile: - Description: You are a professional assessment assistant who needs to provide concise and accurate answers based on your knowledge. ## Task: Please answer the following short-answer question. ## Question: {{question}} ## Answer Requirements: 1. Answer must be ultra-short, in one of these three forms: - A single word or short phrase (no lists, no line breaks, no explanation) - A single number or numeric value (decimals/percent/units allowed) - One simple sentence (single sentence only; no lists; no explanation) 2. Directly answer the core point of the question 3. Do not add prefixes like "The answer is" or "According to" 4. Do not add any explanation or supplementary information ## Answer Format Examples: neural network or 1000 or Deep learning is a branch of machine learning `; export const OPEN_ENDED_ANSWER_PROMPT = ` # Role: 开放式问题回答助手 ## Profile: - Description: 你是一个专业的测评助手,需要根据你的知识对开放式问题进行深入、全面的回答。 ## Task: 请回答以下开放式问题。 ## 题目: {{question}} ## 回答要求: 1. 回答应全面、有深度,体现对问题的深入理解 2. 提供合理的论据、分析和论证 3. 逻辑清晰,结构完整 4. 可以使用段落、分点、步骤、对比等任意合适的形式组织答案 5. 回答长度适中,充分展开但不冗余 ## 回答格式: 根据问题特点,自由组织答案结构,可以使用: - 段落式论述 - 分点阐述 - 步骤说明 - 对比分析 等任意合适的形式 `; export const OPEN_ENDED_ANSWER_PROMPT_EN = ` # Role: Open-ended Question Answering Assistant ## Profile: - Description: You are a professional assessment assistant who needs to provide in-depth and comprehensive answers to open-ended questions based on your knowledge. ## Task: Please answer the following open-ended question. ## Question: {{question}} ## Answer Requirements: 1. Answer should be comprehensive and insightful, demonstrating deep understanding 2. Provide reasonable arguments, analysis, and reasoning 3. Clear logic and complete structure 4. Can use paragraphs, bullet points, steps, comparisons, or any appropriate format 5. Appropriate length - fully developed but not redundant ## Answer Format: Organize answer structure freely based on question characteristics, can use: - Paragraph-style exposition - Bullet point elaboration - Step-by-step explanation - Comparative analysis or any other appropriate format `; // ============ 提示词获取函数 ============ /** * 获取题目回答提示词(使用 processPrompt 规范化处理) */ export async function buildAnswerPrompt(questionType, question, options = null, language = 'zh-CN', projectId = null) { let promptType, baseKey, defaultPrompts, params; switch (questionType) { case 'true_false': promptType = 'trueFalseAnswer'; baseKey = 'TRUE_FALSE_ANSWER_PROMPT'; defaultPrompts = { zh: TRUE_FALSE_ANSWER_PROMPT, en: TRUE_FALSE_ANSWER_PROMPT_EN }; params = { question }; break; case 'single_choice': promptType = 'singleChoiceAnswer'; baseKey = 'SINGLE_CHOICE_ANSWER_PROMPT'; defaultPrompts = { zh: SINGLE_CHOICE_ANSWER_PROMPT, en: SINGLE_CHOICE_ANSWER_PROMPT_EN }; const singleOptionsText = Array.isArray(options) ? options.map((opt, index) => `${String.fromCharCode(65 + index)}. ${opt}`).join('\n') : options || ''; params = { question, options: singleOptionsText }; break; case 'multiple_choice': promptType = 'multipleChoiceAnswer'; baseKey = 'MULTIPLE_CHOICE_ANSWER_PROMPT'; defaultPrompts = { zh: MULTIPLE_CHOICE_ANSWER_PROMPT, en: MULTIPLE_CHOICE_ANSWER_PROMPT_EN }; const multipleOptionsText = Array.isArray(options) ? options.map((opt, index) => `${String.fromCharCode(65 + index)}. ${opt}`).join('\n') : options || ''; params = { question, options: multipleOptionsText }; break; case 'short_answer': promptType = 'shortAnswer'; baseKey = 'SHORT_ANSWER_PROMPT'; defaultPrompts = { zh: SHORT_ANSWER_PROMPT, en: SHORT_ANSWER_PROMPT_EN }; params = { question }; break; case 'open_ended': promptType = 'openEndedAnswer'; baseKey = 'OPEN_ENDED_ANSWER_PROMPT'; defaultPrompts = { zh: OPEN_ENDED_ANSWER_PROMPT, en: OPEN_ENDED_ANSWER_PROMPT_EN }; params = { question }; break; default: promptType = 'shortAnswer'; baseKey = 'SHORT_ANSWER_PROMPT'; defaultPrompts = { zh: SHORT_ANSWER_PROMPT, en: SHORT_ANSWER_PROMPT_EN }; params = { question }; } return await processPrompt(language, promptType, baseKey, defaultPrompts, params, projectId); } /** * 获取评估提示词(从 judgePrompt.js 导入) * @deprecated 请直接使用 judgePrompt.js 中的 buildJudgePrompt */ export async function buildJudgePrompt( questionType, question, correctAnswer, modelAnswer, language = 'zh-CN', customScoreAnchors = null, projectId = null ) { const { buildJudgePrompt: buildJudgePromptFromModule } = require('./llmJudge'); return await buildJudgePromptFromModule( questionType, question, correctAnswer, modelAnswer, language, customScoreAnchors, projectId ); } export default { buildAnswerPrompt, buildJudgePrompt, TRUE_FALSE_ANSWER_PROMPT, TRUE_FALSE_ANSWER_PROMPT_EN, SINGLE_CHOICE_ANSWER_PROMPT, SINGLE_CHOICE_ANSWER_PROMPT_EN, MULTIPLE_CHOICE_ANSWER_PROMPT, MULTIPLE_CHOICE_ANSWER_PROMPT_EN, SHORT_ANSWER_PROMPT, SHORT_ANSWER_PROMPT_EN, OPEN_ENDED_ANSWER_PROMPT, OPEN_ENDED_ANSWER_PROMPT_EN }; ================================================ FILE: lib/llm/prompts/multiTurnConversation.js ================================================ import { processPrompt } from '../common/prompt-loader'; // 生成助手回复的提示词 export const ASSISTANT_REPLY_PROMPT = ` # Role: 多轮对话助手角色 ## Profile: - Description: 你是一名专业的对话伙伴,扮演指定的助手角色,基于参考资料进行多轮对话交流。 - Goal: 基于参考资料,生成符合角色设定的专业回复,保持对话的连贯性和逻辑性。 ## Skills: 1. 深度理解参考资料,准确提取关键信息 2. 完全融入指定的角色设定,保持角色一致性 3. 根据对话历史,生成逻辑连贯的回复 4. 确保回复内容与参考资料相关 ## 对话场景设定: {{scenario}} ## 角色设定: - {{roleA}}: 提问者,寻求信息和帮助 - {{roleB}}: 回答者(你的角色),提供专业、详细的回答 ## 参考资料: {{chunkContent}} ## 对话历史: {{conversationHistory}} ## 当前状态: 这是第 {{currentRound}} 轮对话(总共 {{totalRounds}} 轮) ## Workflow: 1. 仔细阅读参考资料,理解核心信息 2. 回顾对话历史,理解当前对话的发展脉络 3. 基于{{roleB}}的角色设定,生成专业回复 4. 优先使用参考资料中的信息,如果参考资料无法完全回答问题,可以结合自己的专业知识进行补充 ## Constraints: 1. 优先基于参考资料回答,参考资料是主要信息来源 2. 当参考资料中找不到相应信息时,可以根据自己的专业知识提供有价值的回答 3. 必须保持{{roleB}}角色的一致性和专业性 4. 回复要与对话历史保持逻辑连贯性 5. 回复内容要详细但不冗长,适中的长度 6. 给出的回复内容中不要包含参考资料 XXXX,这样的字符,要保证回复内容自然合理 7. 当使用自己的知识时,要确保信息准确可靠,符合角色专业水准 8.不要在回答中出现:"由于没有直接相关的案例资料"、"参考资料中未提及这样的字眼",直接生成自然的回复 ## Output Format: 严格按照以下JSON格式输出,确保格式正确: \`\`\`json { "content": "{{roleB}}的具体回复内容" } \`\`\` 注意: 1. 必须返回有效的JSON格式 2. 只包含content字段 3. content字段的值就是{{roleB}}的完整回复 4. 不要包含任何额外的标识符或格式标记 `; export const ASSISTANT_REPLY_PROMPT_EN = ` # Role: Multi-turn Conversation Assistant ## Profile: - Description: You are a professional conversation partner, playing the specified assistant role, engaging in multi-turn conversations based on original text content. - Goal: Generate professional replies that match the role setting based on original text content, maintaining conversation coherence and logic. ## Skills: 1. Deeply understand original text content and accurately extract key information 2. Fully embody the specified role setting and maintain role consistency 3. Generate logically coherent replies based on conversation history 4. Ensure reply content is highly relevant to the original text ## Conversation Scenario: {{scenario}} ## Role Settings: - {{roleA}}: Questioner, seeking information and help - {{roleB}}: Responder (your role), providing professional and detailed answers ## Original Text Content: {{chunkContent}} ## Conversation History: {{conversationHistory}} ## Current Status: This is round {{currentRound}} of conversation (total {{totalRounds}} rounds) ## Workflow: 1. Carefully read the original text content and understand the core information 2. Review conversation history and understand the current conversation development 3. Generate professional replies based on the {{roleB}} role setting 4. Prioritize information from original text content, but if the original text cannot fully answer the question, supplement with your own professional knowledge ## Constraints: 1. Prioritize answers based on original text content, which is the primary information source 2. When relevant information cannot be found in the original text, provide valuable answers based on your own professional knowledge 3. Must maintain consistency and professionalism of the {{roleB}} role 4. Replies must maintain logical coherence with conversation history 5. Reply content should be detailed but not verbose, with appropriate length 6. When using your own knowledge, ensure the information is accurate and reliable, meeting the professional standards of the role 7. If it's the last round of conversation, appropriate summarization is allowed ## Output Format: Strictly follow the JSON format below, ensure correct formatting: \`\`\`json { "content": "Specific reply content for {{roleB}}" } \`\`\` Note: 1. Must return valid JSON format 2. Only include the content field 3. The content field value is the complete reply for {{roleB}} 4. Do not include any additional identifiers or format markers `; export const ASSISTANT_REPLY_PROMPT_TR = ` # Rol: Çok Turlu Konuşma Asistanı ## Profil: - Açıklama: Belirtilen asistan rolünü oynayan, orijinal metin içeriğine dayalı olarak çok turlu konuşmalara katılan profesyonel bir konuşma ortağısınız. - Hedef: Orijinal metin içeriğine dayalı olarak rol ayarına uygun profesyonel yanıtlar oluşturun, konuşma tutarlılığını ve mantığını koruyun. ## Yetenekler: 1. Orijinal metin içeriğini derinlemesine anlayın ve anahtar bilgileri doğru şekilde çıkarın 2. Belirtilen rol ayarını tamamen benimseyin ve rol tutarlılığını koruyun 3. Konuşma geçmişine dayalı olarak mantıksal olarak tutarlı yanıtlar oluşturun 4. Yanıt içeriğinin orijinal metinle yüksek oranda ilgili olmasını sağlayın ## Konuşma Senaryosu: {{scenario}} ## Rol Ayarları: - {{roleA}}: Soru soran, bilgi ve yardım arayan - {{roleB}}: Yanıtlayan (rolünüz), profesyonel ve ayrıntılı cevaplar sağlayan ## Orijinal Metin İçeriği: {{chunkContent}} ## Konuşma Geçmişi: {{conversationHistory}} ## Mevcut Durum: Bu konuşmanın {{currentRound}}. turu (toplam {{totalRounds}} tur) ## İş Akışı: 1. Orijinal metin içeriğini dikkatlice okuyun ve temel bilgileri anlayın 2. Konuşma geçmişini gözden geçirin ve mevcut konuşma gelişimini anlayın 3. {{roleB}} rol ayarına dayalı olarak profesyonel yanıtlar oluşturun 4. Orijinal metin içeriğinden gelen bilgilere öncelik verin, ancak orijinal metin soruyu tam olarak cevaplayamıyorsa kendi profesyonel bilginizle tamamlayın ## Kısıtlamalar: 1. Birincil bilgi kaynağı olan orijinal metin içeriğine dayalı yanıtlara öncelik verin 2. Orijinal metinde ilgili bilgi bulunamadığında, kendi profesyonel bilginize dayalı değerli yanıtlar sağlayın 3. {{roleB}} rolünün tutarlılığını ve profesyonelliğini korumalısınız 4. Yanıtlar konuşma geçmişiyle mantıksal tutarlılığı korumalıdır 5. Yanıt içeriği ayrıntılı ancak aşırı uzun olmamalı, uygun uzunlukta olmalıdır 6. Kendi bilginizi kullanırken, bilgilerin doğru ve güvenilir olduğundan, rolün profesyonel standartlarını karşıladığından emin olun 7. Son tur konuşmaysa, uygun özet yapılmasına izin verilir ## Çıktı Formatı: Aşağıdaki JSON formatını sıkı şekilde izleyin, doğru biçimlendirmeyi sağlayın: \`\`\`json { "content": "{{roleB}} için özel yanıt içeriği" } \`\`\` Not: 1. Geçerli JSON formatında döndürülmelidir 2. Yalnızca content alanını içermelidir 3. content alanı değeri {{roleB}} için tam yanıttır 4. Herhangi bir ek tanımlayıcı veya format işareti içermemelidir `; // 生成下一轮用户问题的提示词 export const NEXT_QUESTION_PROMPT = ` # Role: 多轮对话用户角色 ## Profile: - Description: 你是一名专业的对话参与者,扮演指定的用户角色,基于已有对话历史生成下一轮的自然问题。 - Goal: 基于对话历史和参考资料,生成符合角色设定的后续问题,推进对话深入发展。 ## Skills: 1. 分析对话历史,识别对话发展脉络和未涵盖的话题 2. 完全融入指定的用户角色设定 3. 生成自然流畅、逻辑连贯的后续问题 4. 确保问题与参考资料相关 ## 对话场景设定: {{scenario}} ## 角色设定: - {{roleA}}: 提问者(你的角色),基于已有对话继续深入询问 - {{roleB}}: 回答者,提供专业回答 ## 参考资料: {{chunkContent}} ## 对话历史: {{conversationHistory}} ## 当前状态: 即将开始第 {{nextRound}} 轮对话(总共 {{totalRounds}} 轮) ## Workflow: 1. 回顾完整的对话历史,理解已讨论的内容 2. 基于参考资料,识别尚未深入探讨的方面 3. 从{{roleA}}的角色视角,提出自然的后续问题 4. 确保问题推进对话向更深层次发展 ## Constraints: 1. 问题必须与参考资料相关,不得脱离主题 2. 必须保持{{roleA}}角色的语言风格和询问方式 3. 问题要基于对话历史,体现自然的对话发展 4. 避免重复之前已经问过的问题 5. 问题类型可以是:澄清细节、扩展讨论、实际应用、相关问题等 6. 问题要简洁明确,避免过于复杂或宽泛 ## Output Format: 严格按照以下JSON格式输出,确保格式正确: \`\`\`json { "question": "{{roleA}}的具体问题内容" } \`\`\` 注意: 1. 必须返回有效的JSON格式 2. 只包含question字段 3. question字段的值就是{{roleA}}的完整问题 4. 不要包含任何额外的标识符或格式标记 `; export const NEXT_QUESTION_PROMPT_EN = ` # Role: Multi-turn Conversation User ## Profile: - Description: You are a professional conversation participant, playing the specified user role, generating natural follow-up questions based on existing conversation history. - Goal: Generate follow-up questions that match the role setting based on conversation history and original text content, advancing the conversation development. ## Skills: 1. Analyze conversation history and identify conversation development and uncovered topics 2. Fully embody the specified user role setting 3. Generate natural, fluent, and logically coherent follow-up questions 4. Ensure questions are related to original text content ## Conversation Scenario: {{scenario}} ## Role Settings: - {{roleA}}: Questioner (your role), continuing to ask in-depth questions based on existing conversation - {{roleB}}: Responder, providing professional answers ## Original Text Content: {{chunkContent}} ## Conversation History: {{conversationHistory}} ## Current Status: About to start round {{nextRound}} of conversation (total {{totalRounds}} rounds) ## Workflow: 1. Review the complete conversation history and understand the discussed content 2. Based on original text content, identify aspects that haven't been deeply explored 3. From {{roleA}}'s role perspective, ask natural follow-up questions 4. Ensure questions advance the conversation to deeper levels ## Constraints: 1. Questions must be related to original text content, not deviating from the topic 2. Must maintain {{roleA}} role's language style and questioning approach 3. Questions should be based on conversation history, reflecting natural conversation development 4. Avoid repeating previously asked questions 5. Question types can be: clarifying details, expanding discussion, practical application, related questions, etc. 6. Questions should be concise and clear, avoiding excessive complexity or broadness ## Output Format: Strictly follow the JSON format below, ensure correct formatting: \`\`\`json { "question": "Specific question content for {{roleA}}" } \`\`\` Note: 1. Must return valid JSON format 2. Only include the question field 3. The question field value is the complete question for {{roleA}} 4. Do not include any additional identifiers or format markers `; export const NEXT_QUESTION_PROMPT_TR = ` # Rol: Çok Turlu Konuşma Kullanıcısı ## Profil: - Açıklama: Belirtilen kullanıcı rolünü oynayan, mevcut konuşma geçmişine dayalı olarak doğal takip soruları oluşturan profesyonel bir konuşma katılımcısısınız. - Hedef: Konuşma geçmişi ve orijinal metin içeriğine dayalı olarak rol ayarına uygun takip soruları oluşturun, konuşma gelişimini ilerletin. ## Yetenekler: 1. Konuşma geçmişini analiz edin ve konuşma gelişimini ve kapsanmayan konuları tanımlayın 2. Belirtilen kullanıcı rolü ayarını tamamen benimseyin 3. Doğal, akıcı ve mantıksal olarak tutarlı takip soruları oluşturun 4. Soruların orijinal metin içeriğiyle ilgili olmasını sağlayın ## Konuşma Senaryosu: {{scenario}} ## Rol Ayarları: - {{roleA}}: Soru soran (rolünüz), mevcut konuşmaya dayalı olarak derinlemesine sorular sormaya devam eden - {{roleB}}: Yanıtlayan, profesyonel cevaplar sağlayan ## Orijinal Metin İçeriği: {{chunkContent}} ## Konuşma Geçmişi: {{conversationHistory}} ## Mevcut Durum: Konuşmanın {{nextRound}}. turunu başlatmak üzere (toplam {{totalRounds}} tur) ## İş Akışı: 1. Tam konuşma geçmişini gözden geçirin ve tartışılan içeriği anlayın 2. Orijinal metin içeriğine dayalı olarak, henüz derinlemesine keşfedilmemiş yönleri tanımlayın 3. {{roleA}}'nın rol perspektifinden doğal takip soruları sorun 4. Soruların konuşmayı daha derin seviyelere taşımasını sağlayın ## Kısıtlamalar: 1. Sorular orijinal metin içeriğiyle ilgili olmalı, konudan sapmamalıdır 2. {{roleA}} rolünün dil stilini ve sorgulama yaklaşımını korumalısınız 3. Sorular konuşma geçmişine dayalı olmalı, doğal konuşma gelişimini yansıtmalıdır 4. Daha önce sorulan soruları tekrarlamaktan kaçının 5. Soru türleri şunlar olabilir: detayları netleştirme, tartışmayı genişletme, pratik uygulama, ilgili sorular vb. 6. Sorular kısa ve net olmalı, aşırı karmaşıklık veya genişlikten kaçınmalıdır ## Çıktı Formatı: Aşağıdaki JSON formatını sıkı şekilde izleyin, doğru biçimlendirmeyi sağlayın: \`\`\`json { "question": "{{roleA}} için özel soru içeriği" } \`\`\` Not: 1. Geçerli JSON formatında döndürülmelidir 2. Yalnızca question alanını içermelidir 3. question alanı değeri {{roleA}} için tam sorudur 4. Herhangi bir ek tanımlayıcı veya format işareti içermemelidir `; /** * 生成助手回复的提示词 * @param {string} language - 语言,'en' 或 '中文' * @param {Object} params - 参数对象 * @param {string} params.scenario - 对话场景 * @param {string} params.roleA - 角色A设定 * @param {string} params.roleB - 角色B设定 * @param {string} params.chunkContent - 参考资料 * @param {string} params.conversationHistory - 对话历史 * @param {number} params.currentRound - 当前轮数 * @param {number} params.totalRounds - 总轮数 * @param {string} projectId - 项目ID * @returns {string} - 完整的提示词 */ export async function getAssistantReplyPrompt( language, { scenario, roleA, roleB, chunkContent, conversationHistory, currentRound, totalRounds }, projectId = null ) { let chunck = ''; if ( chunkContent.includes('This text block is used to store questions generated through data distillation') || !chunkContent ) { const messages = { en: 'No reference materials available. Please generate a reply based on your own knowledge.', tr: 'Kullanılabilir referans materyal yok. Lütfen kendi bilginize dayalı bir yanıt oluşturun.', zh: '没有可用的参考资料,请根据自己的知识直接生成回复' }; chunck = messages[language] || messages.zh; } const result = await processPrompt( language, 'multiTurnConversation', 'ASSISTANT_REPLY_PROMPT', { zh: ASSISTANT_REPLY_PROMPT, en: ASSISTANT_REPLY_PROMPT_EN, tr: ASSISTANT_REPLY_PROMPT_TR }, { scenario, roleA, roleB, chunkContent: chunck, conversationHistory, currentRound, totalRounds }, projectId ); return result; } /** * 生成下一轮用户问题的提示词 * @param {string} language - 语言,'en' 或 '中文' * @param {Object} params - 参数对象 * @param {string} params.scenario - 对话场景 * @param {string} params.roleA - 角色A设定 * @param {string} params.roleB - 角色B设定 * @param {string} params.chunkContent - 参考资料 * @param {string} params.conversationHistory - 对话历史 * @param {number} params.nextRound - 下一轮数 * @param {number} params.totalRounds - 总轮数 * @param {string} projectId - 项目ID * @returns {string} - 完整的提示词 */ export async function getNextQuestionPrompt( language, { scenario, roleA, roleB, chunkContent, conversationHistory, nextRound, totalRounds }, projectId = null ) { let chunck = ''; if ( chunkContent.includes('This text block is used to store questions generated through data distillation') || !chunkContent ) { const messages = { en: 'No reference materials available. Please generate a reply based on your own knowledge.', tr: 'Kullanılabilir referans materyal yok. Lütfen kendi bilginize dayalı bir yanıt oluşturun.', zh: '没有可用的参考资料,请根据自己的知识直接生成回复' }; chunck = messages[language] || messages.zh; } const result = await processPrompt( language, 'multiTurnConversation', 'NEXT_QUESTION_PROMPT', { zh: NEXT_QUESTION_PROMPT, en: NEXT_QUESTION_PROMPT_EN, tr: NEXT_QUESTION_PROMPT_TR }, { scenario, roleA, roleB, chunkContent: chunck, conversationHistory, nextRound, totalRounds }, projectId ); return result; } export default { getAssistantReplyPrompt, getNextQuestionPrompt }; ================================================ FILE: lib/llm/prompts/newAnswer.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const NEW_ANSWER_PROMPT = ` # Role: 微调数据集答案优化专家 ## Profile: - Description: 你是一名微调数据集答案优化专家,擅长根据用户的改进建议,对问题的回答结果和思考过程(思维链)进行优化 ## Skills: 1. 基于给定的优化建议 + 问题,对输入的答案进行优化,并进行适当的丰富和补充 3. 能够根据优化建议,对答案的思考过程(思维链)进行优化,去除思考过程中参考资料相关的描述(不要在推理逻辑中体现有参考资料,改为正常的推理思路) ## 可以参考的背景信息 {{chunkContent}} ## 原始问题 {{question}} ## 待优化的答案 {{answer}} ## 答案优化建议 {{advice}},同时对答案进行适当的丰富和补充,确保答案准确、充分、清晰。 ## 待优化的思考过程 {{cot}} ## 思考过程优化建议 - 通用优化建议:{{advice}} - 去除思考过程中参考资料相关的描述(如:"根据..."、"引用..."、"参考..."等),不要在推理逻辑中体现有参考资料,改为正常的推理思路。 ## Constrains: 1. 结果必须按照 JSON 格式输出(如果给到的待优化思考过程为空,则输出的 COT 字段也为空): \`\`\`json { "answer": "优化后的答案", "cot": "优化后的思考过程" } \`\`\` `; export const NEW_ANSWER_PROMPT_EN = ` # Role: Fine-tuning Dataset Answer Optimization Expert ## Profile: - Description: You are an expert in optimizing answers for fine-tuning datasets. You are skilled at optimizing the answer results and thinking processes (Chain of Thought, COT) of questions based on users' improvement suggestions. ## Skills: 1. Optimize the input answer based on the given optimization suggestions and the question, and make appropriate enrichments and supplements. 3. Optimize the answer's thinking process (COT) according to the optimization suggestions. Remove descriptions related to reference materials from the thinking process (do not mention reference materials in the reasoning logic; change it to a normal reasoning approach). ## Original Text Chunk Content {{chunkContent}} ## Original Question {{question}} ## Answer to be Optimized {{answer}} ## Answer Optimization Suggestions {{advice}}. Meanwhile, make appropriate enrichments and supplements to the answer to ensure it is accurate, comprehensive, and clear. ## Thinking Process to be Optimized {{cot}} ## Thinking Process Optimization Suggestions - General Optimization Suggestions: {{advice}} - Remove descriptions related to reference materials from the thinking process (e.g., "According to...", "Quoting...", "Referencing...", etc.). Do not mention reference materials in the reasoning logic; change it to a normal reasoning approach. ## Constraints: 1. The result must be output in JSON format (if the thinking process to be optimized is empty, the COT field in the output should also be empty): \`\`\`json { "answer": "Optimized answer", "cot": "Optimized thinking process" } \`\`\` `; export const NEW_ANSWER_PROMPT_TR = ` # Rol: İnce Ayar Veri Seti Cevap Optimizasyon Uzmanı ## Profil: - Açıklama: İnce ayar veri setleri için cevapları optimize etme konusunda uzmansınız. Kullanıcıların iyileştirme önerilerine dayalı olarak soruların cevap sonuçlarını ve düşünme süreçlerini (Düşünme Zinciri, COT) optimize etmede yeteneklisiniz. ## Yetenekler: 1. Verilen optimizasyon önerilerine ve soruya dayalı olarak giriş cevabını optimize edin ve uygun zenginleştirmeler ve eklemeler yapın. 3. Optimizasyon önerilerine göre cevabın düşünme sürecini (COT) optimize edin. Düşünme sürecinden referans materyallerle ilgili açıklamaları kaldırın (akıl yürütme mantığında referans materyallerden bahsetmeyin; bunu normal bir akıl yürütme yaklaşımına dönüştürün). ## Orijinal Metin Parçası İçeriği {{chunkContent}} ## Orijinal Soru {{question}} ## Optimize Edilecek Cevap {{answer}} ## Cevap Optimizasyon Önerileri {{advice}}. Aynı zamanda, cevaba uygun zenginleştirmeler ve eklemeler yapın, doğru, kapsamlı ve net olmasını sağlayın. ## Optimize Edilecek Düşünme Süreci {{cot}} ## Düşünme Süreci Optimizasyon Önerileri - Genel Optimizasyon Önerileri: {{advice}} - Düşünme sürecinden referans materyallerle ilgili açıklamaları kaldırın (örn., "...göre", "...alıntılayarak", "...referans göstererek", vb.). Akıl yürütme mantığında referans materyallerden bahsetmeyin; bunu normal bir akıl yürütme yaklaşımına dönüştürün. ## Kısıtlamalar: 1. Sonuç JSON formatında çıktı verilmelidir (optimize edilecek düşünme süreci boşsa, çıktıdaki COT alanı da boş olmalıdır): \`\`\`json { "answer": "Optimize edilmiş cevap", "cot": "Optimize edilmiş düşünme süreci" } \`\`\` `; export async function getNewAnswerPrompt(language, { question, answer, cot, advice, chunkContent }, projectId = null) { const result = await processPrompt( language, 'newAnswer', 'NEW_ANSWER_PROMPT', { zh: NEW_ANSWER_PROMPT, en: NEW_ANSWER_PROMPT_EN, tr: NEW_ANSWER_PROMPT_TR }, { chunkContent: chunkContent || '', question, answer, cot, advice }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/optimizeCot.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const OPTIMIZE_COT_PROMPT = ` # Role: 思维链优化专家 ## Profile: - Description: 你是一位擅长优化思维链的专家,能够对给定的思维链进行处理,去除其中的参考引用相关话术,使其呈现为一个正常的推理过程。 ## Skills: 1. 准确识别并去除思维链中的参考引用话术。 2. 确保优化后的思维链逻辑连贯、推理合理。 3. 维持思维链与原始问题和答案的相关性。 ## Workflow: 1. 仔细研读原始问题、答案和优化前的思维链。 2. 识别思维链中所有参考引用相关的表述,如"参考 XX 资料""文档中提及 XX""参考内容中提及 XXX"等。 3. 去除这些引用话术,同时调整语句,保证思维链的逻辑连贯性。 4. 检查优化后的思维链是否仍然能够合理地推导出答案,并且与原始问题紧密相关。 ## 原始问题 {{originalQuestion}} ## 答案 {{answer}} ## 优化前的思维链 {{originalCot}} ## Constrains: 1. 优化后的思维链必须去除所有参考引用相关话术。 2. 思维链的逻辑推理过程必须完整且合理。 3. 优化后的思维链必须与原始问题和答案保持紧密关联。 4. 给出的答案不要包含 "优化后的思维链" 这样的话术,直接给出优化后的思维链结果。 5. 思维链应按照正常的推理思路返回,如:先分析理解问题的本质,按照 "首先、然后、接着、另外、最后" 等步骤逐步思考,展示一个完善的推理过程。 `; export const OPTIMIZE_COT_PROMPT_EN = ` # Role: Chain of Thought Optimization Expert ## Profile: - Description: You are an expert in optimizing the chain of thought. You can process the given chain of thought, remove the reference and citation-related phrases in it, and present it as a normal reasoning process. ## Skills: 1. Accurately identify and remove the reference and citation-related phrases in the chain of thought. 2. Ensure that the optimized chain of thought is logically coherent and reasonably reasoned. 3. Maintain the relevance of the chain of thought to the original question and answer. ## Workflow: 1. Carefully study the original question, the answer, and the pre-optimized chain of thought. 2. Identify all the reference and citation-related expressions in the chain of thought, such as "Refer to XX material", "The document mentions XX", "The reference content mentions XXX", etc. 3. Remove these citation phrases and adjust the sentences at the same time to ensure the logical coherence of the chain of thought. 4. Check whether the optimized chain of thought can still reasonably lead to the answer and is closely related to the original question. ## Original Question {{originalQuestion}} ## Answer {{answer}} ## Pre-optimized Chain of Thought {{originalCot}} ## Constrains: 1. The optimized chain of thought must remove all reference and citation-related phrases. 2. The logical reasoning process of the chain of thought must be complete and reasonable. 3. The optimized chain of thought must maintain a close association with the original question and answer. 4. The provided answer should not contain phrases like "the optimized chain of thought". Directly provide the result of the optimized chain of thought. 5. The chain of thought should be returned according to a normal reasoning approach. For example, first analyze and understand the essence of the problem, and gradually think through steps such as "First, Then, Next, Additionally, Finally" to demonstrate a complete reasoning process. `; export const OPTIMIZE_COT_PROMPT_TR = ` # Rol: Düşünme Zinciri Optimizasyon Uzmanı ## Profil: - Açıklama: Düşünme zincirini optimize etme konusunda uzmansınız. Verilen düşünme zincirini işleyebilir, içindeki referans ve alıntıyla ilgili ifadeleri kaldırabilir ve normal bir akıl yürütme süreci olarak sunabilirsiniz. ## Yetenekler: 1. Düşünme zincirindeki referans ve alıntıyla ilgili ifadeleri doğru şekilde tanımlayın ve kaldırın. 2. Optimize edilmiş düşünme zincirinin mantıksal olarak tutarlı ve makul bir şekilde gerekçelendirildiğinden emin olun. 3. Düşünme zincirinin orijinal soru ve cevapla ilgisini koruyun. ## İş Akışı: 1. Orijinal soruyu, cevabı ve optimize edilmemiş düşünme zincirini dikkatlice inceleyin. 2. Düşünme zincirindeki tüm referans ve alıntıyla ilgili ifadeleri tanımlayın, örneğin "XX materyaline başvur", "Belgede XX'den bahsediyor", "Referans içerikte XXX'den bahsediyor", vb. 3. Bu alıntı ifadelerini kaldırın ve aynı zamanda cümleleri ayarlayın, düşünme zincirinin mantıksal tutarlılığını sağlayın. 4. Optimize edilmiş düşünme zincirinin hala makul bir şekilde cevaba yol açıp açmadığını ve orijinal soruyla yakından ilgili olup olmadığını kontrol edin. ## Orijinal Soru {{originalQuestion}} ## Cevap {{answer}} ## Optimize Edilmemiş Düşünme Zinciri {{originalCot}} ## Kısıtlamalar: 1. Optimize edilmiş düşünme zinciri tüm referans ve alıntıyla ilgili ifadeleri kaldırmalıdır. 2. Düşünme zincirinin mantıksal akıl yürütme süreci eksiksiz ve makul olmalıdır. 3. Optimize edilmiş düşünme zinciri orijinal soru ve cevapla yakın ilişkisini korumalıdır. 4. Sağlanan cevap "optimize edilmiş düşünme zinciri" gibi ifadeler içermemelidir. Doğrudan optimize edilmiş düşünme zincirinin sonucunu sağlayın. 5. Düşünme zinciri normal bir akıl yürütme yaklaşımına göre döndürülmelidir. Örneğin, önce problemin özünü analiz edin ve anlayın, ve tam bir akıl yürütme sürecini göstermek için "İlk olarak, Sonra, Ardından, Ek olarak, Son olarak" gibi adımlarla kademeli olarak düşünün. `; /** * 获取思维链优化提示词 * @param {string} language - 语言标识 * @param {Object} params - 参数对象 * @param {string} params.originalQuestion - 原始问题 * @param {string} params.answer - 答案 * @param {string} params.originalCot - 原始思维链 * @param {string} projectId - 项目ID,用于获取自定义提示词 * @returns {Promise} - 完整的提示词 */ export async function getOptimizeCotPrompt(language, { originalQuestion, answer, originalCot }, projectId = null) { const result = await processPrompt( language, 'optimizeCot', 'OPTIMIZE_COT_PROMPT', { zh: OPTIMIZE_COT_PROMPT, en: OPTIMIZE_COT_PROMPT_EN, tr: OPTIMIZE_COT_PROMPT_TR }, { originalQuestion, answer, originalCot }, projectId ); return result; } ================================================ FILE: lib/llm/prompts/question.js ================================================ import { processPrompt } from '../common/prompt-loader'; export const QUESTION_PROMPT = ` # Role: 文本问题生成专家 ## Profile: - Description: 你是一名专业的文本分析与问题设计专家,能够从复杂文本中提炼关键信息并产出可用于模型微调的高质量问题集合。 - Input Length: {{textLength}} 字 - Output Goal: 生成不少于 {{number}} 个高质量问题,用于构建问答训练数据集。 ## Skills: 1. 能够全面理解原文内容,识别核心概念、事实与逻辑结构。 2. 擅长设计具有明确答案指向性的问题,覆盖文本多个侧面。 3. 善于控制问题难度与类型,保证多样性与代表性。 4. 严格遵守格式规范,确保输出可直接用于程序化处理。 ## Workflow: 1. **文本解析**:通读全文,分段识别关键实体、事件、数值与结论。 2. **问题设计**:基于信息密度和重要性选择最佳提问切入点{{gaPromptNote}}。 3. **质量检查**:逐条校验问题,确保: - 问题答案可在原文中直接找到依据。 - 问题之间主题不重复、角度不雷同。 - 语言表述准确、无歧义且符合常规问句形式。 {{gaPromptCheck}} ## Constraints: 1. 所有问题必须严格依据原文内容,不得添加外部信息或假设情境。 2. 问题需覆盖文本的不同主题、层级或视角,避免集中于单一片段。 3. 禁止输出与材料元信息相关的问题(如作者、章节、目录等)。 4. 问题不得包含“报告/文章/文献/表格中提到”等表述,需自然流畅。 5. 输出不少于 {{number}} 个问题,且保持格式一致。 ## Output Format: - 使用合法的 JSON 数组,仅包含字符串元素。 - 字段必须使用英文双引号。 - 严格遵循以下结构: \`\`\` ["问题1", "问题2", "..."] \`\`\` ## Output Example: \`\`\` ["人工智能伦理框架应包含哪些核心要素?", "民法典对个人数据保护有哪些新规定?"] \`\`\` ## Text to Analyze: {{text}} ## GA Instruction (Optional): {{gaPrompt}} `; export const QUESTION_PROMPT_EN = ` # Role: Text Question Generation Expert ## Profile: - Description: You are an expert in text analysis and question design, capable of extracting key information from complex passages and producing high-quality questions for fine-tuning datasets. - Input Length: {{textLength}} characters - Output Goal: Generate at least {{number}} high-quality questions suitable for training data. ## Skills: 1. Comprehend the source text thoroughly and identify core concepts, facts, and logical structures. 2. Design questions with clear answer orientation that cover multiple aspects of the text. 3. Balance difficulty and variety to ensure representative coverage of the content. 4. Enforce strict formatting so the output can be consumed programmatically. ## Workflow: 1. **Text Parsing**: Read the entire passage, segment it, and capture key entities, events, metrics, and conclusions. 2. **Question Design**: Select the most informative focal points to craft questions{{gaPromptNote}}. 3. **Quality Check**: Validate each question to ensure: - The answer can be located directly in the original text. - Questions do not duplicate topics or angles. - Wording is precise, unambiguous, and uses natural interrogative phrasing. {{gaPromptCheck}} ## Constraints: 1. Every question must be grounded strictly in the provided text; no external information or hypothetical scenarios. 2. Cover diverse themes, layers, or perspectives from the passage; avoid clustering around one segment. 3. Do not include questions about meta information (author, chapters, table of contents, etc.). 4. Avoid phrases such as "in the report/article/literature/table"; questions must read naturally. 5. Produce at least {{number}} questions with consistent formatting. ## Output Format: - Return a valid JSON array containing only strings. - Use double quotes for all strings. - Follow this exact structure: \`\`\` ["Question 1", "Question 2", "..."] \`\`\` ## Output Example: \`\`\` ["What core elements should an AI ethics framework include?", "What new regulations does the Civil Code have for personal data protection?"] \`\`\` ## Text to Analyze: {{text}} ## GA Instruction (Optional): {{gaPrompt}} `; export const QUESTION_PROMPT_TR = ` # Rol: Metin Soru Üretim Uzmanı ## Profil: - Açıklama: Karmaşık metinlerden temel bilgileri çıkarabilen ve ince ayar veri setleri için yüksek kaliteli sorular üretebilen bir metin analizi ve soru tasarımı uzmanısınız. - Girdi Uzunluğu: {{textLength}} karakter - Çıktı Hedefi: Eğitim verisi için uygun en az {{number}} yüksek kaliteli soru üretin. ## Yetenekler: 1. Kaynak metni tamamen anlayın ve temel kavramları, gerçekleri ve mantıksal yapıları tanımlayın. 2. Metnin birden fazla yönünü kapsayan net cevap yönlendirmeli sorular tasarlayın. 3. İçeriğin temsili kapsamını sağlamak için zorluk ve çeşitlilik dengesini kurun. 4. Çıktının programatik olarak tüketilebilmesi için katı biçimlendirme uygulayın. ## İş Akışı: 1. **Metin Ayrıştırma**: Tüm pasajı okuyun, bölümlere ayırın ve temel varlıkları, olayları, metrikleri ve sonuçları yakalayın. 2. **Soru Tasarımı**: Soru oluşturmak için en bilgilendirici odak noktalarını seçin{{gaPromptNote}}. 3. **Kalite Kontrolü**: Her soruyu doğrulayarak şunları sağlayın: - Cevap doğrudan orijinal metinde bulunabilir. - Sorular konuları veya açıları tekrar etmez. - İfade kesin, belirsiz değil ve doğal soru tümcecikleri kullanır. {{gaPromptCheck}} ## Kısıtlamalar: 1. Her soru yalnızca sağlanan metne dayanmalıdır; harici bilgi veya varsayımsal senaryolar olmamalıdır. 2. Pasajdan farklı temaları, katmanları veya bakış açılarını kapsayın; tek bir segment etrafında kümelenmekten kaçının. 3. Meta bilgilerle ilgili sorular eklemeyin (yazar, bölümler, içindekiler tablosu vb.). 4. "Raporda/makalede/literatürde/tabloda" gibi ifadelerden kaçının; sorular doğal okunmalıdır. 5. Tutarlı biçimlendirmeyle en az {{number}} soru üretin. ## Çıktı Formatı: - Yalnızca string içeren geçerli bir JSON dizisi döndürün. - Tüm stringler için çift tırnak kullanın. - Bu yapıyı tam olarak takip edin: \`\`\` ["Soru 1", "Soru 2", "..."] \`\`\` ## Çıktı Örneği: \`\`\` ["Bir yapay zeka etik çerçevesi hangi temel unsurları içermelidir?", "Medeni Kanun kişisel veri koruma için hangi yeni düzenlemelere sahiptir?"] \`\`\` ## Analiz Edilecek Metin: {{text}} ## GA Talimatı (Opsiyonel): {{gaPrompt}} `; export const GA_QUESTION_PROMPT = ` **目标体裁**: {{genre}} **目标受众**: {{audience}} 请确保: 1. 问题应完全符合「{{genre}}」所定义的风格、焦点和深度等等属性。 2. 问题应考虑到「{{audience}}」的知识水平、认知特点和潜在兴趣点。 3. 从该受众群体的视角和需求出发提出问题 4. 保持问题的针对性和实用性,确保问题-答案的风格一致性 5. 问题应具有一定的清晰度和具体性,避免过于宽泛或模糊。 `; export const GA_QUESTION_PROMPT_EN = ` ## Special Requirements - Genre & Audience Perspective Questioning: Adjust your questioning approach and question style based on the following genre and audience combination: **Target Genre**: {{genre}} **Target Audience**: {{audience}} Please ensure: 1. The question should fully conform to the style, focus, depth, and other attributes defined by "{{genre}}". 2. The question should consider the knowledge level, cognitive characteristics, and potential points of interest of "{{audience}}". 3. Propose questions from the perspective and needs of this audience group. 4. Maintain the specificity and practicality of the questions, ensuring consistency in the style of questions and answers. 5. The question should have a certain degree of clarity and specificity, avoiding being too broad or vague. `; export const GA_QUESTION_PROMPT_TR = ` ## Özel Gereksinimler - Tür & Hedef Kitle Perspektifi Sorgulama: Aşağıdaki tür ve hedef kitle kombinasyonuna göre sorgulama yaklaşımınızı ve soru stilinizi ayarlayın: **Hedef Tür**: {{genre}} **Hedef Kitle**: {{audience}} Lütfen şunları sağlayın: 1. Soru, "{{genre}}" tarafından tanımlanan stil, odak, derinlik ve diğer özelliklere tam olarak uygun olmalıdır. 2. Soru, "{{audience}}" hedef kitlesinin bilgi seviyesini, bilişsel özelliklerini ve potansiyel ilgi noktalarını dikkate almalıdır. 3. Bu hedef kitle grubunun bakış açısından ve ihtiyaçlarından yola çıkarak sorular sorun. 4. Soruların özgüllüğünü ve pratikliğini koruyun, soru-cevap stilinde tutarlılık sağlayın. 5. Soru belirli bir netlik ve özgüllüğe sahip olmalı, çok geniş veya belirsiz olmaktan kaçınmalıdır. `; /** * 构建 GA 提示词 * @param {string} language - 语言,'en' 或 '中文' 或 'tr' * @param {Object} activeGaPair - 当前激活的 GA 组合 * @returns {String} 构建的 GA 提示词 */ export function getGAPrompt(language, { activeGaPair }) { if (!activeGaPair || !activeGaPair.active) { return ''; } let prompt; if (language === 'en') { prompt = GA_QUESTION_PROMPT_EN; } else if (language === 'tr') { prompt = GA_QUESTION_PROMPT_TR; } else { prompt = GA_QUESTION_PROMPT; } return prompt.replaceAll('{{genre}}', activeGaPair.genre).replaceAll('{{audience}}', activeGaPair.audience); } /** * 生成问题提示词生成提示模板。 * @param {string} language - 语言,'en' 或 '中文' 或 'tr' * @param {Object} params - 参数对象 * @param {string} params.text - 待处理的文本 * @param {number} params.number - 问题数量 * @param {Object} params.activeGaPair - 当前激活的 GA对 * @returns {string} - 完整的提示词 */ export async function getQuestionPrompt( language, { text, number = Math.floor(text.length / 240), activeGaPair = null }, projectId = null ) { // 构建GA pairs相关的提示词 const gaPromptText = getGAPrompt(language, { activeGaPair }); let gaPromptNote, gaPromptCheck; if (gaPromptText) { if (language === 'en') { gaPromptNote = ', and incorporate the specified genre-audience perspective'; gaPromptCheck = '- Question style matches the specified genre and audience'; } else if (language === 'tr') { gaPromptNote = ', ve belirtilen tür-hedef kitle perspektifini dahil edin'; gaPromptCheck = '- Soru stili belirtilen tür ve hedef kitle ile eşleşir'; } else { gaPromptNote = ',并结合指定的体裁受众视角'; gaPromptCheck = '- 问题风格与指定的体裁受众匹配'; } } else { gaPromptNote = ''; gaPromptCheck = ''; } const result = await processPrompt( language, 'question', 'QUESTION_PROMPT', { zh: QUESTION_PROMPT, en: QUESTION_PROMPT_EN, tr: QUESTION_PROMPT_TR }, { textLength: text.length, number, gaPrompt: gaPromptText, gaPromptNote, gaPromptCheck, text }, projectId ); return result; } export default getQuestionPrompt; ================================================ FILE: lib/llm/usageLogger.js ================================================ /** * LLM 调用统计日志工具 * 异步记录 LLM 调用的 Token 消耗、响应时间等指标 */ import { db } from '@/lib/db/index'; /** * 获取当前日期字符串 (YYYY-MM-DD) */ function getDateString() { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * 异步记录 LLM 调用日志(不阻塞主流程) * @param {Object} params - 日志参数 * @param {string} params.projectId - 项目 ID * @param {string} params.provider - 提供商名称 * @param {string} params.model - 模型名称 * @param {number} params.inputTokens - 输入 Token 数 * @param {number} params.outputTokens - 输出 Token 数 * @param {number} params.latency - 响应耗时(毫秒) * @param {string} params.status - 状态 ('SUCCESS' | 'FAILED') * @param {string} [params.errorMessage] - 错误信息(失败时填写) */ export async function logLlmUsage({ projectId, provider, model, inputTokens = 0, outputTokens = 0, latency = 0, status = 'SUCCESS', errorMessage = null }) { // 异步执行,不阻塞主流程 setImmediate(async () => { try { await db.llmUsageLogs.create({ data: { projectId: projectId || 'unknown', provider: provider || 'unknown', model: model || 'unknown', inputTokens: inputTokens || 0, outputTokens: outputTokens || 0, totalTokens: (inputTokens || 0) + (outputTokens || 0), latency: latency || 0, status: status || 'SUCCESS', errorMessage: errorMessage || null, dateString: getDateString() } }); } catch (error) { // 静默失败,不影响主流程 console.error('[LLM Usage Logger] Failed to log usage:', error.message); } }); } /** * 创建一个计时器,用于测量 LLM 调用耗时 * @returns {Object} 计时器对象 */ export function createLatencyTimer() { const startTime = Date.now(); return { /** * 获取从开始到现在的耗时(毫秒) */ getLatency() { return Date.now() - startTime; } }; } /** * 从 LLM 响应中提取 Token 使用信息 * @param {Object} response - LLM 响应对象 * @returns {Object} Token 使用信息 */ export function extractTokenUsage(response) { let inputTokens = 0; let outputTokens = 0; try { // AI SDK 格式 if (response?.usage) { inputTokens = response.usage.promptTokens || response.usage.prompt_tokens || 0; outputTokens = response.usage.completionTokens || response.usage.completion_tokens || 0; } // OpenAI 原生格式 else if (response?.response?.body?.usage) { const usage = response.response.body.usage; inputTokens = usage.prompt_tokens || 0; outputTokens = usage.completion_tokens || 0; } // 其他格式尝试 else if (response?.prompt_tokens !== undefined) { inputTokens = response.prompt_tokens || 0; outputTokens = response.completion_tokens || 0; } } catch (error) { console.error('[LLM Usage Logger] Failed to extract token usage:', error.message); } return { inputTokens, outputTokens }; } /** * 包装 LLM 调用,自动记录统计信息 * @param {Function} llmCall - LLM 调用函数 * @param {Object} context - 上下文信息 * @param {string} context.projectId - 项目 ID * @param {string} context.provider - 提供商名称 * @param {string} context.model - 模型名称 * @returns {Promise} LLM 调用结果 */ export async function withUsageLogging(llmCall, context) { const timer = createLatencyTimer(); let response = null; let status = 'SUCCESS'; let errorMessage = null; try { response = await llmCall(); return response; } catch (error) { status = 'FAILED'; errorMessage = error.message || String(error); throw error; } finally { const latency = timer.getLatency(); const { inputTokens, outputTokens } = response ? extractTokenUsage(response) : { inputTokens: 0, outputTokens: 0 }; logLlmUsage({ projectId: context.projectId, provider: context.provider, model: context.model, inputTokens, outputTokens, latency, status, errorMessage }); } } export default { logLlmUsage, createLatencyTimer, extractTokenUsage, withUsageLogging }; ================================================ FILE: lib/services/clean.js ================================================ import LLMClient from '@/lib/llm/core/index'; import { getDataCleanPrompt } from '@/lib/llm/prompts/dataClean'; import { getTaskConfig, getProject } from '@/lib/db/projects'; import { getChunkById, updateChunkContent } from '@/lib/db/chunks'; import logger from '@/lib/util/logger'; /** * 为指定文本块进行数据清洗 * @param {String} projectId 项目ID * @param {String} chunkId 文本块ID * @param {Object} options 选项 * @param {String} options.model 模型名称 * @param {String} options.language 语言(中文/en) * @returns {Promise} 清洗结果 */ export async function cleanDataForChunk(projectId, chunkId, options) { try { const { model, language = '中文' } = options; if (!model) { throw new Error('模型名称不能为空'); } // 并行获取文本块内容和项目配置 const chunk = await getChunkById(chunkId); if (!chunk) { throw new Error('文本块不存在'); } // 创建LLM客户端 const llmClient = new LLMClient(model); // 获取提示词 const prompt = await getDataCleanPrompt(language, { text: chunk.content }, projectId); const { answer: response } = await llmClient.getResponseWithCOT(prompt); // 直接使用LLM返回的清洗后文本 const cleanedContent = response.trim(); if (!cleanedContent) { throw new Error('数据清洗失败:返回内容为空'); } // 更新文本块内容 await updateChunkContent(chunkId, cleanedContent); // 返回清洗结果 return { chunkId, originalLength: chunk.content.length, cleanedLength: cleanedContent.length, cleanedContent, success: true }; } catch (error) { logger.error('数据清洗时出错:', error); throw error; } } export default { cleanDataForChunk }; ================================================ FILE: lib/services/datasets/evaluation.js ================================================ /** * 数据集评估核心服务 * 从现有的评估接口中抽离核心逻辑,供单个评估和批量评估复用 */ import { getDatasetsById, updateDatasetEvaluation } from '@/lib/db/datasets'; import { getChunkById } from '@/lib/db/chunks'; import LLMClient from '@/lib/llm/core/index'; import { getDatasetEvaluationPrompt } from '@/lib/llm/prompts/datasetEvaluation'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; /** * 评估单个数据集 * @param {string} projectId - 项目ID * @param {string} datasetId - 数据集ID * @param {object} model - 模型配置 * @param {string} language - 语言 * @returns {Promise<{success: boolean, data?: object, error?: string}>} */ export async function evaluateDataset(projectId, datasetId, model, language = 'zh-CN') { try { // 1. 获取数据集信息 const dataset = await getDatasetsById(datasetId); if (!dataset) { throw new Error('数据集不存在'); } if (dataset.projectId !== projectId) { throw new Error('数据集不属于指定项目'); } // 2. 根据 questionId 获取原始文本块内容 let chunkContent = dataset.chunkContent || ''; // 如果数据集中没有 chunkContent,尝试通过 questionId 查找 if (!chunkContent && dataset.questionId) { try { // 查找对应的问题,然后获取 chunk 内容 const { getQuestionById } = await import('@/lib/db/questions'); const question = await getQuestionById(dataset.questionId); if (question && question.chunkId) { const chunk = await getChunkById(question.chunkId); if (chunk) { // 检查是否是蒸馏内容 if (chunk.name === 'Distilled Content') { chunkContent = 'Distilled Content - 没有原始文本参考'; } else { chunkContent = chunk.content; } } } } catch (error) { console.warn('无法获取原始文本块内容:', error.message); chunkContent = dataset.chunkContent || ''; } } // 检查是否是蒸馏内容 if (dataset.chunkName === 'Distilled Content' || chunkContent.includes('Distilled Content')) { chunkContent = 'Distilled Content - 没有原始文本参考'; } // 3. 生成评估提示词 const prompt = await getDatasetEvaluationPrompt( language, { chunkContent, question: dataset.question, answer: dataset.answer }, projectId ); // 4. 调用LLM进行评估 const llmClient = new LLMClient(model); const { answer } = await llmClient.getResponseWithCOT(prompt); // 5. 解析评估结果 let evaluationResult; try { evaluationResult = extractJsonFromLLMOutput(answer); if (!evaluationResult || typeof evaluationResult.score !== 'number' || !evaluationResult.evaluation) { throw new Error('评估结果格式错误'); } // 验证评分范围 if (evaluationResult.score < 0 || evaluationResult.score > 5) { evaluationResult.score = Math.max(0, Math.min(5, evaluationResult.score)); } // 确保评分精确到 0.5 evaluationResult.score = Math.round(evaluationResult.score * 2) / 2; } catch (error) { console.error('解析评估结果失败:', error); throw new Error('AI评估结果解析失败,请重试'); } // 6. 更新数据集评估结果 await updateDatasetEvaluation(datasetId, evaluationResult.score, evaluationResult.evaluation); return { success: true, data: { score: evaluationResult.score, aiEvaluation: evaluationResult.evaluation } }; } catch (error) { console.error('数据集评估失败:', error); return { success: false, error: error.message }; } } /** * 批量评估数据集 * @param {string} projectId - 项目ID * @param {Array} datasetIds - 数据集ID数组 * @param {object} model - 模型配置 * @param {string} language - 语言 * @param {Function} onProgress - 进度回调函数 (current, total) => void * @returns {Promise<{success: number, failed: number, results: Array}>} */ export async function batchEvaluateDatasets(projectId, datasetIds, model, language = 'zh-CN', onProgress = null) { const results = []; let successCount = 0; let failedCount = 0; for (let i = 0; i < datasetIds.length; i++) { const datasetId = datasetIds[i]; try { const result = await evaluateDataset(projectId, datasetId, model, language); if (result.success) { successCount++; results.push({ datasetId, success: true, ...result.data }); } else { failedCount++; results.push({ datasetId, success: false, error: result.error }); } } catch (error) { failedCount++; results.push({ datasetId, success: false, error: error.message }); } // 调用进度回调 if (onProgress) { onProgress(i + 1, datasetIds.length); } // 添加小延迟避免过于频繁的API调用 if (i < datasetIds.length - 1) { await new Promise(resolve => setTimeout(resolve, 100)); } } return { success: successCount, failed: failedCount, results }; } ================================================ FILE: lib/services/datasets/index.js ================================================ import { getQuestionById, updateQuestion, getQuestionTemplateById } from '@/lib/db/questions'; import { createDataset, updateDataset } from '@/lib/db/datasets'; import { getAnswerPrompt } from '@/lib/llm/prompts/answer'; import { getEnhancedAnswerPrompt } from '@/lib/llm/prompts/enhancedAnswer'; import { getOptimizeCotPrompt } from '@/lib/llm/prompts/optimizeCot'; import { safeParseJSON } from '@/lib/llm/common/util'; import { getChunkById } from '@/lib/db/chunks'; import { getActiveGaPairsByFileId } from '@/lib/db/ga-pairs'; import { nanoid } from 'nanoid'; import LLMClient from '@/lib/llm/core/index'; import logger from '@/lib/util/logger'; /** * 优化思维链 * @param {string} originalQuestion - 原始问题 * @param {string} answer - 答案 * @param {string} originalCot - 原始思维链 * @param {string} language - 语言 * @param {object} llmClient - LLM客户端 * @param {string} id - 数据集ID * @param {string} projectId - 项目ID */ async function optimizeCot(originalQuestion, answer, originalCot, language, llmClient, id, projectId) { try { const prompt = await getOptimizeCotPrompt(language, { originalQuestion, answer, originalCot }, projectId); const { answer: as, cot } = await llmClient.getResponseWithCOT(prompt); const optimizedAnswer = as || cot; const result = await updateDataset({ id, cot: optimizedAnswer.replace('优化后的思维链', '') }); logger.info(`成功优化思维链: ${originalQuestion}, ID: ${id}`); return result; } catch (error) { logger.error(`优化思维链失败: ${error.message}`); throw error; } } /** * 为单个问题生成答案并创建数据集 * @param {string} projectId - 项目ID * @param {string} questionId - 问题ID * @param {object} options - 选项 * @param {string} options.model - 模型名称 * @param {string} options.language - 语言(中文/en) * @returns {Promise} 生成的数据集 */ export async function generateDatasetForQuestion(projectId, questionId, options) { try { const { model, language = '中文' } = options; // 验证参数 if (!projectId || !questionId || !model) { throw new Error('缺少必要参数'); } // 获取问题 const question = await getQuestionById(questionId); const questionTemplate = (await getQuestionTemplateById(question.id)) || { answerType: 'text' }; if (!question) { throw new Error('问题不存在'); } // 获取文本块内容 const chunk = await getChunkById(question.chunkId); if (!chunk) { throw new Error('文本块不存在'); } const idDistill = ['Distilled Content', 'Image Chunk'].includes(chunk.name); const llmClient = new LLMClient(model); let activeGaPairs = []; let questionLinkedGaPair = null; let useEnhancedPrompt = false; if (chunk.fileId && !idDistill) { try { activeGaPairs = await getActiveGaPairsByFileId(chunk.fileId); if (question.gaPairId) { questionLinkedGaPair = activeGaPairs.find(ga => ga.id === question.gaPairId); if (questionLinkedGaPair) { useEnhancedPrompt = true; logger.info(`问题关联GA pair: ${questionLinkedGaPair.genreTitle}+${questionLinkedGaPair.audienceTitle}`); } } logger.info(`${useEnhancedPrompt ? '使用' : '不使用'}增强提示词`); } catch (error) { logger.warn(`获取GA pairs失败,使用标准提示词: ${error.message}`); useEnhancedPrompt = false; } } let prompt; if (idDistill) { // 对于蒸馏内容,直接使用问题 prompt = question.question; } else if (useEnhancedPrompt) { // 使用MGA增强提示词 const primaryGaPair = { genre: `${questionLinkedGaPair.genreTitle}: ${questionLinkedGaPair.genreDesc}`, audience: `${questionLinkedGaPair.audienceTitle}: ${questionLinkedGaPair.audienceDesc}`, active: questionLinkedGaPair.isActive }; logger.info(`使用问题关联的GA pair: ${primaryGaPair.genre} | ${primaryGaPair.audience}`); prompt = await getEnhancedAnswerPrompt( language, { text: chunk.content, question: question.question, activeGaPair: primaryGaPair, questionTemplate }, projectId ); logger.info(`使用MGA增强提示词生成答案`); } else { // 使用标准提示词 prompt = await getAnswerPrompt( language, { text: chunk.content, question: question.question, questionTemplate }, projectId ); logger.info('使用标准提示词生成答案'); } // 调用大模型生成答案 let { answer, cot } = await llmClient.getResponseWithCOT(prompt); if (questionTemplate.answerType !== 'text') { const answerJson = safeParseJSON(answer); if (typeof answerJson !== 'string') { answer = JSON.stringify(answerJson, null, 2); } } const datasetId = nanoid(12); const datasets = { id: datasetId, projectId: projectId, question: question.question, answer: answer, model: model.modelName, cot: cot, questionLabel: question.label || '', answerType: questionTemplate.answerType || 'text' }; let chunkData = await getChunkById(question.chunkId); datasets.chunkName = chunkData.name; datasets.chunkContent = ''; // 不再保存原始文本块内容 datasets.questionId = question.id; let dataset = await createDataset(datasets); if (cot && !idDistill) { // 为了性能考虑,这里异步优化 optimizeCot(question.question, answer, cot, language, llmClient, datasetId, projectId); } if (dataset) { await updateQuestion({ id: questionId, answered: true }); } const logMessage = useEnhancedPrompt ? `成功生成MGA增强数据集: ${question.question}` : `成功生成标准数据集: ${question.question}`; logger.info(logMessage); return { success: true, dataset, mgaEnhanced: useEnhancedPrompt, activePairs: activeGaPairs.length }; } catch (error) { logger.error(`生成数据集失败: ${error.message}`); throw error; } } export default { generateDatasetForQuestion, optimizeCot }; ================================================ FILE: lib/services/eval/index.js ================================================ import LLMClient from '@/lib/llm/core/index'; import { getEvalQuestionPrompt } from '@/lib/llm/prompts/evalQuestion'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; import { getChunkById } from '@/lib/db/chunks'; import { getTaskConfig } from '@/lib/db/projects'; import { createEvalQuestion } from '@/lib/db/evalDatasets'; import logger from '@/lib/util/logger'; /** * 计算各题型应该生成的数量 * 使用加权随机抽样算法,每次根据比例权重随机选择一个题型 * @param {number} textLength - 文本长度 * @param {number} questionGenerationLength - 每多少字生成一个问题(从配置中获取) * @param {Object} ratios - 各题型比例配置 * @returns {Object} - 各题型的生成数量 */ function calculateQuestionCounts(textLength, questionGenerationLength, ratios) { // 计算总题目数 const totalQuestions = Math.floor(textLength / questionGenerationLength); // 计算比例总和 const totalRatio = Object.values(ratios).reduce((sum, ratio) => sum + ratio, 0); // 如果所有比例都是0或总题目数为0,返回空对象 if (totalRatio === 0 || totalQuestions === 0) { return {}; } const questionTypes = ['true_false', 'single_choice', 'multiple_choice', 'short_answer', 'open_ended']; // 过滤出比例大于0的题型 const activeTypes = questionTypes.filter(type => ratios[type] > 0); if (activeTypes.length === 0) { return {}; } // 初始化计数器 const counts = {}; activeTypes.forEach(type => { counts[type] = 0; }); // 循环 totalQuestions 次,每次根据权重随机选择一个题型 for (let i = 0; i < totalQuestions; i++) { // 生成 0 到 totalRatio 之间的随机数 const random = Math.random() * totalRatio; // 根据累积权重确定选中的题型 let cumulative = 0; for (const type of activeTypes) { cumulative += ratios[type]; if (random < cumulative) { counts[type]++; break; } } } // 过滤掉数量为0的题型 const result = {}; Object.keys(counts).forEach(type => { if (counts[type] > 0) { result[type] = counts[type]; } }); return result; } /** * 为单个文本块生成测评题目 * @param {string} projectId - 项目ID * @param {string} chunkId - 文本块ID * @param {Object} options - 生成选项 * @param {Object} options.model - 模型配置 * @param {string} options.language - 语言('zh-CN' 或 'en') * @param {boolean} options.debug - 是否开启调试模式 * @returns {Promise} - 生成结果 */ export async function generateEvalQuestionsForChunk(projectId, chunkId, options) { const { model, language = 'zh-CN' } = options; try { // 获取文本块内容 const chunk = await getChunkById(chunkId); if (!chunk) { throw new Error(`Chunk not found: ${chunkId}`); } // 获取项目配置 const taskConfig = await getTaskConfig(projectId); const { questionGenerationLength = 240, evalQuestionTypeRatios } = taskConfig; // 如果没有配置比例,使用默认值 const ratios = evalQuestionTypeRatios || { true_false: 0, single_choice: 1, multiple_choice: 0, short_answer: 0, open_ended: 0 }; // 计算各题型数量 const questionCounts = calculateQuestionCounts(chunk.content.length, questionGenerationLength, ratios); logger.info('Generating eval questions:', questionCounts); // 如果没有需要生成的题目,直接返回 if (Object.keys(questionCounts).length === 0) { return { chunkId, questions: [], total: 0, message: 'No question types configured' }; } // 创建LLM客户端 const llmClient = new LLMClient(model); // 为每个题型生成题目 const allQuestions = []; const questionTypes = Object.keys(questionCounts); for (const questionType of questionTypes) { const count = questionCounts[questionType]; if (count <= 0) continue; try { // 获取对应题型的提示词 const prompt = await getEvalQuestionPrompt( language, questionType, { text: chunk.content, number: count }, projectId ); // 调用LLM生成题目 const { answer } = await llmClient.getResponseWithCOT(prompt); // 使用项目标准的JSON解析函数 const questions = extractJsonFromLLMOutput(answer); // 为每个题目添加类型标识 questions.forEach(q => { q.questionType = questionType; }); allQuestions.push(...questions); logger.info(`Generated ${questions.length} questions for type ${questionType}`); } catch (error) { logger.error(`Failed to generate questions for type ${questionType}:`, error); // 继续处理其他题型 } } // 保存到数据库(在服务层处理数据转换) const savedQuestions = []; for (const question of allQuestions) { const saved = await createEvalQuestion({ projectId, chunkId, question: question.question, questionType: question.questionType, options: question.options ? JSON.stringify(question.options) : '', correctAnswer: Array.isArray(question.correctAnswer) ? JSON.stringify(question.correctAnswer) : String(question.correctAnswer || ''), tags: question.tags || '', note: question.note || '' }); savedQuestions.push(saved); } return { chunkId, questions: savedQuestions, total: savedQuestions.length, breakdown: questionCounts }; } catch (error) { logger.error('Error generating eval questions:', error); throw error; } } ================================================ FILE: lib/services/evaluation/index.js ================================================ /** * 模型评估服务 * 提供单题评估、答案匹配、LLM 评分等核心功能 */ import LLMClient from '@/lib/llm/core/index'; import { buildAnswerPrompt } from '@/lib/llm/prompts/modelEvaluation'; import { buildJudgePrompt } from '@/lib/llm/prompts/llmJudge'; // 答题状态常量 export const EVAL_STATUS = { SUCCESS: 0, // 成功 FORMAT_ERROR: 1, // 输出格式不符合规范 API_ERROR: 2 // LLM 调用报错 }; /** * 评估单个题目 * @param {Object} params - 评估参数 * @param {Object} params.evalDataset - 评估题目数据 * @param {Object} params.testModelConfig - 测试模型配置 * @param {Object} params.judgeModelConfig - 教师模型配置(可选,主观题需要) * @param {string} params.projectId - 项目ID * @param {string} params.language - 语言 * @param {Array} params.customScoreAnchors - 自定义评分规则(可选) * @returns {Promise} - 评估结果 { modelAnswer, score, isCorrect, judgeResponse, duration, status, errorMessage } */ export async function evaluateSingleQuestion({ evalDataset, testModelConfig, judgeModelConfig, projectId, language = 'zh-CN', customScoreAnchors = null }) { const startTime = Date.now(); let modelAnswer = ''; let status = EVAL_STATUS.SUCCESS; let errorMessage = ''; // 创建测试模型客户端 const testLLMClient = new LLMClient({ projectId, providerId: testModelConfig.providerId, modelName: testModelConfig.modelId, apiKey: testModelConfig.apiKey, endpoint: testModelConfig.endpoint }); try { // 获取模型回答 modelAnswer = await getModelAnswer(testLLMClient, evalDataset, language); } catch (error) { // LLM 调用报错 status = EVAL_STATUS.API_ERROR; errorMessage = error.message || 'LLM API Error'; const duration = Date.now() - startTime; return { modelAnswer: '', score: 0, isCorrect: false, judgeResponse: '', duration, status, errorMessage }; } // 评估答案 let judgeClient = null; if (judgeModelConfig && needsLLMJudge(evalDataset.questionType)) { judgeClient = new LLMClient({ projectId, providerId: judgeModelConfig.providerId, modelName: judgeModelConfig.modelId, apiKey: judgeModelConfig.apiKey, endpoint: judgeModelConfig.endpoint }); } const result = await evaluateAnswer(evalDataset, modelAnswer, judgeClient, language, customScoreAnchors); // 检查是否是格式错误(对于客观题,如果无法匹配答案可能是格式问题) if (!result.isCorrect && isFormatError(evalDataset.questionType, modelAnswer)) { status = EVAL_STATUS.FORMAT_ERROR; errorMessage = '模型输出格式不符合规范'; } const duration = Date.now() - startTime; return { modelAnswer, ...result, duration, status, errorMessage }; } /** * 检查是否是格式错误 */ function isFormatError(questionType, modelAnswer) { if (!modelAnswer || modelAnswer.trim() === '') return true; const answer = modelAnswer.trim(); switch (questionType) { case 'true_false': // 判断题应该输出 ✅ 或 ❌ return answer !== '✅' && answer !== '❌'; case 'single_choice': // 单选题应该输出单个字母 return !/^[A-Za-z]$/.test(answer.charAt(0)); case 'multiple_choice': // 多选题应该输出多个字母 return !/^[A-Za-z]+$/.test(answer.replace(/[^A-Za-z]/g, '')); default: return false; } } /** * 检查题型是否需要 LLM 评分 */ export function needsLLMJudge(questionType) { return questionType === 'short_answer' || questionType === 'open_ended'; } /** * 获取模型对题目的回答 */ async function getModelAnswer(llmClient, evalDataset, language) { const { question, questionType, options } = evalDataset; // 构建选项文本 let optionsText = ''; if (options && (questionType === 'single_choice' || questionType === 'multiple_choice')) { try { const optionsArray = JSON.parse(options); optionsText = optionsArray.map((opt, i) => `${String.fromCharCode(65 + i)}. ${opt}`).join('\n'); } catch (e) { optionsText = options; } } // 构建提示词 const prompt = await buildAnswerPrompt(questionType, question, optionsText, language); const { answer } = await llmClient.getResponseWithCOT(prompt); return answer; } /** * 评估模型答案 */ async function evaluateAnswer(evalDataset, modelAnswer, judgeLLMClient, language, customScoreAnchors = null) { const { questionType, correctAnswer } = evalDataset; switch (questionType) { case 'true_false': return evaluateTrueFalse(modelAnswer, correctAnswer); case 'single_choice': return evaluateSingleChoice(modelAnswer, correctAnswer); case 'multiple_choice': return evaluateMultipleChoice(modelAnswer, correctAnswer); case 'short_answer': case 'open_ended': if (!judgeLLMClient) { return { score: 0, isCorrect: false, judgeResponse: '缺少教师模型,无法评分' }; } return await evaluateWithLLM( judgeLLMClient, evalDataset, modelAnswer, questionType, language, customScoreAnchors ); default: return { score: 0, isCorrect: false, judgeResponse: '未知题型' }; } } /** * 评估判断题 */ function evaluateTrueFalse(modelAnswer, correctAnswer) { // 根据 TRUE_FALSE_ANSWER_PROMPT,模型应该仅输出 ✅ 或 ❌ // 直接检查这两个 emoji const modelTrimmed = modelAnswer.trim(); const correctTrimmed = correctAnswer.trim(); console.log('modelTrimmed:', modelTrimmed); console.log('correctTrimmed:', correctTrimmed); const isCorrect = modelTrimmed === correctTrimmed && (modelTrimmed === '✅' || modelTrimmed === '❌'); return { score: isCorrect ? 1 : 0, isCorrect, judgeResponse: '' }; } /** * 评估单选题 */ function evaluateSingleChoice(modelAnswer, correctAnswer) { const modelLetter = extractLetters(modelAnswer); const correctLetter = extractLetters(correctAnswer); const isCorrect = modelLetter.charAt(0) === correctLetter.charAt(0); return { score: isCorrect ? 1 : 0, isCorrect, judgeResponse: '' }; } /** * 评估多选题 */ function evaluateMultipleChoice(modelAnswer, correctAnswer) { const modelLetters = extractLetters(modelAnswer).split('').sort().join(''); const correctLetters = extractLetters(correctAnswer).split('').sort().join(''); const isCorrect = modelLetters === correctLetters; return { score: isCorrect ? 1 : 0, isCorrect, judgeResponse: '' }; } /** * 使用 LLM 评估主观题 */ async function evaluateWithLLM( judgeLLMClient, evalDataset, modelAnswer, questionType, language, customScoreAnchors = null ) { const { question, correctAnswer } = evalDataset; // 构建评估提示词(简答题和开放题使用不同的评估标准,支持自定义评分规则) const prompt = await buildJudgePrompt( questionType, question, correctAnswer, modelAnswer, language, customScoreAnchors ); try { const { answer } = await judgeLLMClient.getResponseWithCOT(prompt); return parseJudgeResponse(answer); } catch (error) { console.error('LLM 评分失败:', error); return { score: 0, isCorrect: false, judgeResponse: `评分失败: ${error.message}` }; } } /** * 解析评分响应 */ function parseJudgeResponse(responseText) { // 尝试提取 JSON const jsonMatch = responseText.match(/\{[\s\S]*?\}/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[0]); const score = Math.max(0, Math.min(1, parseFloat(parsed.score) || 0)); return { score, isCorrect: score >= 0.6, judgeResponse: responseText }; } catch (e) { // JSON 解析失败,继续尝试其他方式 } } // 尝试从文本中提取分数 const scoreMatch = responseText.match(/(\d+\.?\d*)/); if (scoreMatch) { let score = parseFloat(scoreMatch[1]); if (score > 1) score = score / 100; score = Math.max(0, Math.min(1, score)); return { score, isCorrect: score >= 0.6, judgeResponse: responseText }; } return { score: 0, isCorrect: false, judgeResponse: `无法解析评分结果: ${responseText}` }; } /** * 标准化文本 */ function normalizeText(text) { return String(text || '') .trim() .toLowerCase() .replace(/\s+/g, ' '); } /** * 提取字母(用于选择题) */ function extractLetters(answer) { return String(answer || '') .toUpperCase() .replace(/[^A-Z]/g, ''); } export default { evaluateSingleQuestion, needsLLMJudge }; ================================================ FILE: lib/services/ga/ga-generation.js ================================================ import { getActiveModel } from '@/lib/services/models'; import logger from '@/lib/util/logger'; import { getGAGenerationPrompt } from '@/lib/llm/prompts/ga-generation'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; const LLMClient = require('@/lib/llm/core'); /** * Generate GA pairs for text content using LLM * @param {string} textContent - The text content to analyze * @param {string} projectId - The project ID to get the active model for * @param {string} language - Language for generation (default: '中文') * @returns {Promise} - Generated GA pairs */ export async function generateGaPairs(textContent, projectId, language = '中文') { try { logger.info('Starting GA pairs generation'); // 验证输入参数 if (!textContent || typeof textContent !== 'string') { throw new Error('Invalid text content provided'); } if (!projectId) { throw new Error('Project ID is required'); } // Get model configuration const model = await getActiveModel(projectId); if (!model) { throw new Error('No active model available for GA generation'); } logger.info(`Using model: ${model.modelName} for project ${projectId}`); const prompt = await getGAGenerationPrompt(language, { text: textContent }, projectId); if (!prompt) { throw new Error('Failed to generate prompt'); } // Call the LLM API const response = await callLLMAPI(model, prompt); if (!response) { throw new Error('Empty response from LLM'); } // Parse the response const gaPairs = parseGaResponse(response); logger.info(`Successfully generated ${gaPairs.length} GA pairs`); return gaPairs; } catch (error) { logger.error('Failed to generate GA pairs:', error); throw error; } } /** * Call LLM API with the given model and prompt * @param {Object} model - Model configuration * @param {string} prompt - The prompt to send * @returns {Promise} - Parsed JSON object/array */ async function callLLMAPI(model, prompt) { try { if (!model || !prompt) { throw new Error('Model and prompt are required'); } logger.info('Calling LLM API...'); const llmClient = new LLMClient(model); const response = await llmClient.getResponse(prompt); // Changed from llmClient.chat if (!response) { throw new Error('Invalid response from LLM'); } return response; } catch (error) { logger.error('LLM API call failed:', error); throw new Error(`LLM API call failed: ${error.message}`); } } /** * Parse GA pairs from LLM response * @param {string} response - Raw LLM response * @returns {Array} - Parsed GA pairs */ function parseGaResponse(response) { try { // Log the raw response for debugging logger.info('Raw LLM response length:', response.length); const parsed = extractJsonFromLLMOutput(response); if (!parsed) { throw new Error('Failed to extract JSON from LLM response'); } // Handle case where response is wrapped in an object let gaPairsArray = parsed; if (!Array.isArray(parsed)) { // Check if it's wrapped in a property if (parsed.gaPairs && Array.isArray(parsed.gaPairs)) { gaPairsArray = parsed.gaPairs; } else if (parsed.pairs && Array.isArray(parsed.pairs)) { gaPairsArray = parsed.pairs; } else if (parsed.results && Array.isArray(parsed.results)) { gaPairsArray = parsed.results; } else { // Try to convert object format to array format const objectKeys = Object.keys(parsed); const audienceKeys = objectKeys.filter(key => key.startsWith('audience_')); const genreKeys = objectKeys.filter(key => key.startsWith('genre_')); if (audienceKeys.length > 0 && genreKeys.length > 0) { gaPairsArray = []; for (let i = 1; i <= Math.min(audienceKeys.length, genreKeys.length); i++) { const audience = parsed[`audience_${i}`]; const genre = parsed[`genre_${i}`]; if (audience && genre) { gaPairsArray.push({ audience, genre }); } } } else { throw new Error('Response is not an array and no recognized array property found'); } } } // Validate the structure const validatedPairs = gaPairsArray.map((pair, index) => { if (!pair.genre || !pair.audience) { throw new Error(`GA pair ${index + 1} missing genre or audience`); } if (!pair.genre.title || !pair.genre.description || !pair.audience.title || !pair.audience.description) { throw new Error(`GA pair ${index + 1} missing required fields`); } return { genre: { title: String(pair.genre.title).trim(), description: String(pair.genre.description).trim() }, audience: { title: String(pair.audience.title).trim(), description: String(pair.audience.description).trim() } }; }); // Ensure we have exactly 5 pairs if (validatedPairs.length !== 5) { logger.warn(`Expected 5 GA pairs, got ${validatedPairs.length}. Using first 5 or padding with fallbacks.`); // If we have more than 5, take the first 5 if (validatedPairs.length > 5) { return validatedPairs.slice(0, 5); } // If we have fewer than 5, pad with fallbacks const fallbacks = getFallbackGaPairs(); while (validatedPairs.length < 5) { validatedPairs.push(fallbacks[validatedPairs.length]); } } logger.info(`Successfully parsed ${validatedPairs.length} GA pairs`); return validatedPairs; } catch (error) { logger.error('Failed to parse GA response:', error); logger.error('Raw response:', response); // Return fallback GA pairs if parsing fails logger.info('Using fallback GA pairs due to parsing failure'); return getFallbackGaPairs(); } } /** * Get fallback GA pairs when generation fails * @returns {Array} - Default GA pairs */ function getFallbackGaPairs() { return [ { genre: { title: '学术研究', description: '学术性、研究导向的内容,具有正式的语调和详细的分析' }, audience: { title: '研究人员', description: '寻求深入知识的学术研究人员和研究生' } }, { genre: { title: '教育指南', description: '结构化的学习材料,具有清晰的解释和示例' }, audience: { title: '学生', description: '本科生和该主题的新学习者' } }, { genre: { title: '专业手册', description: '实用、以实施为重点的内容,用于工作场所应用' }, audience: { title: '从业者', description: '在实践中应用知识的行业专业人员' } }, { genre: { title: '科普文章', description: '使复杂主题易于理解的可访问内容' }, audience: { title: '普通公众', description: '没有专业背景的好奇读者' } }, { genre: { title: '技术文档', description: '详细的规范和实施指南' }, audience: { title: '开发人员', description: '技术专家和系统实施人员' } } ]; } ================================================ FILE: lib/services/ga/ga-pairs.js ================================================ import { generateGaPairs } from './ga-generation'; import { getModelById } from '../models'; import { saveGaPairs, getGaPairsByFileId } from '@/lib/db/ga-pairs'; import { getProjectFileContentById } from '@/lib/db/files'; import logger from '@/lib/util/logger'; /** * Batch generate GA pairs for multiple files * @param {string} projectId - Project ID * @param {Array} files - Array of file objects * @param {string} modelConfigId - Model configuration ID * @param {string} language - Language for generation (default: '中文') * @param {boolean} appendMode - Whether to append to existing GA pairs (default: false) * @returns {Promise} - Array of generation results */ export async function batchGenerateGaPairs(projectId, files, modelConfigId, language = '中文', appendMode = false) { try { logger.info(`Starting batch GA pairs generation for ${files.length} files`); // Get model configuration const modelConfig = await getModelById(modelConfigId); if (!modelConfig) { throw new Error('Model configuration not found'); } const results = []; // Process each file for (const file of files) { try { logger.info(`Processing file: ${file.fileName}`); // Check if GA pairs already exist for this file const existingPairs = await getGaPairsByFileId(file.id); // 在非追加模式下,如果已存在GA对则跳过 if (!appendMode && existingPairs && existingPairs.length > 0) { logger.info(`GA pairs already exist for file ${file.fileName}, skipping`); results.push({ fileId: file.id, fileName: file.fileName, success: true, skipped: true, message: 'GA pairs already exist', gaPairs: existingPairs }); continue; } // Get file content const fileContent = await getProjectFileContentById(projectId, file.id); if (!fileContent) { throw new Error('File content not found'); } // Limit content length for processing (max 50,000 characters) const maxLength = 50000; const content = fileContent.length > maxLength ? fileContent.substring(0, maxLength) + '...' : fileContent; // Generate GA pairs const gaPairs = await generateGaPairs(content, projectId, language); // Save GA pairs to database const savedPairs = await saveGaPairsForFile(projectId, file.id, gaPairs, appendMode, existingPairs); results.push({ fileId: file.id, fileName: file.fileName, success: true, skipped: false, message: `Generated ${gaPairs.length} GA pairs`, gaPairs: savedPairs }); logger.info(`Successfully generated GA pairs for file: ${file.fileName}`); } catch (error) { logger.error(`Failed to generate GA pairs for file ${file.fileName}:`, error); results.push({ fileId: file.id, fileName: file.fileName, success: false, skipped: false, error: error.message, message: `Failed: ${error.message}` }); } } logger.info( `Batch GA pairs generation completed. Success: ${results.filter(r => r.success).length}, Failed: ${results.filter(r => !r.success).length}` ); return results; } catch (error) { logger.error('Batch GA pairs generation failed:', error); throw error; } } /** * Save GA pairs for a file * @param {string} projectId - Project ID * @param {string} fileId - File ID * @param {Array} gaPairs - Generated GA pairs * @param {boolean} appendMode - Whether to append to existing GA pairs * @param {Array} existingPairs - Existing GA pairs (for append mode) * @returns {Promise} - Saved GA pairs */ async function saveGaPairsForFile(projectId, fileId, gaPairs, appendMode = false, existingPairs = []) { try { if (appendMode && existingPairs.length > 0) { // 追加模式:使用与单文件生成相同的逻辑 const { createGaPairs } = await import('@/lib/db/ga-pairs'); const startPairNumber = existingPairs.length + 1; const newGaPairData = gaPairs.map((pair, index) => ({ projectId, fileId, pairNumber: startPairNumber + index, genreTitle: pair.genre?.title || pair.genreTitle || '', genreDesc: pair.genre?.description || pair.genreDesc || '', audienceTitle: pair.audience?.title || pair.audienceTitle || '', audienceDesc: pair.audience?.description || pair.audienceDesc || '', isActive: true })); // 只创建新的GA对,不删除现有的 await createGaPairs(newGaPairData); // 返回所有GA对(现有的+新增的) const allPairs = await getGaPairsByFileId(fileId); return allPairs; } else { // Use the database function to save GA pairs const result = await saveGaPairs(projectId, fileId, gaPairs); // Get the saved pairs to return const savedPairs = await getGaPairsByFileId(fileId); return savedPairs; } } catch (error) { logger.error('Failed to save GA pairs:', error); throw error; } } ================================================ FILE: lib/services/images/index.js ================================================ /** * 图片问题和答案生成服务 */ import LLMClient from '@/lib/llm/core/index'; import { getImageQuestionPrompt } from '@/lib/llm/prompts/imageQuestion'; import { getImageAnswerPrompt } from '@/lib/llm/prompts/imageAnswer'; import { extractJsonFromLLMOutput, safeParseJSON } from '@/lib/llm/common/util'; import { getImageById, getImageChunk, createImages } from '@/lib/db/images'; import { saveQuestions, updateQuestionAnsweredStatus, getQuestionTemplateById } from '@/lib/db/questions'; import { createImageDataset } from '@/lib/db/imageDatasets'; import { getProjectPath } from '@/lib/db/base'; import { getMimeType } from '@/lib/util/image'; import path from 'path'; import fs from 'fs/promises'; import sizeOf from 'image-size'; import logger from '@/lib/util/logger'; /** * 为指定图片生成问题 * @param {String} projectId 项目ID * @param {String} imageId 图片ID * @param {Object} options 选项 * @param {Object} options.model 模型配置 * @param {String} options.language 语言(zh/en) * @param {Number} options.count 问题数量(默认3) * @returns {Promise} 生成结果 */ export async function generateQuestionsForImage(projectId, imageId, options) { try { const { model, language = 'zh', count = 3 } = options; if (!model) { throw new Error('模型配置不能为空'); } // 获取图片信息 const image = await getImageById(imageId); if (!image) { throw new Error('图片不存在'); } if (image.projectId !== projectId) { throw new Error('图片不属于指定项目'); } // 读取图片文件 const projectPath = await getProjectPath(projectId); const imagePath = path.join(projectPath, 'images', image.imageName); const imageBuffer = await fs.readFile(imagePath); const base64Image = imageBuffer.toString('base64'); const mimeType = getMimeType(image.imageName); // 创建 LLM 客户端 const llmClient = new LLMClient(model); // 生成问题提示词 const prompt = await getImageQuestionPrompt(language, { number: count }, projectId); // 调用视觉模型生成问题 const { answer } = await llmClient.getVisionResponse(prompt, base64Image, mimeType); // 提取问题列表 const questions = extractJsonFromLLMOutput(answer); if (!questions || !Array.isArray(questions) || questions.length === 0) { throw new Error('生成问题失败或问题列表为空'); } // 获取或创建图片专用的虚拟 chunk const imageChunk = await getImageChunk(projectId); // 保存问题到数据库 const savedQuestions = await saveQuestions( projectId, questions.map(q => ({ question: q, label: 'image', imageId: image.id, imageName: image.imageName, chunkId: imageChunk.id })) ); logger.info(`图片 ${image.imageName} 生成了 ${questions.length} 个问题`); return { imageId: image.id, imageName: image.imageName, questions: questions, total: questions.length }; } catch (error) { logger.error(`为图片 ${imageId} 生成问题时出错:`, error); throw error; } } /** * 为指定图片生成数据集(问答对) * @param {String} projectId 项目ID * @param {String} imageId 图片ID * @param {String} question 问题文本 * @param {Object} options 选项 * @param {Object} options.model 模型配置 * @returns {Promise} 生成结果 */ export async function generateDatasetForImage(projectId, imageId, question, options) { try { const { model, language = 'zh', previewOnly = false } = options; if (!model) { throw new Error('模型配置不能为空'); } // 获取图片信息 const image = await getImageById(imageId); if (!image) { throw new Error('图片不存在'); } if (image.projectId !== projectId) { throw new Error('图片不属于指定项目'); } // 读取图片文件 const projectPath = await getProjectPath(projectId); const imagePath = path.join(projectPath, 'images', image.imageName); const imageBuffer = await fs.readFile(imagePath); const base64Image = imageBuffer.toString('base64'); const mimeType = getMimeType(image.imageName); // 获取问题模版 const llmClient = new LLMClient(model); const { id, question: questionText } = question; let questionTemplate = { answerType: 'text' }; if (id) { questionTemplate = (await getQuestionTemplateById(question.id)) || { answerType: 'text' }; } const prompt = await getImageAnswerPrompt(language, { question: questionText, questionTemplate }, projectId); let { answer } = await llmClient.getVisionResponse(prompt, base64Image, mimeType); if (questionTemplate.answerType !== 'text') { const answerJson = safeParseJSON(answer); if (typeof answerJson !== 'string') { answer = JSON.stringify(answerJson, null, 2); } } // 如果是预览模式,只返回答案,不保存数据集 if (previewOnly) { return { imageId: image.id, imageName: image.imageName, question: questionText, answer: answer, dataset: null }; } // 保存图片数据集 const dataset = await createImageDataset(projectId, { imageId: image.id, imageName: image.imageName, question: questionText, questionId: id, answer: answer, model: model.modelId || model.modelName, answerType: questionTemplate.answerType }); // 更新对应问题的 answered 状态为 true await updateQuestionAnsweredStatus(projectId, image.id, questionText, true); logger.info(`图片 ${image.imageName} 的问题 "${questionText}" 已生成数据集`); return { imageId: image.id, imageName: image.imageName, question: questionText, answer: answer, dataset: dataset }; } catch (error) { logger.error(`为图片 ${imageId} 生成数据集时出错:`, error); throw error; } } /** * 导入图片到项目 * @param {String} projectId 项目ID * @param {Array} directories 目录路径数组 * @returns {Promise} 导入结果 { success: true, count: number, images: Array } */ export async function importImagesFromDirectories(projectId, directories) { try { if (!directories || !Array.isArray(directories) || directories.length === 0) { throw new Error('请选择至少一个目录'); } // 项目图片目录 const projectPath = await getProjectPath(projectId); const projectImagesDir = path.join(projectPath, 'images'); await fs.mkdir(projectImagesDir, { recursive: true }); const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']; const importedImages = []; // 遍历所有选择的目录 for (const directory of directories) { try { const files = await fs.readdir(directory); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (!imageExtensions.includes(ext)) continue; const sourcePath = path.join(directory, file); const destPath = path.join(projectImagesDir, file); // 复制文件(覆盖同名文件) await fs.copyFile(sourcePath, destPath); // 获取图片信息 const stats = await fs.stat(destPath); let dimensions = { width: null, height: null }; try { // 读取文件为 Buffer,然后传递给 sizeOf const imageBuffer = await fs.readFile(destPath); const size = sizeOf(imageBuffer); if (size && size.width && size.height) { dimensions = { width: size.width, height: size.height }; } } catch (err) { console.warn(`无法获取图片尺寸: ${file}`, err.message); } importedImages.push({ imageName: file, path: `${projectPath}/images/${file}`, size: stats.size, width: dimensions.width, height: dimensions.height }); } } catch (err) { console.error(`处理目录失败: ${directory}`, err); } } // 批量保存到数据库 const savedImages = await createImages(projectId, importedImages); logger.info(`项目 ${projectId} 成功导入 ${savedImages.length} 张图片`); return { success: true, count: savedImages.length, images: savedImages }; } catch (error) { logger.error(`导入图片到项目 ${projectId} 时出错:`, error); throw error; } } /** * 获取图片详情(包含问题列表和已标注数据) * @param {String} projectId 项目ID * @param {String} imageId 图片ID * @returns {Promise} 图片详情 */ export async function getImageDetailWithQuestions(projectId, imageId) { try { const { db } = await import('@/lib/db/index'); if (!imageId) { throw new Error('缺少图片ID'); } // 获取图片基本信息 const image = await getImageById(imageId); if (!image) { throw new Error('图片不存在'); } if (image.projectId !== projectId) { throw new Error('图片不属于指定项目'); } // 读取图片文件并转换为base64 let base64Image = null; try { const projectPath = await getProjectPath(projectId); const imagePath = path.join(projectPath, 'images', image.imageName); const imageBuffer = await fs.readFile(imagePath); const mimeType = getMimeType(image.imageName); base64Image = `data:${mimeType};base64,${imageBuffer.toString('base64')}`; } catch (err) { console.warn(`Failed to read image: ${image.imageName}`, err); } // 获取图片的所有问题 const questions = await db.questions.findMany({ where: { projectId, imageId: image.id }, orderBy: { createAt: 'desc' } }); // 获取所有关联的问题模板 const templateIds = questions.map(q => q.templateId).filter(Boolean); const templates = templateIds.length > 0 ? await db.questionTemplates.findMany({ where: { id: { in: templateIds } } }) : []; const templateMap = new Map(templates.map(t => [t.id, t])); // 获取每个问题的已标注答案 const questionsWithAnswers = await Promise.all( questions.map(async question => { // 查找该问题的已标注答案 const existingAnswer = await db.imageDatasets.findFirst({ where: { imageId: image.id, question: question.question }, orderBy: { createAt: 'desc' } }); // 获取关联的模板 const template = question.templateId ? templateMap.get(question.templateId) : null; return { ...question, template, hasAnswer: !!existingAnswer, answer: existingAnswer?.answer || null, answerId: existingAnswer?.id || null }; }) ); // 分离已标注和未标注的问题 const answeredQuestions = questionsWithAnswers .filter(q => q.hasAnswer) .map(q => ({ id: q.id, question: q.question, answerType: q.template?.answerType || 'text', labels: q.template?.labels || '', customFormat: q.template?.customFormat || '', description: q.template?.description || '', answer: q.answer, answerId: q.answerId, templateId: q.templateId })); const unansweredQuestions = questionsWithAnswers .filter(q => !q.hasAnswer) .map(q => ({ id: q.id, question: q.question, answerType: q.template?.answerType || 'text', labels: q.template?.labels || '', customFormat: q.template?.customFormat || '', description: q.template?.description || '', templateId: q.templateId })); return { ...image, base64: base64Image, format: image.imageName.split('.').pop()?.toLowerCase(), answeredQuestions, unansweredQuestions, datasetCount: answeredQuestions.length, questionCount: questions.length }; } catch (error) { logger.error(`获取图片 ${imageId} 详情时出错:`, error); throw error; } } export default { generateQuestionsForImage, importImagesFromDirectories, generateDatasetForImage, getImageDetailWithQuestions }; ================================================ FILE: lib/services/models.js ================================================ import { getModelConfigById } from '@/lib/db/model-config'; import { getProject } from '@/lib/db/projects'; import logger from '@/lib/util/logger'; /** * Get the active model configuration for a project * @param {string} projectId - Optional project ID to get the default model for * @returns {Promise} - Active model configuration or null */ export async function getActiveModel(projectId = null) { try { // If projectId is provided, get the default model for that project if (projectId) { const project = await getProject(projectId); if (project && project.defaultModelConfigId) { const modelConfig = await getModelConfigById(project.defaultModelConfigId); if (modelConfig) { logger.info(`Using default model for project ${projectId}: ${modelConfig.modelName}`); return modelConfig; } } } // If no specific project model found, try to get from localStorage context // This is a fallback for when the function is called without context logger.warn('No active model found'); return null; } catch (error) { logger.error('Failed to get active model:', error); return null; } } /** * Get active model by ID * @param {string} modelConfigId - Model configuration ID * @returns {Promise} - Model configuration or null */ export async function getModelById(modelConfigId) { try { if (!modelConfigId) { logger.warn('No model ID provided'); return null; } const modelConfig = await getModelConfigById(modelConfigId); if (modelConfig) { logger.info(`Retrieved model: ${modelConfig.modelName}`); return modelConfig; } logger.warn(`Model not found with ID: ${modelConfigId}`); return null; } catch (error) { logger.error('Failed to get model by ID:', error); return null; } } ================================================ FILE: lib/services/multi-turn/index.js ================================================ /** * 多轮对话数据集生成核心服务 */ import { getQuestionById } from '@/lib/db/questions'; import { getChunkById } from '@/lib/db/chunks'; import { createDatasetConversation } from '@/lib/db/dataset-conversations'; import LLMClient from '@/lib/llm/core/index'; import { getAssistantReplyPrompt, getNextQuestionPrompt } from '@/lib/llm/prompts/multiTurnConversation'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; import { nanoid } from 'nanoid'; /** * 生成多轮对话数据集 * @param {string} projectId - 项目ID * @param {string} questionId - 问题ID * @param {object} config - 多轮对话配置 * @returns {Promise<{success: boolean, data?: object, error?: string}>} */ export async function generateMultiTurnConversation(projectId, questionId, config) { try { const { systemPrompt = '', scenario = '', rounds = 3, roleA = '用户', roleB = '助手', model, language = '中文' } = config; // 1. 获取问题信息 const question = await getQuestionById(questionId); if (!question) { throw new Error('问题不存在'); } if (question.projectId !== projectId) { throw new Error('问题不属于指定项目'); } // 2. 获取文本块内容 const chunk = await getChunkById(question.chunkId); if (!chunk) { throw new Error('文本块不存在'); } // 3. 初始化对话消息数组 const messages = []; // 添加系统提示词(如果有) if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } // 4. 创建LLM客户端 const llmClient = new LLMClient(model); // 5. 生成多轮对话 let currentRound = 0; let userMessage = question.question; // 第一轮用户问题 while (currentRound < rounds) { // 添加用户消息 messages.push({ role: 'user', content: userMessage }); // 生成助手回复 const conversationHistory = messages.slice(); // 复制当前对话历史 const assistantResponse = await generateAssistantResponse( llmClient, conversationHistory, chunk.content, scenario, roleA, roleB, currentRound + 1, rounds, projectId, language ); // 添加助手消息 messages.push({ role: 'assistant', content: assistantResponse }); currentRound++; // 如果还需要更多轮对话,生成下一轮用户问题 if (currentRound < rounds) { const nextUserMessage = await generateNextUserMessage( llmClient, messages.slice(), chunk.content, scenario, roleA, roleB, currentRound + 1, rounds, projectId, language ); userMessage = nextUserMessage; } } // 6. 保存到数据库 const conversationData = { id: nanoid(), projectId, questionId, question: question.question, chunkId: question.chunkId, model: typeof model === 'string' ? model : model.modelName || 'unknown', questionLabel: question.label || '', scenario, roleA, roleB, turnCount: currentRound, maxTurns: rounds, rawMessages: JSON.stringify(messages), confirmed: false, score: 0, aiEvaluation: '', tags: '', note: `基于问题 "${question.question}" 生成的多轮对话` }; const result = await createDatasetConversation(conversationData); return { success: true, data: result }; } catch (error) { console.error('生成多轮对话失败:', error); return { success: false, error: error.message }; } } /** * 生成助手回复 */ async function generateAssistantResponse( llmClient, conversationHistory, chunkContent, scenario, roleA, roleB, currentRound, totalRounds, projectId, language ) { const prompt = await getAssistantReplyPrompt( language, { scenario, roleA, roleB, chunkContent, conversationHistory: formatConversationHistory(conversationHistory, roleA, roleB), currentRound, totalRounds }, projectId ); const response = await llmClient.getResponse(prompt); // 使用项目标准的JSON解析函数 const assistantReply = extractJsonFromLLMOutput(response); if (assistantReply && assistantReply.content) { return assistantReply.content; } else { console.warn('助手回复JSON解析失败,使用原始响应:', response); return response.trim(); } } /** * 生成下一轮用户问题 */ async function generateNextUserMessage( llmClient, conversationHistory, chunkContent, scenario, roleA, roleB, nextRound, totalRounds, projectId, language ) { const prompt = await getNextQuestionPrompt( language, { scenario, roleA, roleB, chunkContent, conversationHistory: formatConversationHistory(conversationHistory, roleA, roleB), nextRound, totalRounds }, projectId ); const response = await llmClient.getResponse(prompt); // 使用项目标准的JSON解析函数 const nextQuestion = extractJsonFromLLMOutput(response); if (nextQuestion && nextQuestion.question) { return nextQuestion.question; } else { console.warn('下一轮问题JSON解析失败,使用原始响应:', response); return response.trim(); } } /** * 格式化对话历史 */ function formatConversationHistory(messages, roleA, roleB) { return messages .filter(msg => msg.role !== 'system') .map(msg => { const roleName = msg.role === 'user' ? roleA : roleB; return `${roleName}: ${msg.content}`; }) .join('\n\n'); } /** * 批量生成多轮对话数据集 * @param {string} projectId - 项目ID * @param {Array} questionIds - 问题ID数组 * @param {object} config - 配置 * @param {Function} progressCallback - 进度回调 * @returns {Promise<{success: number, failed: number, results: Array}>} */ export async function batchGenerateMultiTurnConversations(projectId, questionIds, config, progressCallback) { const results = []; let successCount = 0; let failedCount = 0; for (let i = 0; i < questionIds.length; i++) { const questionId = questionIds[i]; try { const result = await generateMultiTurnConversation(projectId, questionId, config); if (result.success) { successCount++; results.push({ questionId, success: true, data: result.data }); } else { failedCount++; results.push({ questionId, success: false, error: result.error }); } } catch (error) { console.error(`生成多轮对话失败 ${questionId}:`, error); failedCount++; results.push({ questionId, success: false, error: error.message }); } // 调用进度回调 if (progressCallback) { await progressCallback(i + 1, questionIds.length); } // 添加小延迟避免API限流 if (i < questionIds.length - 1) { await new Promise(resolve => setTimeout(resolve, 500)); } } return { success: successCount, failed: failedCount, results }; } ================================================ FILE: lib/services/questions/index.js ================================================ import LLMClient from '@/lib/llm/core/index'; import { getQuestionPrompt } from '@/lib/llm/prompts/question'; import { getAddLabelPrompt } from '@/lib/llm/prompts/addLabel'; import { extractJsonFromLLMOutput } from '@/lib/llm/common/util'; import { getTaskConfig, getProject } from '@/lib/db/projects'; import { getTags } from '@/lib/db/tags'; import { getChunkById } from '@/lib/db/chunks'; import { saveQuestions, saveQuestionsWithGaPair } from '@/lib/db/questions'; import { getActiveGaPairsByFileId } from '@/lib/db/ga-pairs'; import logger from '@/lib/util/logger'; /** * 随机移除问题中的问号 * @param {Array} questions 问题列表 * @param {Number} probability 移除概率(0-100) * @returns {Array} 处理后的问题列表 */ function randomRemoveQuestionMark(questions, questionMaskRemovingProbability) { for (let i = 0; i < questions.length; i++) { // 去除问题结尾的空格 let question = questions[i].trimEnd(); if (Math.random() * 100 < questionMaskRemovingProbability && (question.endsWith('?') || question.endsWith('?'))) { question = question.slice(0, -1); } questions[i] = question; } return questions; } /** * 为指定文本块生成问题 * @param {String} projectId 项目ID * @param {String} chunkId 文本块ID * @param {Object} options 选项 * @param {String} options.model 模型名称 * @param {String} options.language 语言(中文/en) * @param {Number} options.number 问题数量(可选) * @returns {Promise} 生成结果 */ export async function generateQuestionsForChunk(projectId, chunkId, options) { try { const { model, language = '中文', number } = options; if (!model) { throw new Error('模型名称不能为空'); } // 并行获取文本块内容和项目配置 const [chunk, taskConfig, project] = await Promise.all([ getChunkById(chunkId), getTaskConfig(projectId), getProject(projectId) ]); if (!chunk) { throw new Error('文本块不存在'); } // 获取项目配置信息 const { questionGenerationLength, questionMaskRemovingProbability = 60 } = taskConfig; const { globalPrompt, questionPrompt } = project; // 创建LLM客户端 const llmClient = new LLMClient(model); // 生成问题的数量,如果未指定,则根据文本长度自动计算 const questionNumber = number || Math.floor(chunk.content.length / questionGenerationLength); // 生成问题提示词 const prompt = await getQuestionPrompt( language, { text: chunk.content, number: questionNumber, activeGaPair: primaryGaPair }, projectId ); const response = await llmClient.getResponse(prompt); // 从LLM输出中提取JSON格式的问题列表 const originalQuestions = extractJsonFromLLMOutput(response); const questions = randomRemoveQuestionMark(originalQuestions, questionMaskRemovingProbability); if (!questions || !Array.isArray(questions)) { throw new Error('生成问题失败'); } const tags = await getTags(projectId); const simplifiedTags = extractLabels(tags); const labelPrompt = await getAddLabelPrompt( language, { label: JSON.stringify(simplifiedTags), question: JSON.stringify(questions) }, projectId ); const labelResponse = await llmClient.getResponse(labelPrompt); const labelQuestions = extractJsonFromLLMOutput(labelResponse); // 保存问题到数据库 await saveQuestions(projectId, labelQuestions, chunkId); // 返回生成的问题 return { chunkId, labelQuestions, total: labelQuestions.length }; } catch (error) { logger.error('生成问题时出错:', error); throw error; } } function extractLabels(data) { if (!Array.isArray(data)) { return []; } return data.map(item => { const result = { label: item.label }; if (Array.isArray(item.child) && item.child.length > 0) { result.child = extractLabels(item.child); } return result; }); } /** * 为指定文本块生成问题(支持GA增强) * @param {String} projectId 项目ID * @param {String} chunkId 文本块ID * @param {Object} options 选项 * @param {String} options.model 模型名称 * @param {String} options.language 语言(中文/en) * @param {Number} options.number 问题数量(可选) * @param {Boolean} options.enableGaExpansion 是否启用GA扩展生成 * @returns {Promise} 生成结果 */ export async function generateQuestionsForChunkWithGA(projectId, chunkId, options) { try { const { model, language = '中文', number } = options; if (!model) { throw new Error('模型名称不能为空'); } // 并行获取文本块内容和项目配置 const [chunk, taskConfig] = await Promise.all([getChunkById(chunkId), getTaskConfig(projectId)]); if (!chunk) { throw new Error('文本块不存在'); } // 获取项目配置信息 const { questionGenerationLength, questionMaskRemovingProbability = 60 } = taskConfig; // 检查是否有可用的GA pairs并且启用GA扩展 let activeGaPairs = []; let useGaExpansion = false; if (chunk.fileId) { try { activeGaPairs = await getActiveGaPairsByFileId(chunk.fileId); useGaExpansion = activeGaPairs.length > 0; logger.info(`检查到 ${activeGaPairs.length} 个激活的GA pairs,${useGaExpansion ? '启用' : '不启用'}GA扩展生成`); } catch (error) { logger.warn(`获取GA pairs失败,使用标准生成: ${error.message}`); useGaExpansion = false; } } // 创建LLM客户端 const llmClient = new LLMClient(model); // 计算基础问题数量 const baseQuestionNumber = number || Math.floor(chunk.content.length / questionGenerationLength); let allGeneratedQuestions = []; let totalExpectedQuestions = baseQuestionNumber; if (useGaExpansion) { // GA扩展模式:为每个GA pair生成基础数量的问题 totalExpectedQuestions = baseQuestionNumber * activeGaPairs.length; logger.info( `GA扩展模式:将生成${baseQuestionNumber} 基础问题 × ${activeGaPairs.length} GA pairs = ${totalExpectedQuestions}个总问题` ); // 为每个GA pair生成问题 for (const gaPair of activeGaPairs) { const activeGaPair = { genre: `${gaPair.genreTitle}: ${gaPair.genreDesc}`, audience: `${gaPair.audienceTitle}: ${gaPair.audienceDesc}`, active: gaPair.isActive }; // 生成问题提示词 const prompt = await getQuestionPrompt( language, { text: chunk.content, number: baseQuestionNumber, activeGaPair: activeGaPair }, projectId ); const response = await llmClient.getResponse(prompt); const originalQuestions = extractJsonFromLLMOutput(response); const questions = randomRemoveQuestionMark(originalQuestions, questionMaskRemovingProbability); if (!questions || !Array.isArray(questions)) { logger.warn(`GA pair ${gaPair.genreTitle}+${gaPair.audienceTitle} 生成问题失败,跳过`); continue; } // 为这批问题添加标签 const tags = extractLabels(await getTags(projectId)); const labelPrompt = await getAddLabelPrompt( language, { label: JSON.stringify(tags), question: JSON.stringify(questions) }, projectId ); const labelResponse = await llmClient.getResponse(labelPrompt); const labelQuestions = extractJsonFromLLMOutput(labelResponse); // 保存问题到数据库(关联GA pair) await saveQuestionsWithGaPair(projectId, labelQuestions, chunkId, gaPair.id); allGeneratedQuestions.push( ...labelQuestions.map(q => ({ ...q, gaPairId: gaPair.id, gaPairInfo: `${gaPair.genreTitle}+${gaPair.audienceTitle}` })) ); logger.info(`GA pair ${gaPair.genreTitle}+${gaPair.audienceTitle} 生成了 ${labelQuestions.length} 个问题`); } } else { // 标准模式:使用原有逻辑 logger.info(`标准模式:生成 ${baseQuestionNumber} 个问题`); const prompt = await getQuestionPrompt( language, { text: chunk.content, number: baseQuestionNumber }, projectId ); const response = await llmClient.getResponse(prompt); const originalQuestions = extractJsonFromLLMOutput(response); const questions = randomRemoveQuestionMark(originalQuestions, questionMaskRemovingProbability); if (!questions || !Array.isArray(questions)) { throw new Error('生成问题失败'); } // 添加标签 const tags = extractLabels(await getTags(projectId)); const labelPrompt = await getAddLabelPrompt( language, { label: JSON.stringify(tags), question: JSON.stringify(questions) }, projectId ); const labelResponse = await llmClient.getResponse(labelPrompt); const labelQuestions = extractJsonFromLLMOutput(labelResponse); // 保存问题到数据库(不关联GA pair) await saveQuestions(projectId, labelQuestions, chunkId); allGeneratedQuestions = labelQuestions; } // 返回生成的问题 return { chunkId, questions: allGeneratedQuestions, total: allGeneratedQuestions.length, expectedTotal: totalExpectedQuestions, gaExpansionUsed: useGaExpansion, gaPairsCount: activeGaPairs.length }; } catch (error) { logger.error('GA增强问题生成时出错:', error); throw error; } } export default { generateQuestionsForChunk, generateQuestionsForChunkWithGA }; ================================================ FILE: lib/services/questions/template.js ================================================ /** * 问题模版服务 * 处理基于模版为数据源批量生成问题的逻辑 */ import { PrismaClient } from '@prisma/client'; import { getChunks } from '@/lib/db/chunks'; import { getImages, getImageChunk } from '@/lib/db/images'; import { saveQuestions } from '@/lib/db/questions'; const prisma = new PrismaClient(); /** * 根据问题模版为所有相关数据源创建问题 * @param {String} projectId 项目ID * @param {Object} template 问题模版对象 * @returns {Promise} 生成结果统计 */ export async function generateQuestionsFromTemplate(projectId, template) { const { sourceType } = template; let successCount = 0; let failCount = 0; const errors = []; try { if (sourceType === 'text') { // 为所有文本块生成问题 const result = await generateQuestionsForTextChunks(projectId, template); successCount += result.successCount; failCount += result.failCount; errors.push(...result.errors); } else if (sourceType === 'image') { // 为所有图片生成问题 const result = await generateQuestionsForImages(projectId, template); successCount += result.successCount; failCount += result.failCount; errors.push(...result.errors); } return { success: true, successCount, failCount, errors, message: `成功为 ${successCount} 个数据源创建问题,${failCount} 个失败` }; } catch (error) { console.error('生成问题失败:', error); return { success: false, successCount, failCount, errors: [...errors, error.message], message: '生成问题过程中发生错误' }; } } /** * 为所有文本块生成问题 * @param {String} projectId 项目ID * @param {Object} template 问题模版 * @param {Boolean} onlyNew 是否只为新的数据源创建(编辑模式) * @returns {Promise} 生成结果 */ async function generateQuestionsForTextChunks(projectId, template, onlyNew = false) { let successCount = 0; let failCount = 0; const errors = []; try { // 获取项目下所有文本块 const chunks = await prisma.chunks.findMany({ where: { projectId }, select: { id: true } }); let targetChunks = chunks; // 编辑模式:只为还未创建此模板问题的文本块创建 if (onlyNew && template.id) { targetChunks = []; for (const chunk of chunks) { const existingQuestion = await prisma.questions.findFirst({ where: { projectId, chunkId: chunk.id, templateId: template.id } }); if (!existingQuestion) { targetChunks.push(chunk); } } } // 为每个文本块创建问题 if (targetChunks.length > 0) { await saveQuestions( projectId, targetChunks.map(chunk => ({ question: template.question, chunkId: chunk.id, templateId: template.id, label: '' })) ); successCount = targetChunks.length; } return { successCount, failCount, errors }; } catch (error) { console.error('获取文本块失败:', error); return { successCount, failCount, errors: [...errors, `获取文本块失败: ${error.message}`] }; } } /** * 为所有图片生成问题 * @param {String} projectId 项目ID * @param {Object} template 问题模版 * @param {Boolean} onlyNew 是否只为新的数据源创建(编辑模式) * @returns {Promise} 生成结果 */ async function generateQuestionsForImages(projectId, template, onlyNew = false) { let successCount = 0; let failCount = 0; const errors = []; try { // 获取项目下所有图片 const images = await prisma.images.findMany({ where: { projectId }, select: { id: true, imageName: true } }); const chunk = await getImageChunk(projectId); // 为每个图片创建问题 for (const image of images) { try { // 编辑模式:检查是否已经创建过此模板的问题 if (onlyNew && template.id) { const existingQuestion = await prisma.questions.findFirst({ where: { projectId, imageId: image.id, templateId: template.id } }); if (existingQuestion) { continue; // 跳过已存在的 } } // 创建图片问题,使用imageId而不是chunkId await prisma.questions.create({ data: { projectId, question: template.question, imageId: image.id, imageName: image.imageName, templateId: template.id, label: 'image', chunkId: chunk.id } }); successCount++; } catch (error) { console.error(`为图片 ${image.id} 创建问题失败:`, error); failCount++; errors.push(`图片 ${image.imageName || image.id}: ${error.message}`); } } return { successCount, failCount, errors }; } catch (error) { console.error('获取图片失败:', error); return { successCount, failCount, errors: [...errors, `获取图片失败: ${error.message}`] }; } } /** * 编辑模式:为还未创建此模板问题的数据源生成问题 * @param {String} projectId 项目ID * @param {Object} template 问题模版对象 * @returns {Promise} 生成结果统计 */ export async function generateQuestionsFromTemplateEdit(projectId, template) { const { sourceType } = template; let successCount = 0; let failCount = 0; const errors = []; try { if (sourceType === 'text') { const result = await generateQuestionsForTextChunks(projectId, template, true); successCount += result.successCount; failCount += result.failCount; errors.push(...result.errors); } else if (sourceType === 'image') { const result = await generateQuestionsForImages(projectId, template, true); successCount += result.successCount; failCount += result.failCount; errors.push(...result.errors); } return { success: true, successCount, failCount, errors, message: `成功为 ${successCount} 个数据源创建问题,${failCount} 个失败` }; } catch (error) { console.error('生成问题失败:', error); return { success: false, successCount, failCount, errors: [...errors, error.message], message: '生成问题过程中发生错误' }; } } /** * 检查模版是否可以生成问题 * @param {String} projectId 项目ID * @param {String} sourceType 数据源类型 * @returns {Promise} 检查结果 */ export async function checkTemplateGenerationAvailability(projectId, sourceType) { try { let count = 0; if (sourceType === 'text') { const chunks = await getChunks(projectId, 1, 1); count = chunks.total || 0; } else if (sourceType === 'image') { const images = await getImages(projectId, 1, 1); count = images.total || 0; } return { available: count > 0, count, message: count > 0 ? `找到 ${count} 个${sourceType === 'text' ? '文本块' : '图片'},可以生成问题` : `项目中没有${sourceType === 'text' ? '文本块' : '图片'},无法生成问题` }; } catch (error) { console.error('检查数据源可用性失败:', error); return { available: false, count: 0, message: '检查数据源时发生错误' }; } } export default { generateQuestionsFromTemplate, generateQuestionsFromTemplateEdit, checkTemplateGenerationAvailability }; ================================================ FILE: lib/services/tasks/answer-generation.js ================================================ /** * 答案生成任务处理器 * 负责异步处理答案生成任务,获取所有未生成答案的问题并批量处理 */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import datasetService from '@/lib/services/datasets'; import { getTaskConfig } from '@/lib/db/projects'; const prisma = new PrismaClient(); /** * 处理答案生成任务 * 查询未生成答案的问题并批量处理 * @param {Object} task 任务对象 * @returns {Promise} */ export async function processAnswerGenerationTask(task) { try { console.log(`Starting answer generation task: ${task.id}`); // 解析模型信息 let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } // 从任务对象直接获取项目 ID const projectId = task.projectId; // 1. 查询未生成答案的问题 console.log(`Starting answer generation for project ${projectId}`); const questionsWithoutAnswers = await prisma.questions.findMany({ where: { projectId, answered: false, // 未生成答案的问题 imageId: null } }); // 如果没有需要处理的问题,直接完成任务 if (questionsWithoutAnswers.length === 0) { await updateTask(task.id, { status: 1, // 1 表示完成 detail: 'No questions to process', note: '', endTime: new Date() }); return; } // 获取任务配置,包括并发限制 const taskConfig = await getTaskConfig(projectId); const concurrencyLimit = taskConfig.concurrencyLimit || 3; // 更新任务总数 const totalCount = questionsWithoutAnswers.length; await updateTask(task.id, { totalCount, detail: `Questions to process: ${totalCount}`, note: '' }); // 2. 批量处理每个问题 let successCount = 0; let errorCount = 0; let totalDatasets = 0; let latestTaskStatus = 0; const errorList = []; // 单个问题处理函数 const processQuestion = async question => { try { // 如果任务已经被标记为失败或已中断,不再继续处理 const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } // 调用数据集生成服务生成答案 const result = await datasetService.generateDatasetForQuestion(task.projectId, question.id, { model: modelInfo, language: task.language === 'zh-CN' ? '中文' : 'en' }); console.log(`Answer generated for question ${question.id}, dataset ID: ${result.dataset.id}`); // 增加成功计数 successCount++; totalDatasets++; // 更新任务进度 const progressNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, datasets generated: ${totalDatasets}`; await updateTask(task.id, { completedCount: successCount + errorCount, detail: progressNote, note: progressNote }); return { success: true, questionId: question.id, datasetId: result.dataset.id }; } catch (error) { console.error(`Error processing question ${question.id}:`, error); errorCount++; const errorMessage = error?.message || String(error); errorList.push({ questionId: question.id, error: errorMessage }); // 更新任务进度 const progressNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, datasets generated: ${totalDatasets}`; await updateTask(task.id, { completedCount: successCount + errorCount, detail: `${progressNote}, latest error: ${errorMessage}`, note: progressNote }); return { success: false, questionId: question.id, error: errorMessage }; } }; // 并行处理所有问题,使用任务设置中的并发限制 await processInParallel(questionsWithoutAnswers, processQuestion, concurrencyLimit, async (completed, total) => { console.log(`Answer generation progress: ${completed}/${total}`); }); if (!latestTaskStatus) { // 任务完成,更新状态 const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; // 如果全部失败,标记为失败;否则标记为完成 const errorSummary = errorList .slice(0, 3) .map(item => `[${item.questionId}] ${item.error}`) .join(' | '); const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, datasets generated: ${totalDatasets}${errorSummary ? `, errors: ${errorSummary}` : ''}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: errorList.length ? JSON.stringify({ errors: errorList.slice(0, 20) }) : '', note: finalNote, endTime: new Date() }); } console.log(`Task completed: ${task.id}`); } catch (error) { console.error('Answer generation task error:', error); await updateTask(task.id, { status: 2, // 2 表示失败 detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}`, endTime: new Date() }); } } export default { processAnswerGenerationTask }; ================================================ FILE: lib/services/tasks/data-cleaning.js ================================================ /** * Data cleaning task processor */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { getTaskConfig } from '@/lib/db/projects'; import { cleanDataForChunk } from '@/lib/services/clean'; const prisma = new PrismaClient(); function parseTaskChunkIds(note) { if (!note) return []; try { const parsed = typeof note === 'string' ? JSON.parse(note) : note; if (!Array.isArray(parsed?.chunkIds)) return []; return [...new Set(parsed.chunkIds.map(id => String(id)).filter(Boolean))]; } catch { return []; } } export async function processDataCleaningTask(task) { try { console.log(`Starting data cleaning task: ${task.id}`); let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } const taskConfig = await getTaskConfig(task.projectId); const concurrencyLimit = taskConfig?.concurrencyLimit || 2; const targetChunkIds = parseTaskChunkIds(task.note); const chunkWhere = { projectId: task.projectId, NOT: { name: { in: ['Image Chunk', 'Distilled Content'] } } }; if (targetChunkIds.length > 0) { chunkWhere.id = { in: targetChunkIds }; } const chunks = await prisma.chunks.findMany({ where: chunkWhere }); if (chunks.length === 0) { await updateTask(task.id, { status: 1, completedCount: 0, totalCount: 0, note: 'No chunks require cleaning' }); return; } const totalCount = chunks.length; await updateTask(task.id, { totalCount, detail: `Chunks to process: ${totalCount}` }); let successCount = 0; let errorCount = 0; let totalOriginalLength = 0; let totalCleanedLength = 0; let latestTaskStatus = 0; const processChunk = async chunk => { try { const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } const result = await cleanDataForChunk(task.projectId, chunk.id, { model: modelInfo, language: task.language === 'zh-CN' ? '中文' : 'en' }); successCount++; totalOriginalLength += result.originalLength; totalCleanedLength += result.cleanedLength; await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, total original length: ${totalOriginalLength}, total cleaned length: ${totalCleanedLength}` }); return { success: true, chunkId: chunk.id, result }; } catch (error) { errorCount++; await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, total original length: ${totalOriginalLength}, total cleaned length: ${totalCleanedLength}` }); return { success: false, chunkId: chunk.id, error: error.message }; } }; await processInParallel(chunks, processChunk, concurrencyLimit); if (!latestTaskStatus) { const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, total original length: ${totalOriginalLength}, total cleaned length: ${totalCleanedLength}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: '', note: finalNote, endTime: new Date() }); } console.log(`Data cleaning task completed: ${task.id}`); } catch (error) { console.error(`Data cleaning task failed: ${task.id}`, error); await updateTask(task.id, { status: 2, detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}` }); } } ================================================ FILE: lib/services/tasks/data-distillation.js ================================================ /** * 数据蒸馏任务处理器 * 负责异步处理全自动蒸馏任务 */ import { PrismaClient } from '@prisma/client'; import { updateTask } from './index'; import { getTaskConfig } from '@/lib/db/projects'; import axios from 'axios'; const prisma = new PrismaClient(); /** * 处理数据蒸馏任务 * @param {Object} task 任务对象 * @returns {Promise} */ export async function processDataDistillationTask(task) { try { console.log(`Starting data distillation task: ${task.id}`); // 解析任务配置 let taskNote; try { taskNote = JSON.parse(task.note); } catch (error) { throw new Error(`Failed to parse task config: ${error.message}`); } // 解析模型信息 let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } const { topic, levels, tagsPerLevel, questionsPerTag, datasetType = 'single-turn', estimatedTags, estimatedQuestions } = taskNote; const projectId = task.projectId; const language = task.language || 'zh'; // 获取项目配置 const taskConfig = await getTaskConfig(projectId); const concurrencyLimit = taskConfig?.concurrencyLimit || 5; // 初始化进度统计 let progress = { stage: 'initializing', tagsTotal: estimatedTags || 0, tagsBuilt: 0, questionsTotal: estimatedQuestions || 0, questionsBuilt: 0, datasetsTotal: estimatedQuestions || 0, datasetsBuilt: 0, multiTurnDatasetsTotal: datasetType === 'multi-turn' || datasetType === 'both' ? estimatedQuestions : 0, multiTurnDatasetsBuilt: 0 }; // 更新任务初始状态 await updateTask(task.id, { totalCount: estimatedQuestions, detail: `Starting tag tree build. Levels: ${levels}, tags per level: ${tagsPerLevel}, questions per tag: ${questionsPerTag}` }); console.log( `[Data distillation task ${task.id}] Starting tag tree build. Levels: ${levels}, tags/level: ${tagsPerLevel}, questions/tag: ${questionsPerTag}` ); // 阶段1: 构建标签树 await buildTagTree({ taskId: task.id, projectId, topic, levels, tagsPerLevel, model: modelInfo, language, progress, concurrencyLimit }); // 阶段2: 生成问题 await generateQuestionsForTags({ taskId: task.id, projectId, levels, questionsPerTag, model: modelInfo, language, progress, concurrencyLimit }); // 阶段3: 生成数据集 if (datasetType === 'single-turn' || datasetType === 'both') { await generateDatasetsForQuestions({ taskId: task.id, projectId, model: modelInfo, language, progress, concurrencyLimit }); } // 阶段4: 生成多轮对话数据集 if (datasetType === 'multi-turn' || datasetType === 'both') { await generateMultiTurnDatasetsForQuestions({ taskId: task.id, projectId, model: modelInfo, language, progress, concurrencyLimit }); } // 任务完成 await updateTask(task.id, { status: 1, completedCount: progress.datasetsBuilt + progress.multiTurnDatasetsBuilt, detail: `Distillation completed: tags ${progress.tagsBuilt}/${progress.tagsTotal}, questions ${progress.questionsBuilt}/${progress.questionsTotal}, single-turn datasets ${progress.datasetsBuilt}/${progress.datasetsTotal}, multi-turn datasets ${progress.multiTurnDatasetsBuilt}/${progress.multiTurnDatasetsTotal}`, endTime: new Date() }); console.log(`Data distillation task completed: ${task.id}`); } catch (error) { console.error('Data distillation task error:', error); await updateTask(task.id, { status: 2, detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}`, endTime: new Date() }); } } /** * 构建标签树 */ async function buildTagTree({ taskId, projectId, topic, levels, tagsPerLevel, model, language, progress, concurrencyLimit }) { console.log(`[Task ${taskId}] Starting tag tree build (levels: ${levels}, tags/level: ${tagsPerLevel})`); // 更新任务状态 await updateTask(taskId, { detail: `Building tag tree (levels: ${levels})` }); // 获取项目名称作为根标签 let projectName = topic; try { const projectResponse = await axios.get(`http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}`); if (projectResponse && projectResponse.data && projectResponse.data.name) { projectName = projectResponse.data.name; console.log(`[Task ${taskId}] Using project name as root tag: "${projectName}"`); } } catch (error) { console.warn(`[Task ${taskId}] Failed to fetch project name, using topic as default: ${error.message}`); } // 递归构建标签树 const buildTagsForLevel = async (parentTag = null, parentTagPath = '', level = 1) => { // 检查任务是否被中断 const latestTask = await prisma.task.findUnique({ where: { id: taskId } }); if (latestTask.status === 2 || latestTask.status === 3) { throw new Error('Task was interrupted'); } if (level > levels) return; // 更新阶段 await updateTask(taskId, { detail: `Building level ${level} tags...` }); // 获取当前层级已有标签 let currentLevelTags = []; try { const response = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/distill/tags/all` ); if (parentTag) { currentLevelTags = response.data.filter(tag => tag.parentId === parentTag.id); } else { currentLevelTags = response.data.filter(tag => !tag.parentId); } } catch (error) { console.error(`[Task ${taskId}] Failed to fetch level ${level} tags:`, error.message); return; } // 计算需要创建的标签数量 const needToCreate = Math.max(0, tagsPerLevel - currentLevelTags.length); if (needToCreate > 0) { const parentTagName = level === 1 ? topic : parentTag?.label || ''; let tagPathWithProjectName; if (level === 1) { tagPathWithProjectName = projectName; } else { if (!parentTagPath) { tagPathWithProjectName = projectName; } else if (!parentTagPath.startsWith(projectName)) { tagPathWithProjectName = `${projectName} > ${parentTagPath}`; } else { tagPathWithProjectName = parentTagPath; } } try { const response = await axios.post( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/distill/tags`, { parentTag: parentTagName, parentTagId: parentTag ? parentTag.id : null, tagPath: tagPathWithProjectName || parentTagName, count: needToCreate, model, language } ); // 更新进度 progress.tagsBuilt += response.data.length; await updateTask(taskId, { detail: `Created ${progress.tagsBuilt}/${progress.tagsTotal} tags (level ${level})` }); currentLevelTags = [...currentLevelTags, ...response.data]; } catch (error) { console.error(`[Task ${taskId}] Failed to create level ${level} tags:`, error.message); } } // 递归构建下一层 if (level < levels) { for (const tag of currentLevelTags) { let tagPath; if (parentTagPath) { tagPath = `${parentTagPath} > ${tag.label}`; } else { tagPath = `${projectName} > ${tag.label}`; } await buildTagsForLevel(tag, tagPath, level + 1); } } }; // 从第一层开始构建 await buildTagsForLevel(); console.log(`[Task ${taskId}] Tag tree build completed: ${progress.tagsBuilt}/${progress.tagsTotal}`); } /** * 为标签生成问题 */ async function generateQuestionsForTags({ taskId, projectId, levels, questionsPerTag, model, language, progress, concurrencyLimit }) { console.log(`[Task ${taskId}] Starting question generation`); await updateTask(taskId, { detail: 'Generating questions for leaf tags...' }); try { // 获取项目名称 let projectName = ''; try { const projectResponse = await axios.get(`http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}`); if (projectResponse && projectResponse.data && projectResponse.data.name) { projectName = projectResponse.data.name; } } catch (error) { console.warn(`[Task ${taskId}] Failed to fetch project name: ${error.message}`); } // 获取所有标签 const response = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/distill/tags/all` ); const allTags = response.data; // 找出所有叶子标签 const childrenMap = {}; allTags.forEach(tag => { if (tag.parentId) { if (!childrenMap[tag.parentId]) { childrenMap[tag.parentId] = []; } childrenMap[tag.parentId].push(tag); } }); const leafTags = allTags.filter(tag => !childrenMap[tag.id] && getTagDepth(tag, allTags) === levels); // 获取所有问题 const questionsResponse = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/questions/tree?isDistill=true` ); const allQuestions = questionsResponse.data; // 更新总问题数量 progress.questionsTotal = leafTags.length * questionsPerTag; // 准备生成任务 const generateTasks = []; for (const tag of leafTags) { const tagPath = getTagPath(tag, allTags, projectName); const existingQuestions = allQuestions.filter(q => q.label === tag.label); const needToCreate = Math.max(0, questionsPerTag - existingQuestions.length); if (needToCreate > 0) { generateTasks.push({ tag, tagPath, needToCreate }); } } console.log(`[Task ${taskId}] Generating ${progress.questionsTotal} questions for ${generateTasks.length} tags`); // 分批并发生成问题 for (let i = 0; i < generateTasks.length; i += concurrencyLimit) { // 检查任务是否被中断 const latestTask = await prisma.task.findUnique({ where: { id: taskId } }); if (latestTask.status === 2 || latestTask.status === 3) { throw new Error('Task was interrupted'); } const batch = generateTasks.slice(i, i + concurrencyLimit); await Promise.all( batch.map(async ({ tag, tagPath, needToCreate }) => { try { const response = await axios.post( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/distill/questions`, { tagPath, currentTag: tag.label, tagId: tag.id, count: needToCreate, model, language } ); progress.questionsBuilt += response.data.length; await updateTask(taskId, { detail: `[Data distillation task ${taskId}] Generated ${progress.questionsBuilt}/${progress.questionsTotal} questions` }); console.log( `[Data distillation task ${taskId}] Generated ${progress.questionsBuilt}/${progress.questionsTotal} questions` ); } catch (error) { console.error(`[Task ${taskId}] Failed to generate questions for tag "${tag.label}":`, error.message); } }) ); } } catch (error) { console.error(`[Task ${taskId}] Question generation failed:`, error.message); throw error; } console.log(`[Task ${taskId}] Question generation completed: ${progress.questionsBuilt}/${progress.questionsTotal}`); } /** * 为问题生成数据集 */ async function generateDatasetsForQuestions({ taskId, projectId, model, language, progress, concurrencyLimit }) { console.log(`[Task ${taskId}] Starting single-turn dataset generation`); await updateTask(taskId, { detail: 'Generating single-turn datasets...' }); try { // 获取所有问题 const response = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/questions/tree?isDistill=true` ); const allQuestions = response.data; // 找出未回答的问题 const unansweredQuestions = allQuestions.filter(q => !q.answered); const answeredQuestions = allQuestions.filter(q => q.answered); // 更新数据集总数和已生成数量 progress.datasetsTotal = allQuestions.length; progress.datasetsBuilt = answeredQuestions.length; if (unansweredQuestions.length === 0) { console.log(`[Task ${taskId}] All questions already have answers; skipping dataset generation`); return; } console.log(`[Task ${taskId}] Generating answers for ${unansweredQuestions.length} questions`); // 分批并发生成数据集 for (let i = 0; i < unansweredQuestions.length; i += concurrencyLimit) { // 检查任务是否被中断 const latestTask = await prisma.task.findUnique({ where: { id: taskId } }); if (latestTask.status === 2 || latestTask.status === 3) { throw new Error('Task was interrupted'); } const batch = unansweredQuestions.slice(i, i + concurrencyLimit); await Promise.all( batch.map(async question => { try { await axios.post(`http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/datasets`, { projectId, questionId: question.id, model, language: language || 'zh-CN' }); progress.datasetsBuilt++; await updateTask(taskId, { completedCount: progress.datasetsBuilt, detail: `[Data distillation task ${taskId}] Generated ${progress.datasetsBuilt}/${progress.datasetsTotal} single-turn datasets` }); console.log(`Generated ${progress.datasetsBuilt}/${progress.datasetsTotal} single-turn datasets`); } catch (error) { console.error(`[Task ${taskId}] Failed to generate dataset for question "${question.id}":`, error.message); } }) ); } } catch (error) { console.error(`[Task ${taskId}] Dataset generation failed:`, error.message); throw error; } console.log( `[Task ${taskId}] Single-turn dataset generation completed: ${progress.datasetsBuilt}/${progress.datasetsTotal}` ); } /** * 为问题生成多轮对话数据集 */ async function generateMultiTurnDatasetsForQuestions({ taskId, projectId, model, language, progress, concurrencyLimit }) { console.log(`[Task ${taskId}] Starting multi-turn dataset generation`); await updateTask(taskId, { detail: 'Generating multi-turn datasets...' }); try { // 获取项目的多轮对话配置 const configResponse = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/tasks` ); const taskConfig = configResponse.data; const multiTurnConfig = { systemPrompt: taskConfig.multiTurnSystemPrompt || '', scenario: taskConfig.multiTurnScenario || '', rounds: taskConfig.multiTurnRounds || 3, roleA: taskConfig.multiTurnRoleA || '', roleB: taskConfig.multiTurnRoleB || '' }; // 检查配置 if (!multiTurnConfig.scenario || !multiTurnConfig.roleA || !multiTurnConfig.roleB || !multiTurnConfig.rounds) { console.error(`[Task ${taskId}] Project is missing multi-turn config; skipping multi-turn generation`); throw new Error('Project is missing multi-turn config'); } // 获取所有已回答的问题 const response = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/questions/tree?isDistill=true` ); const answeredQuestions = response.data; // 获取已生成多轮对话的问题ID const conversationsResponse = await axios.get( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/dataset-conversations?pageSize=1000` ); const existingConversationIds = new Set( (conversationsResponse.data.conversations || []).map(conv => conv.questionId) ); // 筛选需要生成多轮对话的问题 const questionsForMultiTurn = answeredQuestions.filter(q => !existingConversationIds.has(q.id)); // 更新多轮对话数据集总数和已生成数量 progress.multiTurnDatasetsTotal = answeredQuestions.length; progress.multiTurnDatasetsBuilt = answeredQuestions.length - questionsForMultiTurn.length; if (questionsForMultiTurn.length === 0) { console.log(`[Task ${taskId}] All questions already have multi-turn conversations; skipping`); return; } console.log(`[Task ${taskId}] Generating multi-turn conversations for ${questionsForMultiTurn.length} questions`); // 分批并发生成 (并发数更低) const multiTurnConcurrency = Math.min(concurrencyLimit, 2); for (let i = 0; i < questionsForMultiTurn.length; i += multiTurnConcurrency) { // 检查任务是否被中断 const latestTask = await prisma.task.findUnique({ where: { id: taskId } }); if (latestTask.status === 2 || latestTask.status === 3) { throw new Error('Task was interrupted'); } const batch = questionsForMultiTurn.slice(i, i + multiTurnConcurrency); await Promise.all( batch.map(async question => { try { await axios.post( `http://localhost:${process.env.PORT || 1717}/api/projects/${projectId}/dataset-conversations`, { questionId: question.id, ...multiTurnConfig, model, language } ); progress.multiTurnDatasetsBuilt++; await updateTask(taskId, { completedCount: progress.multiTurnDatasetsBuilt, detail: `Generated ${progress.multiTurnDatasetsBuilt}/${progress.multiTurnDatasetsTotal} multi-turn datasets` }); console.log( `Generated ${progress.multiTurnDatasetsBuilt}/${progress.multiTurnDatasetsTotal} multi-turn datasets` ); } catch (error) { console.error( `[Task ${taskId}] Failed to generate multi-turn conversation for question "${question.id}":`, error.message ); } }) ); } } catch (error) { console.error(`[Task ${taskId}] Multi-turn dataset generation failed:`, error.message); throw error; } console.log( `[Task ${taskId}] Multi-turn dataset generation completed: ${progress.multiTurnDatasetsBuilt}/${progress.multiTurnDatasetsTotal}` ); } /** * 获取标签深度 */ function getTagDepth(tag, allTags) { let depth = 1; let currentTag = tag; while (currentTag.parentId) { depth++; currentTag = allTags.find(t => t.id === currentTag.parentId); if (!currentTag) break; } return depth; } /** * 获取标签路径 */ function getTagPath(tag, allTags, projectName = '') { const path = []; let currentTag = tag; while (currentTag) { path.unshift(currentTag.label); if (currentTag.parentId) { currentTag = allTags.find(t => t.id === currentTag.parentId); } else { currentTag = null; } } // 如果有项目名称且路径不以项目名称开头,则添加 if (projectName && path.length > 0 && path[0] !== projectName) { path.unshift(projectName); } return path.join(' > '); } export default { processDataDistillationTask }; ================================================ FILE: lib/services/tasks/dataset-evaluation.js ================================================ /** * 数据集评估任务处理器 * 处理批量数据集质量评估的异步任务 */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { getDatasetsByPagination } from '@/lib/db/datasets'; import { evaluateDataset } from '@/lib/services/datasets/evaluation'; import { getTaskConfig } from '@/lib/db/projects'; import { TASK } from '@/constant'; const prisma = new PrismaClient(); /** * 处理数据集评估任务 * @param {object} task - 任务对象 */ export async function processDatasetEvaluationTask(task) { const { id: taskId, projectId, modelInfo, language } = task; try { console.log(`Starting dataset evaluation task: ${taskId}`); // 更新任务状态为处理中 await updateTask(taskId, { status: TASK.STATUS.PROCESSING, startTime: new Date().toISOString() }); // 解析模型信息 const model = typeof modelInfo === 'string' ? JSON.parse(modelInfo) : modelInfo; if (!model || !model.modelName) { throw new Error('Model config is incomplete'); } // 1. 查找所有未评估的数据集(score为0或null的数据集) console.log(`Searching unevaluated datasets in project ${projectId}...`); const unevaluatedDatasets = []; let page = 1; const pageSize = 2000; let hasMore = true; while (hasMore) { const response = await getDatasetsByPagination(projectId, page, pageSize, { // 不传递任何筛选条件,获取所有数据集 }); console.log(`Fetched page ${page}, total ${response.data?.length || 0} datasets`); if (response.data && response.data.length > 0) { // 在内存中筛选未评估的数据集 const unscored = response.data.filter( dataset => !dataset.score || dataset.score === 0 || !dataset.aiEvaluation ); unevaluatedDatasets.push(...unscored); page++; hasMore = response.data.length === pageSize; } else { hasMore = false; } } console.log(`Found ${unevaluatedDatasets.length} unevaluated datasets`); if (unevaluatedDatasets.length === 0) { await updateTask(taskId, { status: TASK.STATUS.COMPLETED, endTime: new Date().toISOString(), completedCount: 0, totalCount: 0, note: 'No datasets require evaluation' }); return; } // 获取任务配置,包括并发限制 const taskConfig = await getTaskConfig(projectId); const concurrencyLimit = taskConfig.concurrencyLimit || 5; // 更新任务总数 const totalCount = unevaluatedDatasets.length; await updateTask(taskId, { totalCount, detail: `Datasets to evaluate: ${totalCount}`, note: '' }); // 2. 批量处理每个数据集 let successCount = 0; let errorCount = 0; let latestTaskStatus = 0; // 单个数据集处理函数 const processDataset = async dataset => { try { // 如果任务已经被标记为失败或已中断,不再继续处理 const latestTask = await prisma.task.findUnique({ where: { id: taskId } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } // 调用数据集评估服务 const result = await evaluateDataset(projectId, dataset.id, model, language); if (result.success) { console.log( `Dataset ${dataset.id} evaluated. Score: ${result.data.score}, progress: ${successCount + errorCount}/${totalCount}` ); successCount++; } else { console.error(`Failed to evaluate dataset ${dataset.id}:`, result.error); errorCount++; } // 更新任务进度 const progressNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}`; await updateTask(taskId, { completedCount: successCount + errorCount, detail: progressNote, note: progressNote }); return { success: result.success, datasetId: dataset.id, ...result }; } catch (error) { console.error(`Error processing dataset ${dataset.id}:`, error); errorCount++; // 更新任务进度 const progressNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}`; await updateTask(taskId, { completedCount: successCount + errorCount, detail: progressNote, note: progressNote }); return { success: false, datasetId: dataset.id, error: error.message }; } }; // 并行处理所有数据集,使用任务设置中的并发限制 await processInParallel(unevaluatedDatasets, processDataset, concurrencyLimit, async (completed, total) => {}); const evaluationResults = { success: successCount, failed: errorCount, results: [] // 简化结果存储 }; // 3. 更新任务完成状态 if (!latestTaskStatus) { // 如果任务没有被中断,根据处理结果更新状态 const finalStatus = errorCount === 0 ? TASK.STATUS.COMPLETED : TASK.STATUS.FAILED; const endTime = new Date().toISOString(); const note = `Evaluation completed: ${successCount} succeeded, ${errorCount} failed`; await updateTask(taskId, { status: finalStatus, endTime, completedCount: successCount + errorCount, note, detail: `Total: ${totalCount}, succeeded: ${successCount}, failed: ${errorCount}` }); console.log(`Dataset evaluation task completed: ${taskId}, ${note}`); } } catch (error) { console.error(`Dataset evaluation task failed: ${taskId}`, error); // 更新任务为失败状态 await updateTask(taskId, { status: TASK.STATUS.FAILED, endTime: new Date().toISOString(), note: `Evaluation failed: ${error.message}` }); throw error; } } ================================================ FILE: lib/services/tasks/eval-generation.js ================================================ /** * 评估数据集批量生成任务处理服务 */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { getTaskConfig } from '@/lib/db/projects'; import { generateEvalQuestionsForChunk } from '@/lib/services/eval'; const prisma = new PrismaClient(); /** * 处理评估数据集批量生成任务 * @param {object} task - 任务对象 * @returns {Promise} */ export async function processEvalGenerationTask(task) { try { console.log(`Starting eval dataset generation task: ${task.id}`); // 解析模型信息 let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } // 获取项目配置 const taskConfig = await getTaskConfig(task.projectId); const concurrencyLimit = taskConfig?.concurrencyLimit || 2; // 1. 查询所有还没有生成评估题目的文本块 // 先获取所有文本块 const allChunks = await prisma.chunks.findMany({ where: { projectId: task.projectId, // 过滤掉特殊文本块 NOT: { name: { in: ['Image Chunk', 'Distilled Content'] } } } }); if (allChunks.length === 0) { console.log(`No chunks available for eval question generation in project ${task.projectId}`); await updateTask(task.id, { status: 1, completedCount: 0, totalCount: 0, note: 'No chunks available for eval question generation' }); return; } // 查询已经生成过评估题目的文本块ID const chunksWithEval = await prisma.evalDatasets.findMany({ where: { projectId: task.projectId }, select: { chunkId: true }, distinct: ['chunkId'] }); const chunkIdsWithEval = new Set(chunksWithEval.map(item => item.chunkId).filter(Boolean)); // 过滤出还没有生成评估题目的文本块 const chunks = allChunks.filter(chunk => !chunkIdsWithEval.has(chunk.id)); if (chunks.length === 0) { console.log(`All chunks already have eval questions for project ${task.projectId}`); await updateTask(task.id, { status: 1, completedCount: 0, totalCount: 0, note: 'All chunks already have eval questions' }); return; } // 更新任务总数 const totalCount = chunks.length; await updateTask(task.id, { totalCount, detail: `Chunks to process: ${totalCount}` }); // 2. 批量处理每个文本块 let successCount = 0; let errorCount = 0; let totalQuestionsGenerated = 0; let latestTaskStatus = 0; // 单个文本块处理函数 const processChunk = async chunk => { try { // 如果任务已经被标记为失败或已中断,不再继续处理 const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } const result = await generateEvalQuestionsForChunk(task.projectId, chunk.id, { model: modelInfo, language: task.language || 'zh-CN' }); console.log( `Chunk ${chunk.id} eval questions generated. Count: ${result.total}, breakdown: ${JSON.stringify(result.breakdown)}` ); // 增加成功计数 successCount++; totalQuestionsGenerated += result.total || 0; // 更新任务进度 await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestionsGenerated}` }); return { success: true, chunkId: chunk.id, result }; } catch (error) { console.error(`Error processing chunk ${chunk.id}:`, error); errorCount++; // 更新任务进度 await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestionsGenerated}` }); return { success: false, chunkId: chunk.id, error: error.message }; } }; // 并行处理所有文本块,使用任务设置中的并发限制 await processInParallel(chunks, processChunk, concurrencyLimit, async (completed, total) => { console.log(`Eval dataset generation progress: ${completed}/${total}`); }); if (!latestTaskStatus) { // 任务完成,更新状态 const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; // 如果全部失败,标记为失败;否则标记为完成 const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestionsGenerated}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: '', note: finalNote, endTime: new Date() }); } console.log(`Eval dataset generation task completed: ${task.id}`); } catch (error) { console.error(`Eval dataset generation task failed: ${task.id}`, error); await updateTask(task.id, { status: 2, detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}` }); } } ================================================ FILE: lib/services/tasks/file-processing.js ================================================ /** * 文件处理任务 */ import { splitProjectFile } from '@/lib/file/text-splitter'; import { handleDomainTree } from '@/lib/util/domain-tree'; import { processPdf, getFilePageCount } from '@/lib/file/file-process/pdf'; import { getProject, updateProject } from '@/lib/db/projects'; import { TASK } from '@/constant'; import { updateTask } from './index'; /** * 处理文件处理任务 * @param {Object} task 任务对象 */ export async function processFileProcessingTask(task) { const taskMessage = { current: { fileName: '', processedPage: 0, totalPage: 0 }, stepInfo: '', processedFiles: 0, totalFiles: 0, errorList: [], finishedList: [] }; try { console.log(`start processing file processing task: ${task.id}`); const params = JSON.parse(task.note); const { projectId, fileList, strategy = 'default', vsionModel, domainTreeAction } = params; // 记录文件总数 taskMessage.totalFiles = fileList.length; // 计算转换总页数 const totalPages = await getFilePageCount(projectId, fileList); // 更新任务信息 taskMessage.stepInfo = `Total ${taskMessage.totalFiles} files to process, total ${totalPages} pages`; // 更新任务状态 await updateTask(task.id, { status: TASK.STATUS.PROCESSING, totalCount: totalPages + 1, // 总页数 + 领域树处理 detail: JSON.stringify(taskMessage), startTime: new Date() }); //进行文本分割 let fileResult = { totalChunks: 0, chunks: [], toc: '' }; const project = await getProject(projectId); // 循环处理文件 for (const file of fileList) { try { taskMessage.current.fileName = file.fileName; taskMessage.current.processedPage = 1; // 重置当前处理页数 taskMessage.current.totalPage = file.pageCount || 1; // 设置当前文件总页数 await updateTask(task.id, { status: TASK.STATUS.PROCESSING, totalCount: totalPages + 1, // 总页数 + 领域树处理 detail: JSON.stringify(taskMessage), startTime: new Date() }); if (file.fileName.endsWith('.pdf')) { task.vsionModel = vsionModel; // 仅用于视觉模型处理 const result = await processPdf(strategy, projectId, file.fileName, { ...params.options, updateTask: updateTask, task: task, message: taskMessage }); //确认文件处理状态 if (!result.success) { throw new Error(result.error || `File processing failed`); } } // 文本分割 const { toc, chunks, totalChunks } = await splitProjectFile(projectId, file); fileResult.toc += toc; fileResult.chunks.push(...chunks); fileResult.totalChunks += totalChunks; console.log(projectId, file.fileName, `${file.fileName} Text split completed`); // 更新任务信息 taskMessage.finishedList.push(file); taskMessage.processedFiles++; await updateTask(task.id, { completedCount: task.completedCount + file.pageCount, // 已处理页数 detail: JSON.stringify(taskMessage), // 更新任务信息 updateAt: new Date() }); task.completedCount += file.pageCount; // 更新任务已完成页数 } catch (error) { const errorMessage = `Processing file ${file.fileName} failed: ${error.message}`; taskMessage.errorList.push(errorMessage); console.error(errorMessage); //将文件粒度的任务信息存储到任务详情中 await updateTask(task.id, { detail: JSON.stringify(taskMessage) }); } } console.log('domainTreeAction', domainTreeAction); try { // 调用领域树处理模块 const tags = await handleDomainTree({ projectId, newToc: fileResult.toc, model: JSON.parse(task.modelInfo), language: task.language, action: domainTreeAction, fileList, project }); if (!tags && domainTreeAction !== 'keep') { await updateProject(projectId, { ...project }); } //整个转换任务=》文本分割=》领域树构造结束后 转换完成 console.log(`File processing completed successfully`); // 更新任务进度 taskMessage.stepInfo = `File processing completed successfully`; await updateTask(task.id, { completedCount: task.totalCount, status: TASK.STATUS.COMPLETED, detail: JSON.stringify(taskMessage) }); } catch (error) { console.error(`processing failed:`, error); taskMessage.stepInfo = `File processing failed: ${error.message}`; // 更新任务状态为失败 await updateTask(task.id, { status: TASK.STATUS.FAILED, completedCount: 0, detail: JSON.stringify(taskMessage), endTime: new Date() }); return; } console.log(`task ${task.id} finished`); } catch (error) { console.error('pdf processing failed:', error); taskMessage.stepInfo = `File processing failed: ${String(error)}`; await updateTask(task.id, { status: TASK.STATUS.FAILED, detail: JSON.stringify(taskMessage), endTime: new Date() }); } } export default { processFileProcessingTask }; ================================================ FILE: lib/services/tasks/image-dataset-generation.js ================================================ /** * 图片数据集生成任务处理器 * 负责异步处理图片数据集生成任务,获取所有未生成答案的图片问题并批量处理 */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { getTaskConfig } from '@/lib/db/projects'; import imageService from '@/lib/services/images'; import logger from '@/lib/util/logger'; const prisma = new PrismaClient(); /** * 处理图片数据集生成任务 * 查询未生成答案的图片问题并批量处理 * @param {Object} task 任务对象 * @returns {Promise} */ export async function processImageDatasetGenerationTask(task) { try { console.log(`Starting image dataset generation task: ${task.id}`); // 解析模型信息 let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } const projectId = task.projectId; // 1. 查询未生成答案的图片问题 console.log(`Starting image dataset generation for project ${projectId}`); const imageQuestionsWithoutAnswers = await prisma.questions.findMany({ where: { projectId, answered: false, imageId: { not: null } // 只查询图片问题 } }); // 如果没有需要处理的问题,直接完成任务 if (imageQuestionsWithoutAnswers.length === 0) { await updateTask(task.id, { status: 1, completedCount: 0, totalCount: 0, detail: 'No image questions to process', note: '', endTime: new Date() }); return; } // 获取任务配置,包括并发限制 const taskConfig = await getTaskConfig(projectId); const concurrencyLimit = taskConfig?.concurrencyLimit || 2; // 更新任务总数 const totalCount = imageQuestionsWithoutAnswers.length; await updateTask(task.id, { totalCount, detail: `Image questions to process: ${totalCount}`, note: '' }); // 2. 批量处理每个图片问题 let successCount = 0; let errorCount = 0; let totalDatasets = 0; let latestTaskStatus = 0; // 单个图片问题处理函数 const processImageQuestion = async question => { try { // 如果任务已经被标记为失败或已中断,不再继续处理 const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } // 调用图片数据集生成服务 await imageService.generateDatasetForImage(projectId, question.imageId, question, { model: modelInfo, language: task.language }); // 增加成功计数 successCount++; totalDatasets++; // 更新任务进度 await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, datasets generated: ${totalDatasets}` }); return { success: true, questionId: question.id }; } catch (error) { console.error(`Error processing image question ${question.id}:`, error); errorCount++; // 更新任务进度 await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, datasets generated: ${totalDatasets}` }); return { success: false, questionId: question.id, error: error.message }; } }; // 并行处理所有图片问题 await processInParallel( imageQuestionsWithoutAnswers, processImageQuestion, concurrencyLimit, async (completed, total) => { console.log(`Image dataset generation progress: ${completed}/${total}`); } ); if (!latestTaskStatus) { // 任务完成,更新状态 const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, datasets generated: ${totalDatasets}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: '', note: finalNote, endTime: new Date() }); } console.log(`Image dataset generation task completed: ${task.id}`); } catch (error) { console.error(`Image dataset generation task failed: ${task.id}`, error); await updateTask(task.id, { status: 2, detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}` }); } } ================================================ FILE: lib/services/tasks/image-question-generation.js ================================================ /** * 图片问题生成任务处理服务 */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { getTaskConfig } from '@/lib/db/projects'; import imageService from '@/lib/services/images'; const prisma = new PrismaClient(); /** * 处理图片问题生成任务 * @param {object} task - 任务对象 * @returns {Promise} */ export async function processImageQuestionGenerationTask(task) { try { console.log(`Starting image question generation task: ${task.id}`); // 解析模型信息 let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } // 解析任务备注,获取问题数量配置 let questionCount = 3; // 默认值 if (task.note) { try { const noteData = JSON.parse(task.note); if (noteData.questionCount && noteData.questionCount >= 1 && noteData.questionCount <= 10) { questionCount = noteData.questionCount; } } catch (error) { console.warn('Failed to parse task note, using default question count:', error); } } console.log(`Each image will generate ${questionCount} questions`); // 获取项目配置 const taskConfig = await getTaskConfig(task.projectId); const concurrencyLimit = taskConfig?.concurrencyLimit || 2; // 1. 先查询所有已有问题的图片ID(一次查询,高效) const imagesWithQuestions = await prisma.questions.findMany({ where: { projectId: task.projectId, imageId: { not: null } }, select: { imageId: true }, distinct: ['imageId'] }); const imageIdsWithQuestions = new Set(imagesWithQuestions.map(q => q.imageId)); // 2. 查询所有图片 const allImages = await prisma.images.findMany({ where: { projectId: task.projectId } }); // 3. 过滤出没有问题的图片 const imagesWithoutQuestions = allImages.filter(image => !imageIdsWithQuestions.has(image.id)); if (imagesWithoutQuestions.length === 0) { console.log(`No images require question generation for project ${task.projectId}`); await updateTask(task.id, { status: 1, completedCount: 0, totalCount: 0, note: 'No images require question generation' }); return; } // 更新任务总数 const totalCount = imagesWithoutQuestions.length; await updateTask(task.id, { totalCount, detail: `Images to process: ${totalCount}` }); // 3. 批量处理每个图片 let successCount = 0; let errorCount = 0; let totalQuestions = 0; let latestTaskStatus = 0; // 单个图片处理函数 const processImage = async image => { try { // 如果任务已经被标记为失败或已中断,不再继续处理 const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } // 调用图片问题生成服务 const data = await imageService.generateQuestionsForImage(task.projectId, image.id, { model: modelInfo, language: task.language === 'zh-CN' ? 'zh' : 'en', count: questionCount // 使用任务配置的问题数量 }); // 增加成功计数 successCount++; totalQuestions += data.total || 0; // 更新任务进度 await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestions}` }); return { success: true, imageId: image.id, imageName: image.imageName, total: data.total || 0 }; } catch (error) { console.error(`Error processing image ${image.imageName}:`, error); errorCount++; // 更新任务进度 await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestions}` }); return { success: false, imageId: image.id, imageName: image.imageName, error: error.message }; } }; // 并行处理所有图片,使用任务设置中的并发限制 await processInParallel(imagesWithoutQuestions, processImage, concurrencyLimit, async (completed, total) => { console.log(`Image question generation progress: ${completed}/${total}`); }); if (!latestTaskStatus) { // 任务完成,更新状态 const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; // 如果全部失败,标记为失败;否则标记为完成 const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestions}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: '', note: finalNote, endTime: new Date() }); } console.log(`Image question generation task completed: ${task.id}`); } catch (error) { console.error(`Image question generation task failed: ${task.id}`, error); await updateTask(task.id, { status: 2, detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}` }); } } ================================================ FILE: lib/services/tasks/index.js ================================================ /** * 任务服务层入口文件 * 根据任务类型分配处理函数 */ import { PrismaClient } from '@prisma/client'; import { TASK } from '@/constant'; import { processQuestionGenerationTask } from './question-generation'; import { processFileProcessingTask } from './file-processing'; import { processAnswerGenerationTask } from './answer-generation'; import { processDataCleaningTask } from './data-cleaning'; import { processDatasetEvaluationTask } from './dataset-evaluation'; import { processMultiTurnGenerationTask } from './multi-turn-generation'; import { processDataDistillationTask } from './data-distillation'; import { processImageQuestionGenerationTask } from './image-question-generation'; import { processImageDatasetGenerationTask } from './image-dataset-generation'; import { processEvalGenerationTask } from './eval-generation'; import { processModelEvaluationTask } from './model-evaluation'; import './recovery'; const prisma = new PrismaClient(); /** * 处理异步任务 * @param {string} taskId - 任务ID * @returns {Promise} */ export async function processTask(taskId) { try { // 获取任务信息 const task = await prisma.task.findUnique({ where: { id: taskId } }); if (!task) { console.error(`Task not found: ${taskId}`); return; } // 如果任务已经完成或失败,不再处理 if (task.status === TASK.STATUS.COMPLETED || task.status === TASK.STATUS.FAILED) { console.log(`Task already finished; skipping: ${taskId}`); return; } // 根据任务类型调用相应的处理函数 switch (task.taskType) { case 'question-generation': await processQuestionGenerationTask(task); break; case 'file-processing': await processFileProcessingTask(task); break; case 'answer-generation': await processAnswerGenerationTask(task); break; case 'data-cleaning': await processDataCleaningTask(task); break; case 'dataset-evaluation': await processDatasetEvaluationTask(task); break; case 'multi-turn-generation': await processMultiTurnGenerationTask(task); break; case 'data-distillation': await processDataDistillationTask(task); break; case 'image-question-generation': await processImageQuestionGenerationTask(task); break; case 'image-dataset-generation': await processImageDatasetGenerationTask(task); break; case 'eval-generation': await processEvalGenerationTask(task); break; case 'model-evaluation': await processModelEvaluationTask(task); break; default: console.error(`Unknown task type: ${task.taskType}`); await updateTask(taskId, { status: TASK.STATUS.FAILED, note: `Unknown task type: ${task.taskType}` }); } } catch (error) { console.error(`Failed to process task: ${taskId}`, String(error)); await updateTask(taskId, { status: TASK.STATUS.FAILED, note: `Processing failed: ${error.message}` }); } } /** * 更新任务状态 * @param {string} taskId - 任务ID * @param {object} data - 更新数据 * @returns {Promise} - 更新后的任务 */ export async function updateTask(taskId, data) { try { // 如果更新状态为完成或失败,且未提供结束时间,则自动添加 if ((data.status === TASK.STATUS.COMPLETED || data.status === TASK.STATUS.FAILED) && !data.endTime) { data.endTime = new Date(); } // 更新任务 const updatedTask = await prisma.task.update({ where: { id: taskId }, data }); return updatedTask; } catch (error) { console.error(`Failed to update task status: ${taskId}`, error); throw error; } } /** * 启动任务处理器 * 轮询数据库中的待处理任务并执行 */ export async function startTaskProcessor() { try { console.log('Starting task processor...'); // 查找所有处理中的任务 const pendingTasks = await prisma.task.findMany({ where: { status: TASK.STATUS.PROCESSING } }); if (pendingTasks.length > 0) { console.log(`Found ${pendingTasks.length} pending tasks`); // 处理所有待处理任务 for (const task of pendingTasks) { console.log(`Processing task: ${task.id}`); processTask(task.id).catch(err => { console.error(`Task processing failed: ${task.id}`, err); }); } } else { console.log('No pending tasks'); } } catch (error) { console.error('Failed to start task processor', error); } } ================================================ FILE: lib/services/tasks/model-evaluation.js ================================================ /** * 模型评估任务处理服务 * 调用评估服务层完成单题评估 */ import { PrismaClient } from '@prisma/client'; import { TASK } from '@/constant'; import { updateTask } from './index'; import { getModelConfigByProjectId } from '@/lib/db/model-config'; import { evaluateSingleQuestion } from '@/lib/services/evaluation'; const prisma = new PrismaClient(); /** * 处理模型评估任务 * @param {Object} task - 任务对象 */ export async function processModelEvaluationTask(task) { const { id: taskId, projectId, detail, modelInfo, language } = task; try { console.log(`Model evaluation task started: ${taskId}, project: ${projectId}`); // 解析任务详情和模型信息 const taskDetail = typeof detail === 'string' ? JSON.parse(detail) : detail; const modelInfoObj = typeof modelInfo === 'string' ? JSON.parse(modelInfo) : modelInfo; const { evalDatasetIds, judgeModelId, judgeProviderId, customScoreAnchors } = taskDetail; const { modelId, providerId } = modelInfoObj; console.log( `Using test model ${providerId}/${modelId}` + (judgeModelId && judgeProviderId ? `, judge model ${judgeProviderId}/${judgeModelId}` : '') ); // 获取模型配置 const modelConfigs = await getModelConfigByProjectId(projectId); const testModelConfig = modelConfigs.find(c => c.modelId === modelId && c.providerId === providerId); if (!testModelConfig) { throw new Error(`Test model config not found: ${providerId}/${modelId}`); } // 获取教师模型配置 const judgeModelConfig = judgeModelId && judgeProviderId ? modelConfigs.find(c => c.modelId === judgeModelId && c.providerId === judgeProviderId) : null; // 获取要评估的题目 const evalDatasets = await prisma.evalDatasets.findMany({ where: { id: { in: evalDatasetIds }, projectId } }); if (evalDatasets.length === 0) { throw new Error('No eval datasets found for this task'); } console.log(`Loaded ${evalDatasets.length} eval questions for task: ${taskId}`); await updateTask(taskId, { totalCount: evalDatasets.length }); let completedCount = 0; let totalScore = 0; // 逐题评估 for (const evalDataset of evalDatasets) { // 检查任务是否被中断 const currentTask = await prisma.task.findUnique({ where: { id: taskId } }); if (currentTask.status === TASK.STATUS.INTERRUPTED) { console.log( `Model evaluation task interrupted: ${taskId}, completed: ${completedCount}/${evalDatasets.length}` ); return; } try { // 获取该题型对应的自定义评分规则 const scoreAnchorsForType = customScoreAnchors?.[evalDataset.questionType] || null; // 调用服务层进行单题评估 const result = await evaluateSingleQuestion({ evalDataset, testModelConfig, judgeModelConfig, projectId, language, customScoreAnchors: scoreAnchorsForType }); // 保存评估结果 await saveEvalResult(projectId, taskId, evalDataset.id, result); totalScore += result.score; } catch (error) { console.error(`Failed to evaluate question: ${evalDataset.id}`, error); await saveEvalResult(projectId, taskId, evalDataset.id, { modelAnswer: '', score: 0, isCorrect: false, judgeResponse: `Evaluation failed: ${error.message}`, duration: 0, status: 2, // API_ERROR errorMessage: error.message || 'Unknown error' }); } completedCount++; if (completedCount % 10 === 0 || completedCount === evalDatasets.length) { console.log( `Model evaluation progress: ${completedCount}/${evalDatasets.length} questions completed for task ${taskId}` ); } await updateTask(taskId, { completedCount }); } // 计算最终得分并完成任务 const finalScore = evalDatasets.length > 0 ? (totalScore / evalDatasets.length) * 100 : 0; const updatedDetail = { ...taskDetail, finalScore: parseFloat(finalScore.toFixed(2)), totalQuestions: evalDatasets.length, totalScore: parseFloat(totalScore.toFixed(4)) }; await updateTask(taskId, { status: TASK.STATUS.COMPLETED, detail: JSON.stringify(updatedDetail) }); console.log(`Model evaluation task completed: ${taskId}, score: ${finalScore.toFixed(2)}%`); } catch (error) { console.error(`Model evaluation task failed: ${taskId}`, error); await updateTask(taskId, { status: TASK.STATUS.FAILED, note: `Evaluation failed: ${error.message}` }); } } /** * 保存评估结果 */ async function saveEvalResult(projectId, taskId, evalDatasetId, result) { const { modelAnswer, score, isCorrect, judgeResponse, duration = 0, status = 0, errorMessage = '' } = result; await prisma.evalResults.upsert({ where: { taskId_evalDatasetId: { taskId, evalDatasetId } }, update: { modelAnswer, score, isCorrect, judgeResponse, duration, status, errorMessage }, create: { projectId, taskId, evalDatasetId, modelAnswer, score, isCorrect, judgeResponse, duration, status, errorMessage } }); } ================================================ FILE: lib/services/tasks/multi-turn-generation.js ================================================ /** * 多轮对话生成任务处理器 * 负责异步处理多轮对话生成任务,获取所有未生成多轮对话的问题并批量处理 */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { generateMultiTurnConversation } from '@/lib/services/multi-turn/index'; import { getTaskConfig } from '@/lib/db/projects'; import { getAllDatasetConversations } from '@/lib/db/dataset-conversations'; const prisma = new PrismaClient(); /** * 处理多轮对话生成任务 * 查询未生成多轮对话的问题并批量处理 * @param {Object} task 任务对象 * @returns {Promise} */ export async function processMultiTurnGenerationTask(task) { try { console.log(`Starting multi-turn generation task: ${task.id}`); let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse config: ${error.message}`); } // 从任务对象直接获取项目 ID const projectId = task.projectId; const taskConfig = await getTaskConfig(projectId); const multiTurnConfig = taskConfig; // 1. 获取项目中所有问题 console.log(`Starting multi-turn generation for project ${projectId}`); const allQuestions = await prisma.questions.findMany({ where: { projectId, imageId: null }, select: { id: true, question: true, chunkId: true } }); if (allQuestions.length === 0) { await updateTask(task.id, { status: 1, // 1 表示完成 detail: 'No questions to process (requires questions with generated answers)', note: '', endTime: new Date() }); return; } // 2. 获取已生成多轮对话的问题ID const existingConversations = await getAllDatasetConversations(projectId); const existingQuestionIds = new Set(existingConversations.map(conv => conv.questionId)); // 3. 筛选出未生成多轮对话的问题 const questionsWithoutMultiTurn = allQuestions.filter(q => !existingQuestionIds.has(q.id)); // 如果没有需要处理的问题,直接完成任务 if (questionsWithoutMultiTurn.length === 0) { await updateTask(task.id, { status: 1, // 1 表示完成 detail: 'All questions already have multi-turn conversations', note: '', endTime: new Date() }); return; } // 获取任务配置,包括并发限制 const concurrencyLimit = taskConfig.concurrencyLimit || 2; // 多轮对话生成较复杂,默认并发数较低 // 更新任务总数 const totalCount = questionsWithoutMultiTurn.length; await updateTask(task.id, { totalCount, detail: `Questions to process: ${totalCount}`, note: '' }); // 4. 构建多轮对话配置 const config = { systemPrompt: multiTurnConfig.multiTurnSystemPrompt || '', scenario: multiTurnConfig.multiTurnScenario || '学术讨论', rounds: multiTurnConfig.multiTurnRounds || 3, roleA: multiTurnConfig.multiTurnRoleA || '用户', roleB: multiTurnConfig.multiTurnRoleB || '助手', model: modelInfo, language: task.language === 'zh-CN' ? '中文' : 'en' }; // 5. 批量处理每个问题 let successCount = 0; let errorCount = 0; let totalConversations = 0; let latestTaskStatus = 0; // 单个问题处理函数 const processQuestion = async question => { try { // 如果任务已经被标记为失败或已中断,不再继续处理 const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } // 调用单个多轮对话生成服务 const result = await generateMultiTurnConversation(projectId, question.id, config); if (result.success) { console.log(`Multi-turn conversation generated for question ${question.id}`); successCount++; totalConversations += 1; } else { console.error(`Failed to generate multi-turn conversation for question ${question.id}:`, result.error); errorCount++; } // 更新任务进度 const progressNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, conversations generated: ${totalConversations}`; console.log(progressNote); await updateTask(task.id, { completedCount: successCount + errorCount, detail: progressNote, note: progressNote }); return { success: result.success, questionId: question.id, conversationCount: result.success ? 1 : 0 }; } catch (error) { console.error(`Error processing question ${question.id}:`, error); errorCount++; // 更新任务进度 const progressNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, conversations generated: ${totalConversations}`; await updateTask(task.id, { completedCount: successCount + errorCount, detail: progressNote, note: progressNote }); return { success: false, questionId: question.id, error: error.message }; } }; // 并行处理所有问题,使用任务设置中的并发限制 await processInParallel(questionsWithoutMultiTurn, processQuestion, concurrencyLimit, async (completed, total) => { console.log(`Multi-turn generation progress: ${completed}/${total}`); }); if (!latestTaskStatus) { // 任务完成,更新状态 const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; // 如果全部失败,标记为失败;否则标记为完成 const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, conversations generated: ${totalConversations}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: '', note: finalNote, endTime: new Date() }); } console.log(`Task completed: ${task.id}`); } catch (error) { console.error('Multi-turn generation task error:', error); await updateTask(task.id, { status: 2, // 2 表示失败 detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}`, endTime: new Date() }); } } export default { processMultiTurnGenerationTask }; ================================================ FILE: lib/services/tasks/question-generation.js ================================================ /** * Question generation task processor */ import { PrismaClient } from '@prisma/client'; import { processInParallel } from '@/lib/util/async'; import { updateTask } from './index'; import { getTaskConfig } from '@/lib/db/projects'; import questionService from '@/lib/services/questions'; const prisma = new PrismaClient(); function parseTaskChunkIds(note) { if (!note) return []; try { const parsed = typeof note === 'string' ? JSON.parse(note) : note; if (!Array.isArray(parsed?.chunkIds)) return []; return [...new Set(parsed.chunkIds.map(id => String(id)).filter(Boolean))]; } catch { return []; } } export async function processQuestionGenerationTask(task) { try { console.log(`Starting question generation task: ${task.id}`); let modelInfo; try { modelInfo = JSON.parse(task.modelInfo); } catch (error) { throw new Error(`Failed to parse model info: ${error.message}`); } const taskConfig = await getTaskConfig(task.projectId); const concurrencyLimit = taskConfig?.concurrencyLimit || 2; const targetChunkIds = parseTaskChunkIds(task.note); const chunkWhere = { projectId: task.projectId, NOT: { name: { in: ['Image Chunk', 'Distilled Content'] } } }; if (targetChunkIds.length > 0) { chunkWhere.id = { in: targetChunkIds }; } const chunks = await prisma.chunks.findMany({ where: chunkWhere, include: { Questions: true } }); // When chunkIds are explicitly provided, process the selected chunks directly. // For global auto tasks (no chunkIds), only process chunks without questions. const chunksToProcess = targetChunkIds.length > 0 ? chunks : chunks.filter(chunk => chunk.Questions.length === 0); if (chunksToProcess.length === 0) { await updateTask(task.id, { status: 1, completedCount: 0, totalCount: 0, note: 'No chunks require question generation' }); return; } const totalCount = chunksToProcess.length; await updateTask(task.id, { totalCount, detail: `Chunks to process: ${totalCount}` }); let successCount = 0; let errorCount = 0; let totalQuestions = 0; let latestTaskStatus = 0; const errorList = []; const processChunk = async chunk => { try { const latestTask = await prisma.task.findUnique({ where: { id: task.id } }); if (latestTask.status === 2 || latestTask.status === 3) { latestTaskStatus = latestTask.status; return; } const data = await questionService.generateQuestionsForChunkWithGA(task.projectId, chunk.id, { model: modelInfo, language: task.language === 'zh-CN' ? '中文' : 'en', enableGaExpansion: true }); successCount++; totalQuestions += data.total || 0; await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestions}` }); return { success: true, chunkId: chunk.id, total: data.total || 0 }; } catch (error) { errorCount++; const errorMessage = error?.message || String(error); errorList.push({ chunkId: chunk.id, error: errorMessage }); await updateTask(task.id, { completedCount: successCount + errorCount, detail: `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestions}, latest error: ${errorMessage}` }); return { success: false, chunkId: chunk.id, error: errorMessage }; } }; await processInParallel(chunksToProcess, processChunk, concurrencyLimit); if (!latestTaskStatus) { const finalStatus = errorCount > 0 && successCount === 0 ? 2 : 1; const errorSummary = errorList .slice(0, 3) .map(item => `[${item.chunkId}] ${item.error}`) .join(' | '); const finalNote = `Processed: ${successCount + errorCount}/${totalCount}, succeeded: ${successCount}, failed: ${errorCount}, questions generated: ${totalQuestions}${errorSummary ? `, errors: ${errorSummary}` : ''}`; await updateTask(task.id, { status: finalStatus, completedCount: successCount + errorCount, detail: errorList.length ? JSON.stringify({ errors: errorList.slice(0, 20) }) : '', note: finalNote, endTime: new Date() }); } console.log(`Question generation task completed: ${task.id}`); } catch (error) { console.error(`Question generation task failed: ${task.id}`, error); await updateTask(task.id, { status: 2, detail: `Processing failed: ${error.message}`, note: `Processing failed: ${error.message}` }); } } ================================================ FILE: lib/services/tasks/recovery.js ================================================ /** * 任务恢复服务 * 用于在服务启动时检查并恢复未完成的任务 */ import { PrismaClient } from '@prisma/client'; import { processAnswerGenerationTask } from './answer-generation'; import { processQuestionGenerationTask } from './question-generation'; const prisma = new PrismaClient(); // 服务初始化标志,确保只执行一次 let initialized = false; /** * 恢复未完成的任务 * 在应用启动时自动执行一次 */ export async function recoverPendingTasks() { // 如果已经初始化过,直接返回 if (process.env.INITED) { return; } process.env.INITED = true; try { console.log('Checking unfinished tasks...'); // 查找所有处理中的任务 const pendingTasks = await prisma.task.findMany({ where: { status: 0 // 处理中的任务 } }); if (pendingTasks.length === 0) { console.log('No tasks to recover'); initialized = true; return; } console.log(`Found ${pendingTasks.length} unfinished tasks, recovering...`); // 遍历处理每个任务 for (const task of pendingTasks) { try { // 根据任务类型调用对应的处理函数 switch (task.taskType) { case 'question-generation': // 异步处理,不等待完成 processQuestionGenerationTask(task).catch(error => { console.error(`Failed to recover question generation task ${task.id}:`, error); }); break; case 'answer-generation': // 异步处理,不等待完成 processAnswerGenerationTask(task).catch(error => { console.error(`Failed to recover answer generation task ${task.id}:`, error); }); break; default: console.warn(`Other Task: ${task.taskType}`); await prisma.task.update({ where: { id: task.id }, data: { status: 2, detail: `${task.taskType} Error`, note: `${task.taskType} Error`, endTime: new Date() } }); } } catch (error) { console.error(`Failed to recover task ${task.id}:`, error); } } console.log('Task recovery started; unfinished tasks will continue in the background'); initialized = true; } catch (error) { console.error('Task recovery error:', error); // 即使出错也标记为已初始化,避免反复尝试 initialized = true; } } // 在模块加载时自动执行恢复 recoverPendingTasks().catch(error => { console.error('Failed to run task recovery:', error); }); ================================================ FILE: lib/store.js ================================================ import { atomWithStorage } from 'jotai/utils'; // 模型配置列表 export const modelConfigListAtom = atomWithStorage('modelConfigList', []); export const selectedModelInfoAtom = atomWithStorage('selectedModelInfo', null); ================================================ FILE: lib/util/async.js ================================================ // 并行处理数组的辅助函数,限制并发数 export const processInParallel = async (items, processFunction, concurrencyLimit, onProgress) => { const results = []; const inProgress = new Set(); const queue = [...items]; let completedCount = 0; while (queue.length > 0 || inProgress.size > 0) { // 如果有空闲槽位且队列中还有任务,启动新任务 while (inProgress.size < concurrencyLimit && queue.length > 0) { const item = queue.shift(); const promise = processFunction(item).then(result => { inProgress.delete(promise); onProgress && onProgress(++completedCount, items.length); return result; }); inProgress.add(promise); results.push(promise); } // 等待其中一个任务完成 if (inProgress.size > 0) { await Promise.race(inProgress); } } return Promise.all(results); }; ================================================ FILE: lib/util/domain-tree.js ================================================ /** * 领域树处理模块 * 用于处理领域树的生成、修订和管理 */ import LLMClient from '../llm/core/index'; import { getProjectTocs } from '../file/text-splitter'; import { getTags, batchSaveTags } from '../db/tags'; import { extractJsonFromLLMOutput } from '../llm/common/util'; import { filterDomainTree } from './file'; import { getLabelPrompt } from '../llm/prompts/label'; import { getLabelRevisePrompt } from '../llm/prompts/labelRevise'; /** * 处理领域树生成或更新 * @param {Object} options - 配置选项 * @param {string} options.projectId - 项目ID * @param {string} options.action - 操作类型: 'rebuild', 'revise', 'keep' * @param {string} options.toc - 所有文档的目录结构 * @param {Object} options.model - 使用的模型信息 * @param {string} options.language - 语言: 'en' 或 '中文' * @param {string} options.fileName - 文件名(用于新增文件时获取内容) * @param {string} options.deletedContent - 被删除的文件内容(用于删除文件时) * @param {Object} options.project - 项目信息,包含 globalPrompt 和 domainTreePrompt * @returns {Promise} 生成的领域树标签 */ export async function handleDomainTree({ projectId, action = 'rebuild', allToc, newToc, model, language = '中文', deleteToc = null, project }) { // 如果是保持不变,直接返回现有标签 if (action === 'keep') { console.log(`[${projectId}] Using existing domain tree`); return await getTags(projectId); } try { if (!allToc) { allToc = await getProjectTocs(projectId); } const llmClient = new LLMClient(model); let tags, prompt, response; // 重建领域树 if (action === 'rebuild') { console.log(`[${projectId}] Rebuilding domain tree`); prompt = await getLabelPrompt(language, { text: allToc.slice(0, 100000) }, projectId); response = await llmClient.getResponse(prompt); tags = extractJsonFromLLMOutput(response); console.log('rebuild tags', tags); } // 修订领域树 else if (action === 'revise') { console.log(`[${projectId}] Revising domain tree`); // 获取现有的领域树 const existingTags = await getTags(projectId); if (!existingTags || existingTags.length === 0) { // 如果没有现有领域树,就像重建一样处理 prompt = await getLabelPrompt(language, { text: allToc.slice(0, 100000) }, projectId); } else { // 增量更新领域树的逻辑 prompt = await getLabelRevisePrompt( language, { text: allToc, existingTags: filterDomainTree(existingTags), newContent: newToc, deletedContent: deleteToc }, projectId ); } // console.log('revise', prompt); response = await llmClient.getResponse(prompt); tags = extractJsonFromLLMOutput(response); // console.log('revise tags', tags); } // 保存领域树标签(如果生成成功) if (tags && tags.length > 0 && action !== 'keep') { await batchSaveTags(projectId, tags); } else if (!tags && action !== 'keep') { console.error(`[${projectId}] Failed to generate domain tree tags`); } return tags; } catch (error) { console.error(`[${projectId}] Error handling domain tree: ${error.message}`); throw error; } } ================================================ FILE: lib/util/file.js ================================================ import { createHash } from 'crypto'; import { createReadStream } from 'fs'; import { PDFiumLibrary } from '@hyzyla/pdfium'; import fs from 'fs-extra'; import sharp from 'sharp'; export async function getFileMD5(filePath) { return new Promise((resolve, reject) => { const hash = createHash('md5'); const stream = createReadStream(filePath); stream.on('data', chunk => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); }); } export function filterDomainTree(tree = []) { for (let i = 0; i < tree.length; i++) { const { child } = tree[i]; delete tree[i].id; delete tree[i].projectId; delete tree[i].parentId; delete tree[i].questionCount; filterDomainTree(child); } return tree; } async function renderFunction(options) { return await sharp(options.data, { raw: { width: options.width, height: options.height, channels: 4 } }) .png() .toBuffer(); } export const savePdfAsImages = async (pdfData, outputDir, scale = 3) => { // 确保输出目录存在 await fs.ensureDir(outputDir); // 获取PDF文件名(不含扩展名)作为前缀 let fileNamePrefix = 'document'; if (typeof pdfData === 'string') { const path = require('path'); fileNamePrefix = path.basename(pdfData, path.extname(pdfData)); } // 如果pdfData是字符串,则当作路径处理 let data; if (typeof pdfData === 'string') { data = new Uint8Array(await fs.readFile(pdfData)); } else { data = new Uint8Array(pdfData); } // 加载PDF文档 const library = await PDFiumLibrary.init(); const document = await library.loadDocument(data); const numPages = document.getPageCount(); console.log(`PDF文档共 ${numPages} 页,开始生成截图...`); // 存储生成的图像文件路径 const imagePaths = []; // 处理每一页 for (const page of document.pages()) { const pageIndex = page.number + 1; console.log(`生成第 ${pageIndex} 页截图...`); // 将PDF页面渲染为PNG图片 const image = await page.render({ scale: scale, render: renderFunction }); // 生成文件名和路径,使用PDF文件名作为前缀 const fileName = `${fileNamePrefix}-${pageIndex.toString().padStart(4, '0')}.png`; const filePath = `${outputDir}/${fileName}`; // 保存图片到文件 await fs.writeFile(filePath, Buffer.from(image.data)); imagePaths.push(filePath); } document.destroy(); library.destroy(); console.log(`PDF截图完成,共生成 ${imagePaths.length} 张图片`); return imagePaths; }; ================================================ FILE: lib/util/image.js ================================================ import path from 'path'; export function getMimeType(filename) { const ext = path.extname(filename).toLowerCase(); const mimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp', '.svg': 'image/svg+xml' }; return mimeTypes[ext] || 'image/jpeg'; } ================================================ FILE: lib/util/logger.js ================================================ // lib/utils/logger.js const isElectron = typeof process !== 'undefined' && process.versions && process.versions.electron; function log(level, ...args) { try { const message = args.map(arg => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '); if (isElectron) { // 在 Electron 环境下,将日志写入文件 const { ipcRenderer } = require('electron'); ipcRenderer.send('log', { level, message }); } else { // 在非 Electron 环境下,只输出到控制台 console[level](...args); } } catch (error) { console.error('Failed to log:', error); } } export default { info: (...args) => log('info', ...args), error: (...args) => log('error', ...args), warn: (...args) => log('warn', ...args), debug: (...args) => log('debug', ...args) }; ================================================ FILE: lib/util/modelIcon.js ================================================ /** * 获取模型对应的图标路径 * @param {string} modelName - 模型名称 * @returns {string} 图标路径 */ export function getModelIcon(modelName) { if (!modelName) return '/imgs/models/default.svg'; // 将模型名称转换为小写以便比较 const lowerModelName = modelName.toLowerCase(); // 定义已知模型前缀映射 const modelPrefixes = [ { prefix: 'doubao', icon: 'doubao.svg' }, { prefix: 'qwen', icon: 'qwen.svg' }, { prefix: 'gpt', icon: 'gpt.svg' }, { prefix: 'gemini', icon: 'gemini.svg' }, { prefix: 'claude', icon: 'claude.svg' }, { prefix: 'llama', icon: 'llama.svg' }, { prefix: 'mistral', icon: 'mistral.svg' }, { prefix: 'yi', icon: 'yi.svg' }, { prefix: 'deepseek', icon: 'deepseek.svg' }, { prefix: 'chatglm', icon: 'chatglm.svg' }, { prefix: 'wenxin', icon: 'wenxin.svg' }, { prefix: 'glm', icon: 'glm.svg' }, { prefix: 'hunyuan', icon: 'hunyuan.svg' } ]; // 查找匹配的模型前缀 const matchedPrefix = modelPrefixes.find(({ prefix }) => lowerModelName.includes(prefix)); // 返回对应的图标路径,如果没有匹配则返回默认图标 return `/imgs/models/${matchedPrefix ? matchedPrefix.icon : 'default.svg'}`; } ================================================ FILE: lib/util/processInParallel.js ================================================ /** * 并行处理数组的辅助函数,限制并发数 * @param {Array} items - 要处理的项目数组 * @param {Function} processFunction - 处理单个项目的函数 * @param {number} concurrencyLimit - 并发限制数量 * @returns {Promise} 处理结果数组 */ export async function processInParallel(items, processFunction, concurrencyLimit = 2) { const results = []; const inProgress = new Set(); const queue = [...items]; while (queue.length > 0 || inProgress.size > 0) { // 如果有空闲槽位且队列中还有任务,启动新任务 while (inProgress.size < concurrencyLimit && queue.length > 0) { const item = queue.shift(); const promise = processFunction(item).then(result => { inProgress.delete(promise); return result; }); inProgress.add(promise); results.push(promise); } // 等待其中一个任务完成 if (inProgress.size > 0) { await Promise.race(inProgress); } } return Promise.all(results); } export default processInParallel; ================================================ FILE: lib/util/providerLogo.js ================================================ const PROVIDER_LOGO_MAP = { openrouter: '/imgs/providers/openrouter.ico', ollama: '/imgs/providers/ollama.png', openai: '/imgs/providers/openai.png', siliconcloud: '/imgs/providers/siliconflow.ico', deepseek: '/imgs/providers/deepseek.ico', '302ai': '/imgs/providers/302ai.ico', zhipu: '/imgs/providers/zhipu.png', doubao: '/imgs/providers/volcengine.png', groq: '/imgs/providers/groq.ico', grok: '/imgs/providers/grok.svg', alibailian: '/imgs/providers/alibailian.ico' }; const PROVIDER_PRIORITY = ['openrouter']; export function normalizeProviderId(providerId = '', providerName = '') { const raw = String(providerId || providerName || '') .trim() .toLowerCase(); if (!raw) return ''; const normalized = raw.replace(/[\s_-]/g, ''); if (normalized === 'openrouter' || normalized === 'openrouterai') return 'openrouter'; if (normalized === 'openai') return 'openai'; if (normalized === 'siliconflow' || normalized === 'siliconcloud') return 'siliconcloud'; if (normalized === 'zhipuai' || normalized === 'zhipu') return 'zhipu'; if (normalized === 'volcengine' || normalized === 'doubao') return 'doubao'; if (normalized === 'alibabailian' || normalized === 'alibailian') return 'alibailian'; return normalized; } export function getProviderLogo(providerId = '', providerName = '') { const normalizedId = normalizeProviderId(providerId, providerName); return PROVIDER_LOGO_MAP[normalizedId] || '/imgs/models/default.svg'; } export function getProviderPriority(providerId = '', providerName = '') { const normalizedId = normalizeProviderId(providerId, providerName); const index = PROVIDER_PRIORITY.indexOf(normalizedId); return index === -1 ? PROVIDER_PRIORITY.length : index; } export function sortProvidersByPriority(list = [], getId = item => item?.id || item?.providerId || item) { if (!Array.isArray(list)) return []; return [...list].sort((a, b) => { const aPriority = getProviderPriority(getId(a), a?.name || a?.providerName || a?.label); const bPriority = getProviderPriority(getId(b), b?.name || b?.providerName || b?.label); if (aPriority !== bPriority) return aPriority - bPriority; const aName = String(a?.name || a?.providerName || a?.label || getId(a) || ''); const bName = String(b?.name || b?.providerName || b?.label || getId(b) || ''); return aName.localeCompare(bName); }); } ================================================ FILE: lib/util/request.js ================================================ /** * 封装的通用重试函数,用于在操作失败后自动重试 * @param {Function} asyncOperation - 需要执行的异步操作函数 * @param {Object} options - 配置选项 * @param {number} options.retries - 重试次数,默认为1 * @param {number} options.delay - 重试前的延迟时间(毫秒),默认为0 * @param {Function} options.onRetry - 重试前的回调函数,接收错误和当前重试次数作为参数 * @returns {Promise} - 返回异步操作的结果 */ export const withRetry = async (asyncOperation, options = {}) => { const { retries = 1, delay = 0, onRetry = null } = options; let lastError; // 尝试执行操作,包括初次尝试和后续重试 for (let attempt = 0; attempt <= retries; attempt++) { try { return await asyncOperation(); } catch (error) { lastError = error; // 如果这是最后一次尝试,则不再重试 if (attempt === retries) { break; } // 如果提供了重试回调,则执行 if (onRetry && typeof onRetry === 'function') { onRetry(error, attempt + 1); } // 如果设置了延迟,则等待指定时间 if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } } } // 如果所有尝试都失败,则抛出最后一个错误 throw lastError; }; /** * 封装的fetch函数,支持自动重试 * @param {string} url - 请求URL * @param {Object} options - fetch选项 * @param {Object} retryOptions - 重试选项 * @returns {Promise} - 返回fetch响应 */ export const fetchWithRetry = async (url, options = {}, retryOptions = {}) => { return withRetry(() => fetch(url, options), retryOptions); }; /** * 封装的 fetch 函数 * @param {string} url - 请求URL * @param {Object} options - fetch选项 * @returns {Promise} - 返回fetch响应 */ export const request = async (url, options = {}) => { try { const result = await fetch(url, options); const data = await result.json(); if (!result.ok) { throw new Error(`Fetch Error: ${url} ${String(data.error || 'Unknown error')}`); } return data; } catch (error) { throw new Error(`Fetch Error: ${url} ${String(error)} ${options.errMsg || ''}`); } }; export default fetchWithRetry; ================================================ FILE: locales/en/translation.json ================================================ { "language": { "switchToEnglish": "Switch to English", "switchToChinese": "Switch to Chinese", "switcherTitle": "Change Language / 切换语言 / Dil Değiştir", "english": "English", "chineseSimplified": "Simplified Chinese", "turkish": "Turkish", "portugues": "Portuguese", "en": "EN", "zh": "中" }, "theme": { "switchToLight": "Switch to Light Mode", "switchToDark": "Switch to Dark Mode" }, "settings": { "promptConfig": "Prompt Configuration", "promptsDescription": "Configure prompt used in the project, supporting global prompts and scenario-specific prompts.", "globalPrompt": "Global Prompt", "questionPrompt": "Question Generation Prompt", "answerPrompt": "Answer Generation Prompt", "labelPrompt": "Question Labeling Prompt", "domainTreePrompt": "Domain Tree Building Prompt", "globalPromptPlaceholder": "Enter global prompt that will serve as the base prompt for all scenarios", "questionPromptPlaceholder": "Enter prompt for generating questions", "answerPromptPlaceholder": "Enter prompt for generating answers", "labelPromptPlaceholder": "Enter prompt for question labeling (not supported currently)", "domainTreePromptPlaceholder": "Enter prompt for building domain tree", "cleanPrompt": "Data Cleaning Prompt", "cleanPromptPlaceholder": "Enter custom prompt for data cleaning", "loadPromptsFailed": "Failed to load prompt configurations", "savePromptsSuccess": "Successfully saved prompt configurations", "savePromptsFailed": "Failed to save prompt configurations", "title": "Settings", "basicInfo": "Basic Info", "modelConfig": "Model Configuration", "taskConfig": "Task Configuration", "tabsAriaLabel": "Settings Tabs", "idNotEditable": "Project ID is not editable", "saveBasicInfo": "Save Basic Info", "saveSuccess": "Save Successful", "saveFailed": "Save Failed", "deleteSuccess": "Delete Successful", "deleteFailed": "Delete Failed", "fetchTasksFailed": "Failed to fetch task settings", "saveTasksFailed": "Failed to save task settings", "textSplitSettings": "Text Split Settings", "minLength": "Minimum Length", "maxLength": "Maximum Split Length", "textSplitDescription": "Adjust the text split length range", "splitType": "Split Strategy", "splitTypeMarkdown": "Document Structure Spliting (Markdown)", "splitTypeMarkdownDesc": "Automatically split the text according to the titles in the document, maintaining semantic integrity. It is suitable for Markdown documents with a clear structure.", "splitTypeRecursive": "Text Structure Spliting (Custom Delimiter)", "splitTypeRecursiveDesc": "Recursively attempt multiple levels of delimiters (configurable). First, use delimiters with higher priority, and then use secondary delimiters. It is suitable for complex documents.", "splitTypeText": "Fixed-length Spliting (Characters)", "splitTypeTextDesc": "Split the text according to the specified delimiter (configurable), and then combine it according to the specified length. It is suitable for ordinary text files.", "splitTypeToken": "Fixed-length Spliting (Tokens)", "splitTypeTokenDesc": "Block based on the number of Tokens (not the number of characters).", "splitTypeCode": "Intelligent Spliting of Program Code", "splitTypeCodeDesc": "Intelligently block according to the syntax structure of different programming languages, avoiding splitting at places with incomplete syntax.", "splitTypeCustom": "Custom Symbol Splitting", "splitTypeCustomDesc": "Split documents based on custom symbols. The separator will be discarded and the split text chunks will not be affected by chunk size.", "codeLanguage": "Programming Language", "codeLanguageHelper": "Select the programming language for smarter code splitting based on language syntax.", "chunkSize": "Chunk Size", "chunkOverlap": "Chunk Overlap", "separator": "Separator", "separatorHelper": "Separator used for splitting text, e.g. \n\n for blank lines", "customSeparator": "Custom Separator", "customSeparatorHelper": "Custom separator used for splitting text, e.g. --- or ===", "separators": "Separators List", "separatorsInput": "Separators (comma separated)", "separatorsHelper": "Comma-separated list of separators in priority order", "questionGenSettings": "Question Generation Settings", "questionGenLength": "Question Generation Length: {{length}}", "questionMaskRemovingProbability": "Removing Question Marks Probability: {{probability}}%", "questionGenDescription": "Set the maximum length for generated questions", "huggingfaceSettings": "Hugging Face Settings", "datasetUpload": "Dataset Upload Settings", "huggingfaceToken": "Hugging Face Token", "huggingfaceNotImplemented": "", "concurrencyLimit": "Concurrency Limit", "concurrencyLimitHelper": "Limit the number of tasks for generating questions and generating datasets simultaneously. ", "saveTaskConfig": "Save Task Config", "pdfSettings": "PDF file conversion configuration", "minerUToken": "MinerU Token configuration", "minerUHelper": "MinerU Token is valid for only 14 days. Please replace the Token in time", "minerULocalUrl": "PDF Conversion (MinerU Local) URL Configuration", "vision": "Custom large-scale vision model configuration", "visionConcurrencyLimit": "Concurrency limit for custom large-scale vision models", "prompts": { "selectPromptFirst": "Please select a prompt on the left", "customized": "Customized", "editPrompt": "Edit Prompt", "restoreDefault": "Restore Default", "promptType": "Prompt Type", "keyName": "Key Name", "contentPlaceholder": "Please enter custom prompt content...", "restoreDefaultContent": "Restore Default Content", "noPromptsAvailable": "No prompts available", "restoreSuccess": "Successfully restored to default prompt", "restoreFailed": "Failed to restore default prompt", "deleteError": "Error deleting prompt:", "saveSuccess": "Prompt saved successfully", "saveFailed": "Failed to save prompt", "saveError": "Error saving prompt:", "createCustomPrompt": "Create Custom Prompt", "fetchContentError": "Failed to fetch latest prompt content:" }, "multiTurnSettings": "Multi-turn Conversation Settings", "multiTurnSystemPrompt": "System Prompt", "multiTurnSystemPromptHelper": "System prompt for multi-turn conversation generation", "multiTurnScenario": "Conversation Scenario", "multiTurnScenarioHelper": "Describe the conversation scenario or context", "multiTurnRounds": "Number of Rounds", "multiTurnRoleA": "Role A", "multiTurnRoleAHelper": "Description of the first participant in the conversation", "multiTurnRoleB": "Role B", "multiTurnRoleBHelper": "Description of the second participant in the conversation", "multiTurnDescription": "Multi-turn conversation generation configuration", "evalQuestionSettings": "Evaluation Test Set Generation Settings", "evalQuestionSettingsDescription": "Configure the ratio of each question type when generating test sets. A ratio of 0 means this type will not be generated", "evalTrueFalseRatio": "True/False Ratio", "evalSingleChoiceRatio": "Single Choice Ratio", "evalMultipleChoiceRatio": "Multiple Choice Ratio", "evalShortAnswerRatio": "Short Answer Ratio", "evalOpenEndedRatio": "Open-ended Ratio", "evalQuestionRatioHelper": "The system will automatically allocate the number of questions for each type based on the set ratios. The sum of all ratios does not need to equal a specific value" }, "questions": { "autoGenerateDataset": "Auto Generate Dataset", "autoGenerateDatasetTip": "Create background batch processing tasks: automatically query text blocks pending question generation and extract questions.", "filterAll": "All Questions", "filterAnswered": "With Answers", "filterUnanswered": "Without Answers", "filterChunkNamePlaceholder": "Filter by chunk name...", "sourceTypeAll": "All Sources", "sourceTypeText": "Text Source", "sourceTypeImage": "Image Source", "title": "Questions", "confirmDeleteTitle": "Confirm Delete Question", "confirmDeleteContent": "Are you sure you want to delete the question \"{{question}}\"? This action cannot be undone.", "deleting": "Deleting question...", "batchDeleteTitle": "Confirm Batch Delete", "batchDeleting": "Deleting {{count}} questions...", "deleteSuccess": "Question deleted successfully", "deleteFailed": "Failed to delete question", "batchDeleteSuccess": "Successfully deleted {{count}} questions", "batchDeletePartial": "Delete completed, success: {{success}}, failed: {{failed}}", "batchDeleteFailed": "Failed to batch delete questions", "noQuestionsSelected": "Please select questions first", "batchGenerateStart": "Starting to generate datasets for {{count}} questions", "invalidQuestionKey": "Invalid question key", "listView": "Question List", "treeView": "Domain Tree", "selectAll": "Select All", "selectedCount": "Selected {{count}} questions", "totalCount": "Total {{count}} questions", "searchPlaceholder": "Search questions or tags...", "searchMatch": "Match", "searchNotMatch": "Not Match", "deleteSelected": "Delete Selected", "batchGenerate": "Batch Generate Datasets", "generating": "Generating Dataset", "generatingProgress": "Completed: {{completed}}/{{total}}", "generatedCount": "Generated {{count}} datasets", "pleaseWait": "Please wait...", "selectAllLimitReached": "Selected {{count}} questions (maximum limit reached)", "selectAllFailed": "Select all operation failed, please try again later", "createSuccess": "Question created successfully", "updateSuccess": "Question updated successfully", "operationSuccess": "Operation successful", "operationFailed": "Operation failed", "editQuestion": "Edit Question", "questionContent": "Question Content", "sourceType": "Data Source Type", "sourceType.text": "Text", "sourceType.image": "Image", "selectChunk": "Select Text Chunk", "searchChunk": "Search text chunks...", "selectImage": "Select Image", "searchImage": "Search images...", "selectTag": "Select Tag", "searchTag": "Search tags...", "createQuestion": "Create Question", "createNormalQuestion": "Create Normal Question", "createQuestionTemplate": "Create Question Template", "questionPlaceholder": "Please enter your question", "noChunkSelected": "Please select a text chunk first", "noTagSelected": "Please select a tag", "fetchTemplatesFailed": "Failed to fetch question templates", "createTemplateSuccess": "Question template created successfully", "createTemplateFailed": "Failed to create question template", "updateTemplateSuccess": "Question template updated successfully", "updateTemplateFailed": "Failed to update question template", "deleteTemplateSuccess": "Question template deleted successfully", "deleteTemplateFailed": "Failed to delete question template", "exportQuestions": "Export Questions", "exportScope": "Export Scope", "exportAll": "Export All ({{count}} questions)", "exportSelected": "Export Selected ({{count}} questions)", "exportFormat": "Export Format", "txtFormat": "Plain Text (Questions Only)", "exportSuccess": "Questions exported successfully", "exportFailed": "Failed to export questions", "template": { "management": "Question Templates", "create": "Create Template", "edit": "Edit Template", "question": "Question Content", "description": "Prompt", "descriptionHelp": "Used to be included in the overall prompt when AI generates answers related to this question template, to influence the final answer generation results", "noTemplates": "No question templates yet, click create button to add", "deleteConfirm": "Are you sure you want to delete this question template?", "used": "Used", "addLabel": "Add Label", "customFormat": "Custom Format", "customFormatHelp": "Enter JSON format output constraint", "customFormatInfo": "This format will be provided to the LLM as a prompt to constrain output format", "sourceTypeInfo": "Data Source Type", "sourceType": { "label": "Data Source Type", "image": "Image", "text": "Text" }, "answerType": { "label": "Answer Type", "text": "Text", "tags": "Tags", "customFormat": "Custom Format" }, "errors": { "questionRequired": "Please enter question content", "labelsRequired": "Label type questions require at least one label", "customFormatRequired": "Please enter custom format", "invalidJson": "Invalid JSON format" }, "autoGenerate": "Auto-generate questions after creating template", "autoGenerateHelpText": "Will automatically create questions based on this template for all text chunks in the project", "autoGenerateHelpImage": "Will automatically create questions based on this template for all images in the project", "confirmAutoGenerate": "Confirm Auto-generate Questions", "confirmAutoGenerateTextMessage": "You have chosen to auto-generate questions. The system will create questions based on this template for all text chunks in the project.", "confirmAutoGenerateImageMessage": "You have chosen to auto-generate questions. The system will create questions based on this template for all images in the project.", "autoGenerateWarning": "This operation may create a large number of questions. Please confirm to continue.", "autoGenerateSuccess": "Successfully created questions for {{count}} data sources", "autoGeneratePartialFail": "Successfully created {{success}} questions, {{fail}} failed", "autoGenerateFailed": "Failed to auto-generate questions" }, "generateSingleTurnDataset": "Generate Single-turn Dataset", "generateSingleTurnDatasetDesc": "Generate Q&A dataset based on questions", "generateMultiTurnDataset": "Generate Multi-Turn Dataset", "generateImageDataset": "Generate Image Q&A Dataset", "generateMultiTurnDatasetDesc": "Generate multi-turn conversation dataset based on questions", "deleteConfirm": "Are you sure you want to delete this question? This action cannot be undone." }, "common": { "dataSource": "Data Source", "menu": "Menu", "openMenu": "Open navigation menu", "all": "All", "jumpTo": "Jump To", "unknownError": "Unknown Error", "create": "Create", "edit": "Edit", "delete": "Delete", "save": "Save", "cancel": "Cancel", "confirm": "Confirm", "complete": "Complete", "close": "Close", "add": "Add", "remove": "Remove", "loading": "Loading...", "yes": "Yes", "no": "No", "confirmDelete": "Confirm Delete", "saving": "Saving...", "deleting": "Deleting...", "actions": "Actions", "confirmDeleteDataSet": "Are you sure you want to delete this dataset? This action cannot be undone.", "noData": "None", "failed": "Failed", "success": "Success", "backToList": "Back to List", "label": "Label", "confirmDeleteDescription": "Are you sure you want to delete this File? This action cannot be undone.", "more": "More", "import": "Import", "export": "Export", "fetchError": "Fetching data failed", "confirmDeleteQuestion": "Are you sure you want to delete this question? This action cannot be undone.", "deleteSuccess": "Delete successful", "visitGitHub": "Visit GitHub Repository", "syncOldData": "Sync Old Data", "copy": "Copy", "enabled": "Enabled", "disabled": "Disabled", "copied": "Copied", "generating": "Generating...", "processing": "Processing...", "items": "items", "detailInfo": "Detail Info", "reset": "Reset", "apply": "Apply", "mainNavigation": "Main Navigation", "goHome": "Go to Home", "goToHomePage": "Go to Home Page", "mobileNavigation": "Mobile Navigation Menu", "navigation": "Navigation", "closeMenu": "Close Menu", "documentation": "Documentation", "viewOnGitHub": "View on GitHub", "back": "Back", "refresh": "Refresh", "expand": "Expand All", "collapse": "Collapse" }, "home": { "title": "Easy Dataset", "subtitle": "A powerful tool for creating fine-tuning datasets for Large Language Models", "createProject": "Create Project", "searchDataset": "Search Public Datasets" }, "projects": { "reuseConfig": "Reuse Model Config", "noReuse": "No Configuration Reuse", "selectProject": "Select Project", "fetchFailed": "Failed to fetch project list", "fetchError": "Error fetching project list", "loading": "Loading your projects...", "createFailed": "Failed to create project", "createError": "Error creating project", "createNew": "Create New Project", "saveFailed": "Failed to save project", "id": "Project ID", "name": "Project Name", "description": "Project Description", "questions": "Questions", "datasets": "Datasets", "evalDatasets": "Eval Datasets", "tokens": "Tokens", "lastUpdated": "Last Updated", "viewDetails": "View Details", "createFirst": "Please create a project first", "noProjects": "No projects found", "notExist": "The project does not exist.", "createProject": "Create Project", "deleteConfirm": "Are you sure you want to delete this project? This action cannot be undone.", "deleteSuccess": "Project deleted successfully", "deleteFailed": "Failed to delete project", "backToHome": "Back to Home", "deleteConfirmTitle": "Confirm Delete" }, "textSplit": { "dragToUpload": "Drag files to upload", "fileList": "File List", "autoGenerateQuestions": "Auto Generate", "autoGenerateQuestionsTip": "Create background batch processing tasks: automatically query text blocks pending question generation and extract questions.", "exportChunks": "Export Chunks", "allChunks": "All Text Chunks", "generatedQuestions2": "With Questions", "ungeneratedQuestions": "Without Questions", "contentKeyword": "Text Chunk Content", "contentKeywordPlaceholder": "Enter keywords to search chunk content", "characterRange": "Character Range", "questionStatus": "Question Status", "noFilesUploaded": "No files uploaded yet", "unknownFile": "Unknown File", "fetchFilesFailed": "Failed to fetch files", "editTag": "Edit Tag", "deleteTag": "Delete Tag", "addTag": "Add Tag", "selectedCount": "Selected {{count}} text chunks", "totalCount": "total {{count}} text chunks", "batchGenerateQuestions": "Batch Generate", "batchDeleteChunks": "Batch Delete", "batchDeleteChunksConfirmTitle": "Confirm Batch Delete", "batchDeleteChunksConfirmMessage": "Are you sure you want to delete the selected {{count}} text chunks? This action cannot be undone.", "uploadedDocuments": "Uploaded {{count}} Documents", "title": "Texts", "uploadNewDocument": "Upload New Document", "selectFile": "Select File", "markdownOnly": "Currently only supports Markdown (.md) format files", "supportedFormats": "Supported formats: .pdf .md, .txt, .docx", "uploadAndProcess": "Upload and Process", "selectedFiles": "Selected Files ({{count}})", "oneFileMessage": "File upload not allowed, please delete the existing file first", "mutilFileMessage": "After uploading a new file, the domain tree will be rebuilt", "noChunks": " No text chunks found", "chunkDetails": "Chunk Details: {{chunkId}}", "fetchChunksFailed": "Failed to fetch text chunks", "fetchChunksError": "Error fetching text chunks", "fileResultReceived": "File result received", "fileUploadSuccess": "File uploaded successfully", "splitTextFailed": "Text splitting failed", "splitTextError": "Error splitting text", "deleteChunkFailed": "Failed to delete text chunk", "deleteChunkError": "Error deleting text chunk", "selectModelFirst": "Please select a model first, you can select from the top navigation bar", "modelNotAvailable": "Selected model is not available, please select again", "generateQuestionsFailed": "Failed to generate questions for chunk {{chunkId}}", "questionsGenerated": "{{total}} questions generated", "customSplitMode": "Custom Split Mode", "customSplitInstructions": "Select text to add split points. The system will place split markers at your selected positions.", "splitPointsList": "Added Split Points", "saveSplitPoints": "Save Split Points", "confirmCustomSplitTitle": "Confirm Split Replacement", "confirmCustomSplitMessage": "Note: Custom split points will replace the previously automated split results for this document. Do you want to continue?", "customSplitSuccess": "Custom split saved successfully", "customSplitFailed": "Failed to save custom split", "missingRequiredData": "Missing required data", "chunksPreview": "Chunks Size Preview", "chunk": "Chunk", "characters": " chars", "questionsGeneratedSuccess": "Successfully generated {{total}} questions for the text chunk", "generateQuestionsForChunkFailed": "Failed to generate questions for chunk {{chunkId}}", "generateQuestionsForChunkError": "Error generating questions for chunk {{chunkId}}", "generateQuestionsError": "Error generating questions", "partialSuccess": "Partially successful question generation ({{successCount}}/{{total}}), {{errorCount}} chunks failed", "allSuccess": "Successfully generated {{totalQuestions}} questions for {{successCount}} text chunks", "fileDeleted": "File {{fileName}} deleted, refreshing text chunk list", "tabs": { "smartSplit": "Smart Split", "domainAnalysis": "Domain Analysis" }, "loading": "Loading...", "fetchingDocuments": "Fetching document data", "processing": "Processing...", "progressStatus": "Selected {{total}} text chunks, {{completed}} completed", "processingPleaseWait": "Processing, please wait!", "oneFileLimit": "File upload not allowed, there is already an uploaded file", "unsupportedFormat": "Unsupported file format: {{files}}", "modelInfoParseError": "Failed to parse model information", "uploadFailed": "Upload failed", "uploadSuccess": "Successfully uploaded {{count}} files", "deleteFailed": "Failed to delete file", "deleteSuccess": "File {{fileName}} has been successfully deleted", "generatedQuestions": "{{count}} Questions", "generatedEvalQuestions": "{{count}} Test Questions", "viewDetails": "View Details", "generateQuestions": "Generate Questions", "generateEvalQuestions": "Generate Test Set", "evalQuestionsGeneratedSuccess": "Successfully generated {{total}} evaluation questions", "generateEvalQuestionsFailed": "Failed to generate evaluation questions", "dataCleaning": "Data Cleaning", "batchDataCleaning": "Batch Data Cleaning", "autoDataCleaning": "Auto Data Cleaning", "autoDataCleaningTip": "Create background batch processing task: automatically clean all text chunks", "autoEvalGeneration": "Auto Generate Eval Dataset", "autoEvalGenerationTip": "Create background batch processing task: automatically generate evaluation datasets for all text chunks without eval questions", "autoTasks": "Auto Tasks", "dataCleaningSuccess": "Data cleaning completed, original length: {{originalLength}}, cleaned length: {{cleanedLength}}", "dataCleaningFailed": "Data cleaning failed for text chunk {{chunkId}}", "dataCleaningForChunkSuccess": "Data cleaning completed for text chunk {{chunkId}}", "dataCleaningForChunkFailed": "Data cleaning failed for text chunk {{chunkId}}", "dataCleaningForChunkError": "Data cleaning error for text chunk {{chunkId}}", "dataCleaningPartialSuccess": "Partial data cleaning success ({{successCount}}/{{total}}), {{errorCount}} text chunks failed", "dataCleaningAllSuccess": "Successfully completed data cleaning for {{successCount}} text chunks", "charsCount": "Characters", "pdfProcessStatus": "Total {{total}} files, {{completed}} have been converted", "pdfPageProcessStatus": "Processing {{fileName}}, {{completed}} out of {{total}} pages converted", "pdfProcessing": "Converting file...", "pdfProcessingFailed": "File conversion failed!", "pdfProcess": "File detected!", "selectPdfProcessingStrategy": "Please select the file processing method:", "pdfProcessingStrategyDefault": "Default", "pdfProcessingStrategyDefaultHelper": "Use the built-in PDF parsing strategy", "pdfProcessingStrategyMinerUHelper": "Use MinerU API for parsing. Please configure the MinerU API Token first", "pdfProcessingStrategyVision": "Custom Vision Model", "pdfProcessingStrategyVisionHelper": "Use a custom vision model for parsing", "pdfProcessingToast": "File detected. The system will create a background task to parse the file", "pdfProcessingWaring": "There is a file processing task in progress. It is recommended to wait for the task to complete before performing other operations, otherwise it may affect the quality of data generation!", "pdfProcessingLoading": "Executing file conversion task, please wait for the task to complete before uploading new files...", "basicPdfParsing": "Basic PDF Parsing", "basicPdfParsingDesc": "Capable of identifying the key outlines of simple PDF files with high speed", "mineruApiDesc": "Capable of identifying complex PDF files, including formulas and charts (Requires configuration of MinerU API Key)", "mineruLocalDesc": "Capable of identifying complex PDF files, including formulas and charts (requires configuring MinerU Local URL)", "mineruApiDescDisabled": "Please go to Project Settings - Task Configuration to set up MinerU Token", "mineruLocalDisabled": "Please first set up MinerU Local URL in [Project Settings - Task Configuration]", "mineruWebPlatform": "MinerU Online Platform Parsing", "mineruWebPlatformDesc": "Capable of identifying complex PDF files, including formulas and charts (Requires redirecting to another website)", "mineruSelected": "Selected to use MinerU for parsing PDFs", "mineruLocalSelected": "Selected to use MinerU Local for parsing PDFs", "customVisionModel": "Custom Vision Model Parsing", "customVisionModelDesc": "Capable of identifying complex PDF files, including formulas and charts (Requires adding vision model configuration to the model configuration)", "customVisionModelSelected": "Selected to use the visual large model {{name}} ({{provider}}) for parsing PDFs", "defaultSelected": "Selected to use the default built-in strategy for parsing PDFs", "download": "Download the document", "deleteFile": "Delete document", "batchDelete": "Batch Delete ({{count}})", "batchDeleteTitle": "Batch Delete Files", "batchDeleteConfirm": "Are you sure you want to delete the selected {{count}} files? This action cannot be undone.", "batchDeleteSuccess": "Successfully deleted {{count}} files", "batchDeleteFailed": "Batch delete failed", "searchFiles": "Search files...", "searchResults": "Found {{count}} files ({{total}} total)", "noSearchResults": "No files found containing \"{{searchTerm}}\"", "noResultsOnCurrentPage": "No search results on current page, please return to the first page", "noDataOnCurrentPage": "No data on current page", "viewChunk": "View Text Chunk", "editChunk": "Edit Text Chunk {{chunkId}}", "editChunkSuccess": "Text chunk edited successfully", "editChunkFailed": "Failed to edit text chunk", "editChunkError": "Error occurred when editing text chunk", "deleteFileWarning": "Warning: Deleting this document will also delete the following related items", "deleteFileWarningChunks": "All associated text chunks", "deleteFileWarningQuestions": "All questions generated from these chunks", "deleteFileWarningDatasets": "All datasets created from these questions", "domainTree": { "firstUploadTitle": "Domain Tree Generation", "uploadTitle": "Document Upload - Domain Tree Processing", "deleteTitle": "Document Deletion - Domain Tree Processing", "reviseOption": "Revise Domain Tree", "reviseDesc": "Modify the current domain tree based on added or deleted documents, only affecting changed parts", "rebuildOption": "Rebuild Domain Tree", "rebuildDesc": "Generate a completely new domain tree based on all document contents", "keepOption": "Keep Unchanged", "keepDesc": "Keep the current domain tree structure unchanged without any modifications" } }, "domain": { "title": "Domain Knowledge Tree", "addRootTag": "Add Root Tag", "addFirstTag": "Add First Tag", "noTags": "No domain tags available", "docStructure": "Document Structure", "noToc": "No table of contents available. Please upload and process the document first.", "editTag": "Edit Tag", "deleteTag": "Delete Tag", "addChildTag": "Add Child Tag", "deleteTagConfirmTitle": "Delete Tag", "deleteTagConfirmMessage": "Are you sure you want to delete tag \"{{tag}}\"?", "deleteWarning": "This action will delete this tag and all its child tags, questions, and datasets. This cannot be undone!", "dialog": { "addTitle": "Add Tag", "editTitle": "Edit Tag", "addChildTitle": "Add Child Tag", "inputRoot": "Please enter a new root tag name", "inputEdit": "Please edit the tag name", "inputChild": "Please add a child tag for \"{label}\"", "labelName": "Tag Name", "saving": "Saving...", "save": "Save", "deleteConfirm": "Are you sure to delete tag \"{label}\"?", "deleteWarning": "This action will delete all child tags and cannot be undone.", "emptyLabel": "Tag name cannot be empty" }, "tabs": { "tree": "Domain Tree", "structure": "Document Structure" }, "errors": { "saveFailed": "Failed to save tags" }, "messages": { "updateSuccess": "Tags updated successfully" } }, "export": { "alpacaSettings": "Alpaca Format Settings", "questionFieldType": "Question Field Type", "useInstruction": "Use instruction field", "useInput": "Use input field", "customInstruction": "Custom Instruction Content", "instructionPlaceholder": "Enter fixed instruction content", "instructionHelperText": "When using input field, you can specify fixed instruction content here", "title": "Export", "format": "Format", "fileFormat": "File Format", "systemPrompt": "System Prompt", "systemPromptPlaceholder": "Please enter system prompt...", "ReasoninglanguagePlaceholder": "Please enter Reasoning language : English or Chinese or others", "onlyConfirmed": "Only export confirmed data", "example": "Format Example", "confirmExport": "Confirm Export", "includeCOT": "Include Chain of Thought", "cotDescription": "Includes the reasoning process before the final answer", "customFormat": "Custom Format", "customFormatSettings": "Custom Format Settings", "questionFieldName": "Question Field Name", "multilingualThinkingFormat": "Multilingual‑Thinking", "Reasoninglanguage": "Reasoning language", "sampleInstruction": "Human instruction (required)", "sampleOutput": "Model response (required)", "sampleSystem": "System prompt (optional)", "sampleInstruction2": "Second instruction", "sampleOutput2": "Second response", "sampleSystemShort": "System prompt", "fixedInstruction": "Fixed instruction content", "sampleInput": "Human question (required)", "sampleInput2": "Second question", "sampleInputOptional": "Human input (optional)", "sampleUserMessage": "Human instruction", "sampleAssistantMessage": "Model response", "sampleAnalysis": "Model's chain of thought content", "sampleFinal": "Model response", "sampleThinking": "Model's chain of thought content", "fetchLabelStatsError": "Failed to fetch label statistics:" }, "import": { "title": "Import", "fileUpload": "File Upload", "fileUploadDescription": "Upload local files to import datasets", "mapFields": "Field Mapping", "importing": "Importing", "uploadFile": "Upload File", "supportedFormats": "Supports JSON, JSONL, CSV format files", "dragDropFile": "Drag and drop files here or click to select files", "dropFileHere": "Release to upload file", "maxFileSize": "Maximum file size: 50MB", "processingFile": "Processing file...", "uploadedFiles": "Uploaded Files", "uploadError": "File upload failed, please check if the file format is correct", "selectFromSource": "Select dataset from {{source}}", "sourceDescription": "Enter dataset name or keywords to search", "datasetName": "Dataset Name", "hfPlaceholder": "e.g.: squad, glue, imdb", "msPlaceholder": "e.g.: damo/nlp_bert_document-classification", "search": "Search", "searching": "Searching", "searchResults": "Search Results", "downloads": "Downloads", "download": "Download", "downloading": "Downloading", "hfNote": "Note: Downloading large datasets may take a long time, it is recommended to select smaller datasets for testing.", "msNote": "Note: ModelScope dataset download requires network connection, please ensure network connectivity.", "fieldMapping": "Field Mapping", "mappingDescription": "Please map the source data fields to target fields. The system has automatically identified possible mapping relationships, you can adjust as needed.", "selectMapping": "Select Field Mapping", "questionField": "Question Field", "answerField": "Answer Field", "cotField": "Chain of Thought Field", "tagsField": "Tags Field", "selectField": "Select Field", "questionDesc": "User's question or input content (required)", "answerDesc": "AI's answer or output content (required)", "cotDesc": "Chain of thought or reasoning process (optional)", "tagsDesc": "Tag array, multiple tags separated by commas (optional)", "dataPreview": "Data Preview", "previewNote": "Shows first 3 records, each field value displays up to 100 characters", "confirmMapping": "Confirm Mapping", "requiredFields": "Please select at least question and answer field mappings", "mappingRequired": "Question and answer fields are required", "duplicateMapping": "Cannot map multiple target fields to the same source field", "noPreviewData": "No preview data available", "preparingData": "Preparing data...", "uploadingData": "Uploading data...", "processing": "Processing... {{processed}}/{{total}}", "completed": "Import completed", "importStats": "Import Statistics", "total": "Total: {{count}}", "success": "Success: {{count}}", "failed": "Failed: {{count}}", "source": "Data Source", "description": "Description", "errors": "Error Messages", "moreErrors": "{{count}} more errors not shown...", "importSuccess": "Dataset import completed!", "enterDatasetName": "Please enter dataset name", "noDatasetFound": "No matching datasets found", "complete": "Complete", "addToEval": "Add to Eval Dataset", "addToEvalSuccess": "Successfully added to eval dataset", "addToEvalFailed": "Failed to add", "generateEvalVariant": "Generate Eval Variant", "selectModelFirst": "Please select a model first", "generateVariantFailed": "Failed to generate variant", "saveVariantSuccess": "Saved to eval dataset", "saveVariantFailed": "Failed to save", "evalVariantTitle": "Generate Evaluation Variant", "evalVariantHint": "AI has generated a new test variant based on the original Q&A. You can edit and save it.", "saveToEval": "Save to Eval Dataset", "evalVariantConfigHint": "Please select the question type and quantity. AI will rewrite based on the current Q&A pair.", "questionType": "Question Type", "typeOpenEnded": "Open-ended", "typeSingleChoice": "Single Choice", "typeMultipleChoice": "Multiple Choice", "typeTrueFalse": "True/False", "typeShortAnswer": "Short Answer", "generateCount": "Generate Count", "evalVariantPreviewHint": "You can edit the generated questions and save them to the evaluation set after confirmation.", "questionIndex": "Question {{index}}", "options": "Options (JSON Array)", "optionsHint": "e.g.: [\"Option A\", \"Option B\"]", "answerArrayHint": "For multiple choice, please enter an array, e.g. [\"A\", \"C\"]", "answerBoolHint": "For True/False, please enter ✅ or ❌", "evalVariantPreviewTitle": "Confirm Generated Questions", "generate": "Generate" }, "export_extended": { "answerFieldName": "Answer Field Name", "cotFieldName": "Cot Field Name", "includeLabels": "Include Labels", "includeChunk": "Include Text Chunk", "questionOnly": "Export Questions Only", "localTab": "Local Export", "llamaFactoryTab": "Llama Factory", "huggingFaceTab": "HuggingFace", "configExists": "Configuration File Exists", "configPath": "Configuration File Path", "updateConfig": "Update LLaMA Factory Configuration", "noConfig": "No configuration file exists, click the button below to generate", "generateConfig": "Generate LLaMA Factory Configuration", "huggingFaceComingSoon": "HuggingFace export feature coming soon", "uploadToHuggingFace": "Upload to HuggingFace", "datasetName": "Dataset Name", "datasetNameHelp": "Format: username/dataset-name", "privateDataset": "Private Dataset", "datasetSettings": "Dataset Settings", "exportOptions": "Export Options", "uploadSuccess": "Dataset uploaded successfully to HuggingFace", "viewOnHuggingFace": "View on HuggingFace", "noTokenWarning": "Hugging Face Token not found. Please configure it in project settings.", "goToSettings": "Go to Settings", "tokenHelp": "You can get your token from HuggingFace settings page" }, "datasets": { "loadingDataset": "Loading Dataset...", "datasetNotFound": "Dataset Not Found", "optimizeTitle": "AI Optimize", "optimizeAdvice": "Optimize Advice", "optimizePlaceholder": "Please enter your suggestions for improving the answer, and AI will optimize the answer and reasoning chain based on your suggestions", "generatingDataset": "Generating Dataset", "aiOptimizeAdvicePlaceholder": "Please enter your suggestions for improving the answer, and AI will optimize the answer and reasoning chain based on your suggestions", "aiOptimizeAdvice": "Please enter your suggestions for improving the answer, and AI will optimize the answer and reasoning chain based on your suggestions", "aiOptimize": "AI Optimize", "partialSuccess": "Partially successful dataset generation ({{successCount}}/{{total}}), {{failCount}} questions failed", "generating": "Generating Dataset", "generateError": "Failed to generate dataset", "management": "Datasets", "question": "Question", "filterAll": "All", "filterConfirmed": "Confirmed", "filterUnconfirmed": "Unconfirmed", "createdAt": "Created At", "model": "Model", "domainTag": "Domain Tag", "cot": "COT", "answer": "Answer", "chunkId": "Text Chunk", "confirmed": "Confirmed", "noTag": "No Tag", "noData": "No Data", "rowsPerPage": "Rows per page", "pagination": "{{from}}-{{to}} of {{count}}", "confirmDeleteMessage": "Are you sure you want to delete this dataset? This action cannot be undone.", "questionLabel": "Question", "fetchFailed": "Failed to fetch dataset", "deleteFailed": "Failed to delete dataset", "deleteSuccess": "Delete successful", "exportSuccess": "Dataset exported successfully", "exportFailed": "Export failed", "exportProgress": "Export Progress", "exportingData": "Exporting dataset", "processedCount": "Processed {{processed}} / {{total}} items", "exportInProgress": "Fetching data, please wait...", "exportFinalizing": "Generating file, almost done...", "loading": "Loading datasets...", "stats": "Total {{total}} datasets, {{confirmed}} confirmed ({{percentage}}%)", "selected": "Total selected: {{count}}", "batchconfirmDeleteMessage": "Are you sure you want to delete {{count}} selected questions? This action cannot be undone.", "batchDelete": "Batch Delete", "batchDeleteProgress": "Completed: {{completed}}/{{total}}", "batchDeleteCount": "Delete count: {{count}}", "searchPlaceholder": "Search datasets...", "fieldQuestion": "Question", "fieldAnswer": "Answer", "fieldCOT": "COT", "fieldLabel": "Domain Label", "moreFilters": "More Filters", "filtersTitle": "Filter Options", "filterConfirmationStatus": "Confirmation Status", "filterCotStatus": "Chain of Thought Status", "filterHasCot": "Has CoT", "filterNoCot": "No CoT", "filterScoreRange": "Rating Range", "filterNoteKeyword": "Note Keyword", "filterNoteKeywordPlaceholder": "Enter note keyword...", "filterChunkName": "Chunk Name", "filterChunkNamePlaceholder": "Enter chunk name...", "filterCustomTag": "Custom Tag", "resetFilters": "Reset", "applyFilters": "Apply", "viewDetails": "View Details", "datasetDetail": "Dataset Details", "metadata": "Metadata", "confirmSave": "Confirm Save", "unconfirm": "Unconfirm", "unconfirming": "Unconfirming...", "uncategorized": "Uncategorized", "questionCount": "{{count}} Questions", "source": "Source", "generateDataset": "Generate Dataset", "generateNotImplemented": "Dataset generation is not implemented", "generateSuccess": "Successfully generated dataset for: {{question}}", "generateFailed": "Failed to generate dataset: {{error}}", "noTagsAndQuestions": "No tags and questions available", "answerCount": "{{count}} Answers", "answered": "Answered", "enableShortcuts": "Page shortcut key", "shortcutsHelp": "Press ← forward, press → backward, press y to confirm, press d to delete", "filterDistill": "Distilled Dataset", "filterDistillYes": "Distilled Dataset", "filterDistillNo": "Non-distilled Dataset", "evaluation": "Dataset Evaluation", "rating": "Rating", "ratingExcellent": "Excellent", "ratingGood": "Good", "ratingAverage": "Average", "ratingPoor": "Poor", "ratingVeryPoor": "Very Poor", "ratingUnrated": "Unrated", "customTags": "Custom Tags", "addCustomTag": "Add custom tag...", "note": "Note", "addNote": "Add note...", "noNote": "No notes", "clickToAddNote": "Click to add note...", "enterNote": "Enter note...", "noteShortcuts": "Ctrl+Enter to save, Esc to cancel", "aiEvaluation": "AI Quality Assessment", "addToEval": "Add to Eval Dataset", "addToEvalSuccess": "Successfully added to eval dataset", "addToEvalFailed": "Failed to add", "generateEvalVariant": "Generate Eval Variant", "generateVariantFailed": "Failed to generate variant", "saveVariantSuccess": "Saved to eval dataset", "saveVariantFailed": "Failed to save", "evalVariantTitle": "Generate Evaluation Variant", "evalVariantPreviewTitle": "Confirm Generated Questions", "saveToEval": "Save to Eval Dataset", "evalVariantConfigHint": "Please select the question type and quantity. AI will rewrite based on the current Q&A pair.", "questionType": "Question Type", "typeOpenEnded": "Open-ended", "typeSingleChoice": "Single Choice", "typeMultipleChoice": "Multiple Choice", "typeTrueFalse": "True/False", "typeShortAnswer": "Short Answer", "generateCount": "Generate Count", "evalVariantPreviewHint": "You can edit the generated questions and save them to the evaluation set after confirmation.", "questionIndex": "Question {{index}}", "options": "Options (JSON Array)", "optionsHint": "e.g.: [\"Option A\", \"Option B\"]", "answerArrayHint": "For multiple choice, please enter an array, e.g. [\"A\", \"C\"]", "answerBoolHint": "For True/False, please enter ✅ or ❌", "generate": "Generate", "updateSuccess": "Update successful", "updateFailed": "Update failed", "evaluate": "Evaluate", "evaluating": "Evaluating...", "batchEvaluate": "Batch Evaluate", "selectModelFirst": "Please select a model first", "evaluateSuccess": "Evaluation completed! Score: {{score}}/5", "evaluateFailed": "Evaluation failed", "evaluateError": "Evaluation failed: {{error}}", "batchEvaluateStarted": "Batch evaluation task started, processing in background", "batchEvaluateStartFailed": "Failed to start batch evaluation", "batchEvaluateFailed": "Batch evaluation failed: {{error}}", "scoreRange": "{{min}} - {{max}} points", "singleTurn": "Single-turn Q&A Dataset", "multiTurn": "Multi-turn Conversation Dataset", "imageQA": "Image Q&A Dataset", "conversationDetail": "Multi-turn Conversation Details", "conversationContent": "Conversation Content", "basicInfo": "Basic Information", "firstQuestion": "First Question", "conversationScenario": "Conversation Scenario", "conversationRounds": "Conversation Rounds", "modelUsed": "Model Used", "qualityScore": "Quality Score", "notes": "Notes", "createTime": "Create Time", "notSet": "Not Set", "noTags": "No Tags", "noNotes": "No Notes", "notEvaluated": "Not Evaluated", "round": "Round {{round}}", "system": "System", "user": "User", "assistant": "Assistant", "confirmDelete": "Confirm Delete", "confirmDeleteConversation": "Are you sure you want to delete this multi-turn conversation dataset? This action cannot be undone.", "conversationNotFound": "Conversation dataset not found", "fetchDataFailed": "Failed to fetch data", "saveFailed": "Save failed", "saveSuccess": "Save successful", "saving": "Saving...", "inputTagsPlaceholder": "Enter tags, separated by spaces", "addNotesPlaceholder": "Add notes", "noConversations": "No multi-turn conversations", "notRated": "Not Rated", "minScore": "Min Score", "maxScore": "Max Score", "unconfirmed": "Unconfirmed" }, "rating": { "veryPoor": "Very Poor", "poor": "Poor", "belowAverage": "Below Average", "fair": "Fair", "average": "Average", "good": "Good", "veryGood": "Very Good", "excellent": "Excellent", "outstanding": "Outstanding", "perfect": "Perfect", "unrated": "Unrated" }, "tags": { "noTags": "No tags", "addTag": "Add tag...", "addCustomTag": "Add custom tag", "maxTagsReached": "Maximum {{maxTags}} tags reached", "availableTagsHint": "Select from existing tags or enter new ones" }, "update": { "newVersion": "New Version", "newVersionAvailable": "New Version Available", "currentVersion": "Current Version", "latestVersion": "Latest Version", "downloadNow": "Download Now", "downloading": "Downloading", "installNow": "Install Now", "updating": "Updating...", "updateNow": "Update Now", "viewRelease": "View Release Notes", "checking": "Checking for updates...", "noUpdates": "Already up to date", "updateError": "Update Error", "updateSuccess": "Update Successful", "restartRequired": "Restart Required", "restartNow": "Restart Now", "restartLater": "Restart Later" }, "datasetSquare": { "title": "Dataset Square", "subtitle": "Discover and explore various public dataset resources to support your model training and research", "searchPlaceholder": "Search dataset keywords...", "searchVia": "Search via", "categoryTitle": "Dataset Categories", "categories": { "all": "All", "popular": "Popular", "chinese": "Chinese Resources", "english": "English Resources", "research": "Research Data", "multimodal": "Multimodal" }, "foundResources": "Found {{count}} dataset resources", "currentFilter": "Current filter: {{category}}", "noDatasets": "No datasets found matching your criteria", "tryOtherCategories": "Please try other categories or return to view all datasets", "dataset": "Dataset", "viewDataset": "View Dataset" }, "playground": { "title": "Model Testing", "selectModelFirst": "Please select a model", "sendFirstMessage": "Send your first message to start testing", "inputMessage": "Enter message...", "send": "Send", "outputMode": "Output Mode", "normalOutput": "Normal Output", "streamingOutput": "Streaming Output", "clearConversation": "Clear Conversation", "selectModelMax3": "Please select up to 3 models to test", "reasoningProcess": "Reasoning Chain" }, "chunks": { "title": "Text Chunk", "defaultTitle": "Default Title" }, "documentation": "Documentation", "models": { "configNotFound": "Model config not found", "parseError": "Failed to parse model config", "fetchFailed": "Failed to fetch model", "saveFailed": "Failed to save model config", "pleaseSelectModel": "Please select at least one model", "title": "Model Settings", "add": "Add Model", "unselectedModel": "Unselected Model", "unconfiguredAPIKey": "Unconfigured API Key", "saveAllModels": "Save All Models", "edit": "Edit", "delete": "Delete", "modelId": "Model ID", "modelName": "Model Name", "modelNamePlaceholder": "Enter model name (optional, defaults to Model ID)", "modelIdPlaceholder": "Enter model ID (e.g., gpt-4o)", "endpoint": "Endpoint", "apiKey": "API Key", "provider": "Provider", "localModel": "Local Model", "apiKeyConfigured": "API Key Configured", "apiKeyNotConfigured": "API Key Not Configured", "temperature": "Temperature", "maxTokens": "Max Tokens", "maxTokensInputTip": "Slider range: 1-{{max}}. You can also input any positive integer.", "topP": "Top P", "type": "Model Type", "text": "Large Language Model", "vision": "Vision Large Model", "typeTips": "If you want to use a custom vision model to parse PDFs, please configure at least one vision large model", "refresh": "Refresh Models", "configuredModels": "Configured Models", "unconfiguredModels": "Unconfigured Models", "noConfiguredModels": "No configured models", "noUnconfiguredModels": "No unconfigured models", "checkEndpointHealth": "Check endpoint health", "checkAllEndpointHealth": "Check all endpoints", "endpointHealthy": "Endpoint is healthy", "endpointCheckFailed": "Endpoint check failed", "endpointMissing": "Endpoint is empty", "endpointReachableModelMissing": "Endpoint reachable, but current model is not in the returned model list", "healthCheckSummary": "Health check completed: {{okCount}} healthy, {{failCount}} failed", "checking": "Checking...", "healthy": "Healthy", "reachable": "Reachable", "unhealthy": "Unhealthy", "notChecked": "Not checked" }, "stats": { "ongoingProjects": "Ongoing Projects", "questionCount": "Question Count", "generatedDatasets": "Generated Datasets", "supportedModels": "Supported Models" }, "migration": { "title": "Project Migration", "description": "Some projects need to be migrated to the database. Migration can improve performance and support more features.", "projectsList": "Unmigrated Projects", "migrate": "Start Migration", "migrating": "Migrating...", "success": "Successfully migrated {{count}} projects", "failed": "Migration failed", "checkFailed": "Failed to check unmigrated projects", "checkError": "Error checking unmigrated projects", "starting": "Starting migration task...", "processing": "Processing migration task...", "completed": "Migration completed", "startFailed": "Failed to start migration task", "statusFailed": "Failed to get migration status", "taskNotFound": "Migration task not found", "progressStatus": "Migrated {{completed}}/{{total}} projects", "openDirectory": "Open Directory", "deleteDirectory": "Delete Directory", "confirmDelete": "Are you sure you want to delete this project directory? This action cannot be undone.", "openDirectoryFailed": "Failed to open project directory", "deleteDirectoryFailed": "Failed to delete project directory" }, "distill": { "title": "Distill", "generateRootTags": "Generate Root Tags", "generateSubTags": "Generate Sub Tags", "generateQuestions": "Generate Questions", "generateRootTagsTitle": "Generate Root Domain Tags", "generateSubTagsTitle": "Generate Sub Tags for {{parentTag}}", "generateQuestionsTitle": "Generate Questions for {{tag}}", "parentTag": "Parent Tag", "parentTagPlaceholder": "Enter parent tag name (e.g., Sports, Technology)", "parentTagHelp": "Enter a domain topic, and the system will generate related tags based on it", "generateQuestionsError": "Failed to generate questions", "tagCount": "Number of Tags", "tagCountHelp": "Enter the number of tags to generate, maximum is 100", "questionCount": "Number of Questions", "questionCountHelp": "Enter the number of questions to generate, maximum is 100", "generatedTags": "Generated Tags", "generatedQuestions": "Generated Questions", "tagPath": "Tag Path", "noTags": "No Tags", "noQuestions": "No Questions", "clickGenerateButton": "Click the generate button above to create tags", "selectModelFirst": "Please select a model first", "selectModel": "Select Model", "generateTagsError": "Failed to generate tags", "generateTags": "Generate Tags", "subTags": "sub-tags", "questions": "questions", "deleteTagConfirmTitle": "Confirm to delete tag?", "editTagTitle": "Edit Tag", "tagName": "Tag Name", "labelRequired": "Tag name cannot be empty", "tagUpdateSuccess": "Tag updated successfully", "tagUpdateFailed": "Failed to update tag", "unknownTag": "Unknown Tag", "autoDistillButton": "Auto Distill Dataset", "autoDistillTitle": "Automated Dataset Distillation Configuration", "distillTopic": "Distillation Topic", "tagLevels": "Tag Levels", "tagLevelsHelper": "Set the number of levels, maximum is {{max}}", "tagsPerLevel": "Tags Per Level", "tagsPerLevelHelper": "Number of sub-tags to generate under each parent tag, maximum is {{max}}", "questionsPerTag": "Questions Per Tag", "questionsPerTagHelper": "Number of questions to generate for each leaf tag, maximum is {{max}}", "estimationInfo": "Task Estimation Info", "estimatedTags": "Estimated Tags", "estimatedQuestions": "Estimated Questions", "currentTags": "Current Tags", "currentQuestions": "Current Questions", "newTags": "New Tags", "newQuestions": "New Questions", "startAutoDistill": "Start Auto Distillation", "autoDistillProgress": "Auto Distillation Progress", "overallProgress": "Overall Progress", "tagsProgress": "Tag Building Progress", "questionsProgress": "Question Generation Progress", "currentStage": "Current Stage", "realTimeLogs": "Real-time Logs", "waitingForLogs": "Waiting for logs...", "autoDistillStarted": "{{time}} Auto distillation task started", "autoDistillInsufficientError": "Current configuration will not produce new tags or questions, please adjust parameters", "stageInitializing": "Initializing...", "stageBuildingLevel1": "Building Level 1 Tags", "stageBuildingLevel2": "Building Level 2 Tags", "stageBuildingLevel3": "Building Level 3 Tags", "stageBuildingLevel4": "Building Level 4 Tags", "stageBuildingLevel5": "Building Level 5 Tags", "stageBuildingQuestions": "Generating Questions", "stageBuildingDatasets": "Building Datasets", "stageCompleted": "Task Completed", "datasetsProgress": "Datasets Progress", "rootTopicHelperText": "By default, the project name is used as the top-level distillation theme. If you need to change it, please go to the project settings to modify the project name.", "addChildTag": "Add Child Tag", "datasetType": "Dataset Type", "singleTurnDataset": "Single-turn Dataset", "multiTurnDataset": "Multi-turn Dataset", "bothDatasetTypes": "Generate Both Dataset Types", "autoDistillTaskDetail": "Auto Distill Task: {{topic}}", "backgroundTaskCreated": "Background distill task created. You can check the progress in the task management center.", "backgroundTaskFailed": "Failed to create background task", "taskExecutionError": "Task execution error: {{error}}" }, "tasks": { "pending": "{{count}} tasks are processing", "completed": "tasks are completed", "title": "Task Management Center", "loading": "Loading tasks...", "empty": "No tasks found", "confirmDelete": "Are you sure you want to delete this task?", "confirmAbort": "Are you sure you want to abort this task? The task will be stopped.", "deleteSuccess": "Task deleted", "deleteFailed": "Failed to delete task", "abortSuccess": "Task aborted", "abortFailed": "Failed to abort task", "status": { "processing": "Processing", "completed": "Completed", "failed": "Failed", "aborted": "Aborted", "unknown": "Unknown" }, "types": { "text-processing": "Text Processing", "file-processing": "File Processing", "data-cleaning": "Data Cleaning", "question-generation": "Question Generation", "answer-generation": "Answer Generation", "eval-generation": "Evaluation Generation", "multi-turn-generation": "Multi-turn Generation", "image-question-generation": "Image Question Generation", "data-distillation": "Data Distillation", "pdf-processing": "PDF Processing" }, "filters": { "status": "Task Status", "type": "Task Type" }, "actions": { "refresh": "Refresh task list", "delete": "Delete task", "abort": "Abort task" }, "table": { "type": "Type", "status": "Status", "progress": "Progress", "note": "Note", "createTime": "Created", "endTime": "Completed", "duration": "Duration", "model": "Model", "detail": "Details", "actions": "Actions" }, "duration": { "seconds": "{{seconds}}s", "minutes": "{{minutes}}m {{seconds}}s", "hours": "{{hours}}h {{minutes}}m" }, "fetchFailed": "Failed to fetch task list", "createSuccess": "Task created successfully", "createFailed": "Failed to create task", "multiTurnCreateSuccess": "Multi-turn conversation dataset task created successfully", "notes": { "selectedChunks": "{{count}} chunks selected", "fileBatch": "File processing params: {{count}} files (strategy: {{strategy}})", "jsonParams": "Task parameters configured", "noChunksQuestion": "No chunks require question generation", "noChunksCleaning": "No chunks require cleaning", "processingFailed": "Processing failed: {{error}}", "questionSummary": "Processed {{processed}}/{{total}}, succeeded {{succeeded}}, failed {{failed}}, questions generated {{generated}}", "datasetSummary": "Processed {{processed}}/{{total}}, succeeded {{succeeded}}, failed {{failed}}, datasets generated {{generated}}", "cleaningSummary": "Processed {{processed}}/{{total}}, succeeded {{succeeded}}, failed {{failed}}, original length {{original}}, cleaned length {{cleaned}}", "genericSummary": "Processed {{processed}}/{{total}}, succeeded {{succeeded}}, failed {{failed}}" } }, "gaPairs": { "title": "Genre-Audience Pairs Management", "loading": "Loading GA pairs...", "addPair": "Add GA Pair", "saveChanges": "Save Changes", "saving": "Saving...", "restoreBackup": "Restore Backup", "noGaPairsTitle": "No Genre-Audience Pairs Found", "noGaPairsDescription": "Generate AI-powered Genre-Audience pairs for this file", "generateGaPairs": "Generate Genre-Audience Pairs", "generating": "Generating...", "generateMore": "Generate More Genre-Audience Pairs", "activePairs": "Active Genre-Audience Pairs ({{active}}/{{total}})", "pairNumber": "Genre-Audience Pair #{{number}}", "active": "Active", "deleteTooltip": "Delete GA Pair", "genre": "Genre", "genreDescription": "Genre Description", "audience": "Audience", "audienceDescription": "Audience Description", "addDialogTitle": "Add New Genre-Audience Pair", "genreTitle": "Genre Title", "audienceTitle": "Audience Title", "genreTitlePlaceholder": "Enter the genre title...", "genreDescPlaceholder": "Describe the genre in detail...", "audienceTitlePlaceholder": "Enter the audience title...", "audienceDescPlaceholder": "Describe the target audience in detail...", "cancel": "Cancel", "addPairButton": "Add Genre-Audience Pair", "requiredFields": "Genre Title and Audience Title are required", "restoredFromBackup": "Restored from backup", "allPairsDeleted": "All GA pairs deleted successfully", "pairsSaved": "{{count}} GA pairs saved successfully", "additionalPairsGenerated": "Successfully generated {{count}} additional Genre-Audience pairs. Total: {{total}}", "validationError": "GA pair {{number}}: Genre and Audience titles are required", "loadError": "Unable to load GA pairs: {{error}}", "generateError": "Failed to generate GA pairs", "saveError": "Failed to save GA pairs", "noActiveModel": "Please configure an AI model in settings before generating GA pairs.", "contentTooShort": "The file content is too short or not suitable for GA pair generation.", "configError": "AI model configuration error. The required dependencies may not be installed.", "serverError": "Server error ({{status}}). Please try again later.", "emptyResponse": "Empty response from generation service", "generationFailed": "Generation failed", "saveOperationFailed": "Save operation failed", "serviceNotAvailable": "GA Pairs generation service is not available. Please check your API configuration.", "requestFailed": "Request failed ({{status}}). Please try again.", "internalServerError": "Internal server error occurred.", "batchGenerate": "Batch Generate GA Pairs", "batchGenerateDescription": "Will batch generate GA pairs for {{count}} selected files. This operation may take some time.", "appendMode": "Append Mode", "appendModeDescription": "Generate additional GA pairs for files that already have GA pairs, rather than overwriting", "selectAtLeastOneFile": "Please select at least one file first", "noDefaultModel": "No default model set, please configure a model in project settings first", "incompleteModelConfig": "Model configuration is incomplete, please check model settings", "missingApiKey": "Model API key not configured, please add API key in model settings", "loadingProjectModel": "Loading project model...", "usingModel": "Using model", "startGeneration": "Start Generation", "batchGenCompleted": "Batch generation completed! Successfully generated GA pairs for {{success}}/{{total}} files.", "generationError": "Error occurred during generation: {{error}}", "fetchProjectInfoFailed": "Failed to fetch project info: {{status}}", "fetchModelConfigFailed": "Failed to fetch model config: {{status}}", "fetchProjectModelError": "Error fetching project model configuration", "batchGenerationFailed": "Batch GA pair generation failed", "batchGenerationSuccess": "Successfully generated GA pairs for {{count}} files", "selectAllFiles": "Select All", "deselectAllFiles": "Deselect All", "batchGenerateTitle": "Batch Generate GA Pairs", "generationMode": "Generation Mode", "aiGenerateMode": "AI Generate", "manualAddMode": "Manual Add", "genreDesc": "Genre Description", "audienceDesc": "Audience Description", "manualGaPairRequired": "Please fill in Genre Title and Audience Title", "batchAddManual": "Batch Add" }, "batchEdit": { "title": "Batch Edit Text Chunks", "batchEdit": "Batch Edit", "batchEditTooltip": "Batch edit selected text chunks", "position": "Add Position", "atBeginning": "Add at Beginning", "atEnd": "Add at End", "contentToAdd": "Content to Add", "contentPlaceholder": "Enter content to add to text chunks...", "contentRequired": "Please enter content to add", "contentHelp": "This content will be added to all selected text chunks", "preview": "Preview", "allChunksSelected": "All {{count}} text chunks selected", "selectedChunks": "{{selected}} / {{total}} text chunks selected", "processing": "Processing...", "applyToChunks": "Apply to {{count}} chunks", "editSuccess": "Successfully edited {{count}} text chunks", "editFailed": "Batch edit failed", "previewNote": "The above is a preview of the first selected text chunk. All selected text chunks will undergo the same modification" }, "errors": { "projectIdRequired": "Project ID cannot be empty", "getDatasetsFailed": "Failed to get datasets", "getTagStatsFailed": "Failed to get tag statistics", "deleteFileFailed": "Error deleting file", "recordNotFound": "Current record does not exist", "mineruTokenNotFound": "Token configuration not found, please check if MinerU token is configured in task settings", "mineruLocalUrlNotFound": "MinerU local URL configuration not found, please check if MinerU local URL is configured in task settings" }, "sampleData": { "questionContent": "Question content", "answerContent": "Answer content", "cotContent": "Chain of thought content", "domainLabel": "Domain label", "textChunk": "Text chunk" }, "exportDialog": { "balancedExport": "Balanced Export", "balancedExportTitle": "Balanced Export Settings", "balancedExportDescription": "Configure the data volume for each category based on domain tags to achieve balanced dataset export", "quickSettings": "Quick Settings", "setAllTo50": "Set all to 50", "setAllTo100": "Set all to 100", "setAllTo200": "Set all to 200", "customAmount": "Custom amount", "tagName": "Tag Name", "availableCount": "Available Count", "exportCount": "Export Count", "settings": "Settings", "totalExportCount": "Total export count", "tagCount": "Tag count", "export": "Export" }, "imageDatasets": { "title": "Image Q&A Dataset", "subtitle": "Manage and optimize your image Q&A datasets", "description": "Manage and optimize your image Q&A datasets.", "searchPlaceholder": "Search questions or answers...", "noAnswer": "No answer", "labels": "Labels", "typeLabel": "Label", "typeCustom": "Custom", "typeText": "Text", "unscored": "Unscored", "confirmed": "Confirmed", "unconfirmed": "Unconfirmed", "view": "View Details", "evaluate": "Quality Assessment", "delete": "Delete", "deleteConfirm": "Are you sure you want to delete this dataset?", "imageName": "Image Name", "status": "Status", "scoreRange": "Score Range", "noData": "No image datasets", "noDataTip": "Please generate Q&A datasets in Image Management first", "fetchFailed": "Failed to fetch datasets", "fetchDetailFailed": "Failed to fetch detail", "deleteSuccess": "Deleted successfully", "deleteFailed": "Failed to delete", "updateSuccess": "Updated successfully", "updateFailed": "Failed to update", "regenerateSuccess": "AI recognition successful", "regenerateFailed": "AI recognition failed", "notFound": "Dataset not found", "detail": "Detail", "image": "Image", "question": "Question", "answer": "Answer", "selectLabels": "Select Labels", "noLabels": "No labels selected", "jsonPlaceholder": "Enter JSON format data...", "metadata": "Metadata", "score": "Score", "tags": "Tags", "addTag": "Add tag...", "note": "Note", "notePlaceholder": "Add note...", "modelInfo": "Model Info", "createdAt": "Created At", "updatedAt": "Updated At", "exportTitle": "Export Image Dataset", "exportFormat": "Export Format", "rawFormat": "Raw Format", "customFormat": "Custom Format", "exportImagesOption": "Export Image Files", "exportImagesDesc": "Package all images into a ZIP file for download", "includeImagePath": "Include Image Path in Dataset", "includeImagePathDesc": "Add image path in question or answer (format: /images/image_name)", "systemPrompt": "System Prompt (Optional)", "systemPromptPlaceholder": "Enter system prompt...", "confirmedOnly": "Export Confirmed Only", "exportTip": "Label format answers will be automatically parsed to text (comma separated)", "exportSuccess": "Dataset exported successfully", "exportFailed": "Export failed", "noDataToExport": "No data to export", "exportImagesSuccess": "Image ZIP package exported successfully", "exportImagesFailed": "Failed to export images" }, "images": { "resolution": "Resolution", "uploadTime": "Upload Time", "fileName": "File Name", "title": "Image Management", "importImages": "Import Images", "searchPlaceholder": "Search image name...", "hasQuestions": "Question Status", "hasDatasets": "Dataset Status", "withQuestions": "With Questions", "withoutQuestions": "Without Questions", "withDatasets": "With Datasets", "withoutDatasets": "Without Datasets", "noImages": "No images", "noImagesDescription": "Start importing images to create your first dataset", "preview": "Preview", "questions": "Questions", "datasets": "Datasets", "datasetCount": "Dataset Count", "generateQuestions": "Generate Questions", "generateDataset": "Generate Dataset", "deleteConfirm": "Are you sure you want to delete this image?", "deleteSuccess": "Deleted successfully", "deleteFailed": "Delete failed", "batchDelete": "Batch Delete", "selectImagesToDelete": "Please select images to delete", "batchDeleteConfirm": "Are you sure you want to delete {{count}} selected images?", "batchDeleteSuccess": "Successfully deleted {{count}} images", "batchDeletePartialSuccess": "Successfully deleted {{success}}, failed {{fail}}", "batchDeleteFailed": "Batch delete failed", "importTip": "Select one or more directories containing images. All images will be imported into the project (duplicate names will be overwritten)", "selectDirectory": "Select Directory", "directoryPath": "Directory Path", "enterDirectoryPath": "e.g., /Users/username/Pictures", "selectedDirectories": "Selected Directories", "selectAtLeastOne": "Please select at least one directory", "importSuccess": "Successfully imported {{count}} images", "importFailed": "Import failed", "startImport": "Start Import", "addDirectory": "Add Directory", "importFromDirectory": "Import from Directory", "importFromPdf": "Import from PDF", "importFromZip": "Import from ZIP", "pdfImportTip": "Select a PDF file, the system will automatically convert it to images and import", "zipImportTip": "Select a ZIP archive file, the system will automatically extract and import images from it", "clickToSelectPdf": "Click to select PDF file", "clickToSelectZip": "Click to select ZIP file", "supportedFormat": "Supported format: PDF", "supportedZipFormat": "Supported format: ZIP", "fileSize": "File size", "selectedFile": "Selected file", "invalidPdfFile": "Please select a valid PDF file", "invalidZipFile": "Please select a valid ZIP file", "selectPdfFile": "Please select a PDF file", "selectZipFile": "Please select a ZIP file", "pdfImportSuccess": "Successfully imported {{count}} images from PDF \"{{name}}\"", "pdfImportFailed": "PDF import failed", "zipImportSuccess": "Successfully imported {{count}} images from ZIP \"{{name}}\"", "zipImportFailed": "ZIP import failed", "convertAndImport": "Convert and Import", "extractAndImport": "Extract and Import", "electronRequired": "This feature requires the desktop application", "selectDirectoryFailed": "Failed to select directory", "imageName": "Image Name", "questionCount": "Question Count", "questionCountHelp": "Generate 1-10 questions", "size": "Size", "dimensions": "Dimensions", "currentModel": "Current Model", "selectModelFirst": "Please select a model first", "visionModelRequired": "Please select a vision-capable model (e.g., GPT-4 Vision, Claude, etc.)", "countRange": "Question count should be between 1-10", "questionsGenerated": "Successfully generated {{count}} questions", "generateFailed": "Generation failed", "question": "Question", "questionPlaceholder": "Enter your question...", "questionRequired": "Please enter a question", "datasetGenerated": "Dataset generated successfully", "autoGenerateQuestions": "Auto Generate Questions", "autoGenerateConfirm": "The system will automatically generate questions for all images without questions. This will create a background task, and you can view the progress in Task Management.", "taskCreated": "Task created successfully, processing in background", "taskCreateFailed": "Failed to create task", "manualAnnotation": "Manual Annotation", "annotationTitle": "Image Annotation", "imageInfo": "Image Info", "annotatedCount": "Annotated", "selectQuestion": "Select or Create Question", "selectQuestionPlaceholder": "Select question template...", "universalQuestions": "Universal Questions", "independentQuestions": "Independent Questions", "answerTypeText": "Text", "answerTypeLabel": "Label", "answerTypeCustomFormat": "Custom Format", "usedTimes": "Used {{count}} times", "answer": "Answer", "answerPlaceholder": "Enter answer...", "selectLabels": "Select Labels", "availableLabels": "Available Labels", "noLabelsAvailable": "No labels available", "addNewLabel": "Add new label...", "selectedLabels": "Selected", "customFormatAnswer": "Custom Format Answer", "formatRequirement": "Format Requirement", "customFormatPlaceholder": "Enter JSON in the required format...", "note": "Note", "notePlaceholder": "Note (optional)", "saveAndContinue": "Save & Continue", "noImageSelected": "No image selected", "noTemplateSelected": "Please select a question", "answerRequired": "Please enter an answer", "invalidJsonFormat": "Invalid JSON format", "annotationSuccess": "Annotation saved successfully", "annotationFailed": "Failed to save annotation", "allQuestionsAnnotated": "All questions for this image have been annotated", "allImagesAnnotated": "All questions for all images have been annotated", "noQuestionsAssociated": "No questions associated with this image", "loadImageDetailFailed": "Failed to load image details", "answeredQuestions": "Annotated Questions", "useTemplate": "Use Template", "formatJson": "Format", "jsonFormatHelp": "Please enter valid JSON format data", "imageLoadError": "Failed to load image", "annotate": "Annotate", "annotateImage": "Annotate Image", "createQuestion": "Create Question", "createTemplate": "Create Question Template", "aiGenerate": "AI Recognize", "aiGenerateSuccess": "AI generation successful", "aiGenerateFailed": "AI generation failed", "missingParameters": "Missing required parameters", "selectNewQuestion": "Select New Question", "fetchTemplatesFailed": "Failed to fetch question templates", "createTemplateSuccess": "Question template created successfully", "createTemplateFailed": "Failed to create question template", "updateTemplateSuccess": "Question template updated successfully", "updateTemplateFailed": "Failed to update question template", "deleteTemplateSuccess": "Question template deleted successfully", "deleteTemplateFailed": "Failed to delete question template", "template": { "management": "Manage Question Templates", "create": "Create Template", "edit": "Edit Template", "question": "Question Content", "description": "Description", "noTemplates": "No question templates yet, click create button to add", "deleteConfirm": "Are you sure you want to delete this question template?", "used": "Used", "addLabel": "Add Label", "customFormat": "Custom Format", "customFormatHelp": "Enter JSON format output constraint", "customFormatInfo": "This format will be provided to the LLM as a prompt to constrain output format", "type": { "label": "Question Type", "universal": "Universal Question", "independent": "Independent Question" }, "answerType": { "label": "Answer Type", "text": "Text", "tags": "Tags", "customFormat": "Custom Format" }, "errors": { "questionRequired": "Please enter question content", "labelsRequired": "Label type questions require at least one label", "customFormatRequired": "Please enter custom format", "invalidJson": "Invalid JSON format" } } }, "monitoring": { "title": "Resource Monitoring Dashboard", "timeRange": { "24h": "Last 24 hours", "7d": "Last 7 days", "30d": "Last 30 days" }, "filters": { "allProjects": "All Projects", "allProviders": "All Providers", "allStatus": "All Status" }, "status": { "success": "Success", "failed": "Failed" }, "actions": { "export": "Export Report" }, "stats": { "totalTokens": "Total Token Usage", "avgTokensPerCall": "Avg Tokens per Call", "totalCalls": "Total Calls", "avgLatency": "Avg Latency", "inputOutput": "Input: {{input}} · Output: {{output}}", "successCalls": "{{count}} Success", "failedCalls": "{{count}} Failed", "failureRate": "{{rate}}% Failure Rate", "basedOnSuccessCalls": "Based on {{count}} successful calls", "noSuccessCalls": "No successful calls" }, "charts": { "tokenTrend": "Token Usage Trend", "inputLegend": "Input", "outputLegend": "Output", "distributionTitle": "Token Usage Distribution (by Model)", "distributionSubtitle": "Resource usage share by model", "tokensTooltip": "{{value}}K Tokens" }, "table": { "title": "Usage Details", "searchPlaceholder": "Search project, model, or failure reason...", "empty": "No data", "rowsPerPage": "Rows per page:", "columns": { "projectName": "Project", "provider": "Provider", "model": "Model", "status": "Status", "failureReason": "Failure Reason", "inputTokens": "Input Tokens", "outputTokens": "Output Tokens", "totalTokens": "Total", "calls": "Calls", "avgLatency": "Avg Latency" } }, "errors": { "fetchSummaryFailed": "Failed to fetch monitoring summary", "fetchLogsFailed": "Failed to fetch monitoring logs" } }, "eval": { "title": "Eval", "datasets": "Eval Datasets", "tasks": "Eval Tasks", "datasetsTitle": "Evaluation Datasets", "datasetsDescription": "Manage and view all generated evaluation test questions", "tasksTitle": "Evaluation Tasks", "tasksComingSoon": "Coming Soon", "tasksComingSoonHint": "Evaluation task feature is under development", "totalQuestions": "Total Questions", "questionType": "Type", "question": "Question", "answer": "Answer", "options": "Options", "correct": "Correct", "wrong": "Wrong", "sourceChunk": "Source Chunk", "tags": "Tags", "tagsPlaceholder": "Enter tags, separated by commas", "note": "Note", "detail": "Detail", "notFound": "Question not found", "noData": "No evaluation data", "noDataHint": "Please generate evaluation test set from text split page first", "searchPlaceholder": "Search question content...", "cardView": "Card View", "listView": "List View", "deleteSelected": "Delete Selected ({{count}})", "deleteConfirmTitle": "Confirm Delete", "deleteConfirmMessage": "Are you sure you want to delete {{count}} question(s)? This action cannot be undone.", "questionTypes": { "true_false": "True/False", "single_choice": "Single Choice", "multiple_choice": "Multiple Choice", "short_answer": "Short Answer", "open_ended": "Open Ended" } }, "evalDatasets": { "import": { "title": "Import Eval Datasets", "questionType": "Question Type", "selectTypeFirst": "Please select a question type first", "selectFile": "Please select a file to import", "invalidFileType": "Unsupported file format, please upload json, xls or xlsx files", "formatPreview": "Data Format Preview", "downloadTemplate": "Download Template", "template": "Template", "uploadFile": "Upload File", "dropOrClick": "Click or drag file here", "supportedFormats": "Supports JSON, XLS, XLSX formats", "tags": "Tags (Optional)", "tagsPlaceholder": "Add tags for imported data, separate multiple tags with commas", "tagsHelp": "All imported data will be tagged with these labels", "import": "Import", "importing": "Importing...", "failed": "Import failed", "success": "Import successful", "successMessage": "Successfully imported {{count}} evaluation datasets", "showingErrors": "Showing first {{count}} errors", "custom": "Custom Import", "builtin": "Built-in Datasets", "builtinTitle": "Select Built-in Dataset", "searchPlaceholder": "Search datasets...", "confirmImportTitle": "Confirm Import", "confirmImportMessage": "Are you sure you want to import dataset \"{{name}}\"? This will add new evaluation data to the current project.", "downloading": "Downloading..." }, "export": { "title": "Export Eval Datasets", "formatLabel": "Export Format", "filterLabel": "Filter Criteria", "previewLabel": "Data to export: ", "records": " records", "largeDataHint": "Large dataset, streaming export will be used, please wait", "exporting": "Exporting...", "exportBtn": "Export", "jsonDesc": "Standard JSON array", "jsonlDesc": "One record per line", "csvDesc": "Table format", "noTagsAvailable": "No tags available" } }, "evalTasks": { "title": "Model Evaluation Tasks", "createTitle": "Create Evaluation Task", "detailTitle": "Evaluation Task Details", "createTask": "Create Task", "noTasks": "No evaluation tasks", "noTasksHint": "Create an evaluation task to test model performance on evaluation datasets", "selectModels": "Select Test Models", "selectModelsHint": "You can select multiple models for comparison", "selectJudgeModel": "Select Judge Model", "selectJudgeModelPlaceholder": "Please select...", "selectJudgeModelHint": "Judge model is used to score subjective questions and cannot be the same as test models", "judgeModel": "Judge Model", "filterByType": "Filter by Question Type", "filterByTypeHint": "Leave empty to use all questions", "selectedQuestions": "Selected Questions", "questions": "questions", "hasSubjectiveHint": "Contains subjective questions (short answer/open-ended), a judge model is required", "hasSubjective": "Has Subjective", "startEval": "Start Evaluation", "progress": "Progress", "totalQuestions": "Questions", "status": "Status", "totalScore": "Total Score", "correctCount": "Correct", "accuracy": "Accuracy", "statsByType": "Statistics by Type", "resultDetails": "Result Details", "question": "Question", "questionType": "Type", "result": "Result", "score": "Score", "correctAnswer": "Correct Answer", "modelAnswer": "Model Answer", "judgeResponse": "Judge Response", "interrupt": "Interrupt", "statusProcessing": "Processing", "statusCompleted": "Completed", "statusFailed": "Failed", "statusInterrupted": "Interrupted", "deleteConfirmTitle": "Confirm Delete", "deleteConfirmMessage": "Are you sure you want to delete this evaluation task? All results will also be deleted.", "interruptConfirmTitle": "Confirm Interrupt", "interruptConfirmMessage": "Are you sure you want to interrupt this task? Completed results will be preserved.", "errorNoModels": "Please select at least one test model", "errorNoQuestions": "No evaluation questions available", "errorNoJudgeModel": "Subjective questions exist, please select a judge model", "errorJudgeSameAsTest": "Judge model cannot be the same as test models", "errorCreateFailed": "Failed to create evaluation task", "errorLoadFailed": "Failed to load evaluation tasks", "errorDeleteFailed": "Failed to delete evaluation task", "errorInterruptFailed": "Failed to interrupt evaluation task", "statusSuccess": "Success", "statusFormatError": "Format Error", "statusApiError": "API Error", "statusUnknown": "Unknown Status", "duration": "Duration", "answerStatus": "Answer Status", "modelInfo": "Model Info", "reportTitle": "Model Evaluation Report", "taskIdLabel": "Task ID", "pageInfo": "Page: {{page}} / {{totalPages}}", "noMatchingResults": "No evaluation results match the current filters", "reportFooter": "Easy Dataset Evaluation System · Generated by AI", "finalSelection": "Final Selection: ", "questionsSuffix": " questions", "noModelsAvailable": "No models available, please configure models in settings first", "filterTitle": "Question Filter", "clearFilter": "Clear Filter", "searchKeyword": "Search Keyword", "searchPlaceholder": "Search question or answer content...", "filterByTypeLabel": "Filter by Type", "filterByTagLabel": "Filter by Tag", "questionCountLabel": "Question Count: ", "useAllQuestions": "Use all filtered results", "randomSampleHint": "Randomly sample {{questionCount}} from {{filteredCount}} questions", "durationFormat": "({{time}}s)", "totalQuestionsLabel": "Total", "correctLabel": "Correct", "incorrectLabel": "Incorrect", "judgeComment": "AI Judge Comment:", "scoreUnit": " pts", "shortAnswer": "Short Answer", "openEnded": "Open-ended", "scoreAnchorsTitle": "{{type}} Scoring Rules", "customizable": "Customizable", "scoreAnchorsHint": "Customize scoring criteria to guide LLM in evaluating model responses", "restoreDefault": "Restore Default", "scoreRange": "Score Range", "scoreDescriptionPlaceholder": "Enter scoring criteria description for this range..." }, "blindTest": { "title": "Human Blind Test", "createTitle": "Create Blind Test", "createTask": "Create Task", "noTasks": "No blind test tasks", "noTasksHint": "Create a blind test task to compare two models' answer quality", "selectModels": "Select Models to Compare", "modelA": "Model A", "modelB": "Model B", "modelComparison": "Model Comparison", "selectQuestions": "Select Test Questions", "questionType": "Question Type", "questionTypeHint": "Blind test only supports short answer and open-ended questions", "filterByTag": "Filter by Tag", "questionCount": "Question Count", "availableQuestions": "Available: {{count}} questions", "useAllQuestions": "Use all filtered results", "randomSample": "Randomly sample {{count}} questions", "startBlindTest": "Start Blind Test", "creating": "Creating...", "noModelsAvailable": "No models available, please configure models in settings first", "errorSelectModelA": "Please select Model A", "errorSelectModelB": "Please select Model B", "errorSameModel": "Two models cannot be the same", "errorNoQuestions": "No questions match the criteria", "statusProcessing": "In Progress", "statusCompleted": "Completed", "statusFailed": "Failed", "statusInterrupted": "Interrupted", "progress": "Progress", "viewDetails": "View Details", "continue": "Continue Test", "interrupt": "Interrupt Task", "deleteConfirmTitle": "Confirm Delete", "deleteConfirmMessage": "Are you sure you want to delete this blind test task? This action cannot be undone.", "interruptConfirmTitle": "Confirm Interrupt", "interruptConfirmMessage": "Are you sure you want to interrupt this task? Completed results will be preserved.", "inProgress": "Blind Test In Progress", "generatingAnswers": "Generating answers...", "question": "Question", "answerA": "Answer A", "answerB": "Answer B", "duration": "Duration", "whichBetter": "Which answer is better?", "leftBetter": "Left is Better", "rightBetter": "Right is Better", "bothGood": "Both Good", "bothBad": "Both Bad", "loadQuestion": "Load Question", "taskNotFound": "Task not found", "resultTitle": "Blind Test Results", "resultSummary": "Result Summary", "wins": "Wins", "times": "times", "totalQuestions": "Total Questions", "ties": "Ties", "detailResults": "Detailed Results", "left": "Left", "right": "Right" } } ================================================ FILE: locales/pt-BR/translation.json ================================================ { "language": { "switchToEnglish": "Mudar para Inglês", "switchToChinese": "Mudar para Chinês", "switcherTitle": "Mudar Idioma / Change Language / Dil Değiştir", "english": "Inglês", "chineseSimplified": "Chinês Simplificado", "turkish": "Turco", "en": "EN", "zh": "中" }, "theme": { "switchToLight": "Mudar para Modo Claro", "switchToDark": "Mudar para Modo Escuro" }, "settings": { "promptConfig": "Configuração de Prompts", "promptsDescription": "Configure vários prompts personalizados usados no projeto para intervenção manual na geração do conjunto de dados.", "globalPrompt": "Prompt Global", "questionPrompt": "Prompt de Geração de Perguntas", "answerPrompt": "Prompt de Geração de Respostas", "labelPrompt": "Prompt de Rotulação de Perguntas", "domainTreePrompt": "Prompt de Construção da Árvore de Domínio", "globalPromptPlaceholder": "Por favor, insira o prompt global (use com cautela, pode afetar a geração geral)", "questionPromptPlaceholder": "Por favor, insira o prompt personalizado para geração de perguntas", "answerPromptPlaceholder": "Por favor, insira o prompt personalizado para geração de respostas", "labelPromptPlaceholder": "Por favor, insira o prompt personalizado para rotulação de perguntas (configuração temporariamente não suportada)", "domainTreePromptPlaceholder": "Por favor, insira o prompt personalizado para construção da árvore de domínio", "cleanPrompt": "Prompt de Limpeza de Dados", "cleanPromptPlaceholder": "Por favor, insira o prompt personalizado para limpeza de dados", "loadPromptsFailed": "Falha ao carregar configuração de prompts", "savePromptsSuccess": "Configuração de prompts salva com sucesso", "savePromptsFailed": "Falha ao salvar configuração de prompts", "title": "Configurações do Projeto", "basicInfo": "Informações Básicas", "modelConfig": "Configuração do Modelo", "taskConfig": "Configuração de Tarefas", "tabsAriaLabel": "Abas de Configurações", "idNotEditable": "ID do Projeto não pode ser editado", "saveBasicInfo": "Salvar Informações Básicas", "saveSuccess": "Salvo com sucesso", "saveFailed": "Falha ao salvar", "deleteSuccess": "Excluído com sucesso", "deleteFailed": "Falha ao excluir", "fetchTasksFailed": "Falha ao obter configuração de tarefas", "saveTasksFailed": "Falha ao salvar configuração de tarefas", "textSplitSettings": "Configurações de Divisão de Texto", "minLength": "Comprimento Mínimo", "maxLength": "Comprimento Máximo de Divisão", "textSplitDescription": "Ajuste o intervalo de comprimento da divisão de texto, afeta a granularidade do resultado da divisão", "splitType": "Estratégia de Divisão", "splitTypeMarkdown": "Divisão por Estrutura de Documento (Markdown)", "splitTypeMarkdownDesc": "Divide texto automaticamente com base nos títulos do documento, mantendo a integridade semântica, adequado para documentos Markdown com estrutura clara", "splitTypeRecursive": "Divisão por Estrutura de Texto (Separadores Personalizados)", "splitTypeRecursiveDesc": "Tenta recursivamente separadores de múltiplos níveis (configuráveis), usando primeiro separadores de alta prioridade, depois separadores secundários, adequado para documentos complexos", "splitTypeText": "Divisão de Comprimento Fixo (Caracteres)", "splitTypeTextDesc": "Divide texto pelo separador especificado (configurável), depois combina pelo comprimento especificado, adequado para arquivos de texto comuns", "splitTypeToken": "Divisão de Comprimento Fixo (Token)", "splitTypeTokenDesc": "Divide com base na contagem de Tokens (não caracteres)", "splitTypeCode": "Divisão Inteligente de Código de Programa", "splitTypeCodeDesc": "Realiza divisão inteligente com base na estrutura sintática de diferentes linguagens de programação, evitando divisões em locais com sintaxe incompleta", "splitTypeCustom": "Divisão por Símbolos Personalizados", "splitTypeCustomDesc": "Divide documentos com base em símbolos personalizados, os separadores serão descartados, os blocos de texto divididos não são afetados pelo tamanho do bloco", "codeLanguage": "Linguagem de Código", "codeLanguageHelper": "Selecione a linguagem de código para divisão, a divisão inteligente será realizada de acordo com as características da linguagem", "chunkSize": "Tamanho do Bloco", "chunkOverlap": "Comprimento de Sobreposição do Bloco", "separator": "Separador", "separatorHelper": "Separador usado para dividir texto, como \n\n representa linha em branco", "customSeparator": "Separador Personalizado", "customSeparatorHelper": "Separador personalizado usado para dividir texto, como --- ou ===", "separators": "Lista de Separadores", "separatorsInput": "Separadores (separados por vírgula)", "separatorsHelper": "Lista de separadores separados por vírgula, ordenados por prioridade", "questionGenSettings": "Configurações de Geração de Perguntas", "questionGenLength": "Gerar uma pergunta a cada {{length}} caracteres", "questionMaskRemovingProbability": "Remover {{probability}}% dos pontos de interrogação no final das perguntas", "questionGenDescription": "Definir o comprimento máximo para geração de perguntas", "concurrencyLimit": "Limite de Concorrência", "concurrencyLimitHelper": "Limitar a quantidade de tarefas simultâneas para geração de perguntas e conjunto de dados", "saveTaskConfig": "Salvar Configuração de Tarefas", "pdfSettings": "Configuração de Conversão de Arquivos PDF", "minerUToken": "Configuração de Token para Conversão de PDF (MinerU API)", "minerUHelper": "O Token do MinerU tem validade de apenas 14 dias, por favor, substitua o Token a tempo", "minerULocalUrl": "Configuração de URL para Conversão de PDF (MinerU Local)", "vision": "Seleção de Modelo de Visão Personalizado", "visionConcurrencyLimit": "Limite de Concorrência do Modelo de Visão", "huggingfaceSettings": "Configurações do Hugging Face", "huggingfaceToken": "Token do Hugging Face", "multiTurnSettings": "Configurações de Conjunto de Dados de Diálogo de Múltiplas Rodadas", "multiTurnSystemPrompt": "Prompt do Sistema", "multiTurnSystemPromptHelper": "Definir a identidade e normas de comportamento do assistente de IA", "multiTurnScenario": "Cenário de Diálogo", "multiTurnScenarioHelper": "Descrever o cenário específico e objetivo do diálogo", "multiTurnRounds": "Número de Rodadas de Diálogo: {{rounds}} rodadas", "multiTurnRoleA": "Configuração do Personagem A (Usuário)", "multiTurnRoleAHelper": "Definir a identidade e características do papel do usuário", "multiTurnRoleB": "Configuração do Personagem B (Assistente)", "multiTurnRoleBHelper": "Definir a identidade e características do papel do assistente", "multiTurnDescription": "A configuração de diálogo de múltiplas rodadas é usada para gerar conjuntos de dados de diálogo coerentes de múltiplas rodadas, suporta personalização de personagens e cenários", "evalQuestionSettings": "Configurações de Geração de Conjunto de Teste", "evalQuestionSettingsDescription": "Configure a proporção de cada tipo de questão ao gerar conjunto de teste, proporção 0 significa não gerar esse tipo de questão", "evalTrueFalseRatio": "Proporção de Questões de Verdadeiro/Falso", "evalSingleChoiceRatio": "Proporção de Questões de Múltipla Escolha", "evalMultipleChoiceRatio": "Proporção de Questões de Múltipla Escolha (Várias Respostas)", "evalShortAnswerRatio": "Proporção de Respostas Curtas Fixas", "evalOpenEndedRatio": "Proporção de Respostas Abertas", "evalQuestionRatioHelper": "O sistema alocará automaticamente a quantidade de geração de cada tipo de questão de acordo com as proporções definidas, a soma de todas as proporções não precisa ser igual a um valor específico", "prompts": { "selectPromptFirst": "Por favor, selecione um prompt à esquerda", "customized": "Personalizado", "editPrompt": "Editar Prompt", "restoreDefault": "Restaurar Padrão", "promptType": "Tipo de Prompt", "keyName": "Nome da Chave", "contentPlaceholder": "Por favor, insira o conteúdo do prompt personalizado...", "restoreDefaultContent": "Restaurar conteúdo padrão", "noPromptsAvailable": "Nenhum prompt disponível", "restoreSuccess": "Restaurado para o prompt padrão", "restoreFailed": "Falha ao restaurar o prompt padrão", "deleteError": "Erro ao excluir prompt:", "saveSuccess": "Prompt salvo com sucesso", "saveFailed": "Falha ao salvar prompt", "saveError": "Erro ao salvar prompt:", "createCustomPrompt": "Criar Prompt Personalizado", "fetchContentError": "Falha ao obter o conteúdo mais recente do prompt:" } }, "questions": { "autoGenerateDataset": "Gerar Conjunto de Dados Automaticamente", "autoGenerateDatasetTip": "Criar tarefa de processamento em lote em segundo plano: consultar automaticamente perguntas pendentes de geração de respostas e gerar conjunto de dados", "generateSingleTurnDataset": "Gerar Conjunto de Dados de Diálogo de Rodada Única", "generateSingleTurnDatasetDesc": "Gerar conjunto de dados de perguntas e respostas com base nas perguntas", "generateMultiTurnDataset": "Gerar Conjunto de Dados de Diálogo de Múltiplas Rodadas", "generateImageDataset": "Gerar Conjunto de Dados de Perguntas e Respostas de Imagens", "generateMultiTurnDatasetDesc": "Gerar conjunto de dados de diálogo de múltiplas rodadas com base nas perguntas", "multiTurnNotConfigured": "Por favor, configure primeiro os parâmetros relacionados a diálogo de múltiplas rodadas nas configurações do projeto", "filterAll": "Todas as Perguntas", "filterAnswered": "Respostas Geradas", "filterUnanswered": "Respostas Não Geradas", "filterChunkNamePlaceholder": "Filtrar por nome do bloco de texto...", "sourceTypeAll": "Todas as Fontes de Dados", "sourceTypeText": "Fonte de Dados de Texto", "sourceTypeImage": "Fonte de Dados de Imagem", "title": "Perguntas", "confirmDeleteTitle": "Confirmar Exclusão de Pergunta", "confirmDeleteContent": "Tem certeza de que deseja excluir a pergunta \"{{question}}\"? Esta ação não pode ser desfeita.", "deleting": "Excluindo pergunta...", "batchDeleteTitle": "Confirmar Exclusão em Lote de Perguntas", "batchDeleting": "Excluindo {{count}} perguntas...", "deleteSuccess": "Pergunta excluída com sucesso", "deleteFailed": "Falha ao excluir pergunta", "batchDeleteSuccess": "{{count}} perguntas excluídas com sucesso", "batchDeletePartial": "Exclusão concluída, sucesso: {{success}}, falha: {{failed}}", "batchDeleteFailed": "Falha na exclusão em lote de perguntas", "noQuestionsSelected": "Por favor, selecione perguntas primeiro", "batchGenerateStart": "Iniciando geração de conjunto de dados para {{count}} perguntas", "invalidQuestionKey": "Chave de pergunta inválida", "listView": "Lista de Perguntas", "treeView": "Visualização em Árvore de Domínio", "selectAll": "Selecionar Todos", "selectedCount": "{{count}} perguntas selecionadas", "totalCount": "Total de {{count}} perguntas", "searchPlaceholder": "Pesquisar perguntas ou tags...", "searchMatch": "Correspondência", "searchNotMatch": "Não Corresponde", "deleteSelected": "Excluir Selecionados", "batchGenerate": "Construir Conjunto de Dados em Lote", "generating": "Gerando conjunto de dados", "generatingProgress": "Concluído: {{completed}}/{{total}}", "generatedCount": "{{count}} conjuntos de dados gerados", "pleaseWait": "Por favor, aguarde...", "selectAllLimitReached": "{{count}} perguntas selecionadas (limite máximo atingido)", "selectAllFailed": "Falha na operação de seleção total, por favor, tente novamente mais tarde", "createSuccess": "Pergunta criada com sucesso", "updateSuccess": "Pergunta atualizada com sucesso", "operationSuccess": "Operação bem-sucedida", "operationFailed": "Falha na operação", "editQuestion": "Editar Pergunta", "questionContent": "Conteúdo da Pergunta", "sourceType": "Tipo de Fonte de Dados", "sourceType.text": "Texto", "sourceType.image": "Imagem", "selectChunk": "Selecionar Bloco de Texto", "searchChunk": "Pesquisar bloco de texto...", "selectImage": "Selecionar Imagem", "searchImage": "Pesquisar imagem...", "selectTag": "Selecionar Tag", "searchTag": "Pesquisar tag...", "createQuestion": "Criar Pergunta", "createNormalQuestion": "Criar Pergunta Normal", "createQuestionTemplate": "Criar Modelo de Pergunta", "questionPlaceholder": "Por favor, insira o conteúdo da pergunta", "noChunkSelected": "Por favor, selecione um bloco de texto primeiro", "fetchTemplatesFailed": "Falha ao obter modelos de pergunta", "createTemplateSuccess": "Modelo de pergunta criado com sucesso", "createTemplateFailed": "Falha ao criar modelo de pergunta", "updateTemplateSuccess": "Modelo de pergunta atualizado com sucesso", "updateTemplateFailed": "Falha ao atualizar modelo de pergunta", "deleteTemplateSuccess": "Modelo de pergunta excluído com sucesso", "deleteTemplateFailed": "Falha ao excluir modelo de pergunta", "exportQuestions": "Exportar Conjunto de Perguntas", "exportScope": "Escopo de Exportação", "exportAll": "Exportar Todos ({{count}} perguntas)", "exportSelected": "Exportar Selecionados ({{count}} perguntas)", "exportFormat": "Formato de Exportação", "txtFormat": "Texto Puro (apenas conteúdo da pergunta)", "exportSuccess": "Conjunto de perguntas exportado com sucesso", "exportFailed": "Falha ao exportar conjunto de perguntas", "template": { "management": "Modelo de Pergunta", "create": "Criar Modelo de Pergunta", "edit": "Editar Modelo de Pergunta", "question": "Conteúdo da Pergunta", "description": "Prompt", "descriptionHelp": "Usado para adicionar ao prompt geral quando a IA gerar respostas relacionadas a este modelo de pergunta posteriormente, para intervir no resultado final da geração de respostas", "noTemplates": "Nenhum modelo de pergunta disponível, clique no botão criar para adicionar", "deleteConfirm": "Tem certeza de que deseja excluir este modelo de pergunta?", "used": "Usado", "addLabel": "Adicionar Tag", "customFormat": "Formato Personalizado", "customFormatHelp": "Insira restrições de saída no formato JSON", "customFormatInfo": "Este formato será fornecido como prompt ao modelo grande, usado para restringir o formato de saída", "sourceTypeInfo": "Tipo de Fonte de Dados", "sourceType": { "label": "Tipo de Fonte de Dados", "image": "Imagem (usado para gerar QA de imagens)", "text": "Texto (usado para gerar QA de blocos de texto)" }, "answerType": { "label": "Formato de Saída da Resposta", "text": "Texto Normal", "tags": "Array de Tags", "customFormat": "Formato Personalizado" }, "errors": { "questionRequired": "Por favor, insira o conteúdo da pergunta", "labelsRequired": "Perguntas do tipo tag precisam de pelo menos uma tag", "customFormatRequired": "Por favor, insira o formato personalizado", "invalidJson": "Formato JSON incorreto" }, "autoGenerate": "Gerar perguntas automaticamente após criar modelo", "autoGenerateHelpText": "Criará automaticamente perguntas baseadas neste modelo para todos os blocos de texto no projeto", "autoGenerateHelpImage": "Criará automaticamente perguntas baseadas neste modelo para todas as imagens no projeto", "confirmAutoGenerate": "Confirmar geração automática de perguntas", "confirmAutoGenerateTextMessage": "Você selecionou gerar perguntas automaticamente. O sistema criará perguntas baseadas neste modelo para todos os blocos de texto no projeto.", "confirmAutoGenerateImageMessage": "Você selecionou gerar perguntas automaticamente. O sistema criará perguntas baseadas neste modelo para todas as imagens no projeto.", "autoGenerateWarning": "Esta operação pode criar um grande número de perguntas, por favor, confirme antes de continuar.", "autoGenerateSuccess": "Perguntas criadas com sucesso para {{count}} fontes de dados", "autoGeneratePartialFail": "{{success}} perguntas criadas com sucesso, {{fail}} falhas", "autoGenerateFailed": "Falha na geração automática de perguntas" }, "noTagSelected": "Por favor, selecione uma tag", "deleteConfirm": "Tem certeza de que deseja excluir esta pergunta?", "generateMultiTurn": "Gerar Diálogo de Múltiplas Rodadas", "multiTurnGenerated": "Conjunto de dados de diálogo de múltiplas rodadas gerado com sucesso!" }, "common": { "dataSource": "Fonte de Dados", "menu": "Menu", "openMenu": "Abrir Menu de Navegação", "all": "Todos", "jumpTo": "Ir para", "unknownError": "Erro Desconhecido", "create": "Criar", "confirm": "Confirmar", "edit": "Editar", "delete": "Excluir", "save": "Salvar", "cancel": "Cancelar", "complete": "Concluir", "close": "Fechar", "add": "Adicionar", "remove": "Remover", "loading": "Carregando...", "yes": "Sim", "no": "Não", "confirmDelete": "Confirmar exclusão? Esta ação não pode ser desfeita!", "saving": "Salvando...", "deleting": "Excluindo...", "actions": "Ações", "confirmDeleteDataSet": "Confirmar exclusão do conjunto de dados? A operação não pode ser desfeita!", "noData": "Nenhum", "failed": "Falhou", "success": "Sucesso", "backToList": "Voltar para Lista", "label": "Tag", "confirmDeleteDescription": "Confirmar exclusão? Esta ação não pode ser recuperada.", "more": "Mais", "import": "Importar", "export": "Exportar", "fetchError": "Erro ao obter dados", "confirmDeleteQuestion": "Confirmar exclusão desta pergunta? Esta ação não pode ser recuperada.", "deleteSuccess": "Excluído com sucesso", "visitGitHub": "Visitar Repositório GitHub", "syncOldData": "Sincronizar Dados de Arquivos", "copy": "Copiar", "copied": "Copiado", "enabled": "Ativado", "disabled": "Desativado", "generating": "Gerando...", "processing": "Processando...", "items": "itens", "detailInfo": "Informações Detalhadas", "reset": "Redefinir", "apply": "Aplicar", "mainNavigation": "Navegação Principal", "goHome": "Voltar para Início", "goToHomePage": "Voltar para Página Inicial", "mobileNavigation": "Menu de Navegação Móvel", "navigation": "Navegação", "closeMenu": "Fechar Menu", "documentation": "Documentação", "viewOnGitHub": "Ver no GitHub", "back": "Voltar", "refresh": "Atualizar", "expand": "Expandir Todos", "collapse": "Recolher Conteúdo" }, "home": { "title": "Easy Dataset", "subtitle": "Uma poderosa ferramenta de criação de conjunto de dados para ajuste fino de grandes modelos de linguagem", "createProject": "Criar Projeto", "searchDataset": "Pesquisar Conjunto de Dados Público" }, "projects": { "reuseConfig": "Reutilizar Configuração do Modelo", "noReuse": "Não Reutilizar Configuração", "selectProject": "Selecionar Projeto", "fetchFailed": "Falha ao obter lista de projetos", "fetchError": "Erro ao obter lista de projetos", "loading": "Carregando seus projetos...", "createFailed": "Falha ao criar projeto", "createError": "Erro ao criar projeto", "createNew": "Criar Novo Projeto", "saveFailed": "Falha ao salvar projeto", "id": "ID do Projeto", "name": "Nome do Projeto", "description": "Descrição do Projeto", "questions": "Perguntas", "datasets": "Conjuntos de Dados", "evalDatasets": "Conjuntos de Avaliação", "tokens": "Tokens", "lastUpdated": "Última Atualização", "viewDetails": "Ver Detalhes", "createFirst": "Criar Primeiro Projeto", "noProjects": "Nenhum Projeto", "notExist": "Projeto não existe", "createProject": "Criar Projeto", "deleteConfirm": "Confirmar exclusão do projeto? Esta ação não pode ser recuperada.", "deleteSuccess": "Projeto excluído com sucesso", "deleteFailed": "Falha ao excluir projeto", "backToHome": "Voltar para Início", "deleteConfirmTitle": "Confirmar Exclusão do Projeto", "title": "Gerenciamento de Projetos", "openDirectory": "Abrir Diretório do Projeto" }, "textSplit": { "dragToUpload": "Arraste arquivos para cá para fazer upload", "fileList": "Lista de Arquivos", "autoGenerateQuestions": "Extrair Perguntas Automaticamente", "autoGenerateQuestionsTip": "Criar tarefa de processamento em lote em segundo plano: consultar automaticamente blocos de texto pendentes de geração de perguntas e extrair perguntas", "exportChunks": "Exportar Blocos de Texto", "allChunks": "Todos os Blocos de Texto", "generatedQuestions2": "Perguntas Geradas", "ungeneratedQuestions": "Perguntas Não Geradas", "contentKeyword": "Conteúdo do Bloco de Texto", "contentKeywordPlaceholder": "Insira palavras-chave para pesquisar conteúdo do bloco de texto", "characterRange": "Intervalo de Caracteres", "questionStatus": "Status da Pergunta", "noFilesUploaded": "Nenhum arquivo enviado ainda", "unknownFile": "Arquivo Desconhecido", "fetchFilesFailed": "Erro ao obter lista de arquivos", "editTag": "Editar Tag", "deleteTag": "Excluir Tag", "addTag": "Adicionar Tag", "selectedCount": "{{count}} blocos de texto selecionados", "totalCount": "Total de {{count}} blocos de texto", "batchGenerateQuestions": "Gerar Perguntas em Lote", "batchDeleteChunks": "Excluir em Lote", "batchDeleteChunksConfirmTitle": "Confirmar Exclusão em Lote", "batchDeleteChunksConfirmMessage": "Tem certeza de que deseja excluir os {{count}} blocos de texto selecionados? Esta ação não pode ser desfeita.", "uploadedDocuments": "{{count}} documentos enviados", "title": "Processamento de Arquivos", "uploadNewDocument": "Enviar Novo Arquivo", "selectFile": "Selecionar Arquivo (suporta múltiplos)", "markdownOnly": "Atualmente suporta apenas arquivos no formato Markdown (.md) (recomenda-se enviar arquivos do mesmo domínio)", "supportedFormats": "Formatos suportados: .pdf .md, .txt, .docx (recomenda-se enviar arquivos do mesmo domínio)", "uploadAndProcess": "Enviar e Processar Arquivo", "selectedFiles": "Arquivos Selecionados ({{count}})", "oneFileMessage": "Um projeto limita o processamento a um arquivo, se precisar enviar um novo arquivo, por favor, exclua o arquivo existente primeiro", "mutilFileMessage": "A árvore de domínio será reconstruída após enviar novo arquivo", "noChunks": "Nenhum bloco de texto ainda, por favor, envie e processe o arquivo primeiro", "chunkDetails": "Detalhes do Bloco de Texto: {{chunkId}}", "fetchChunksFailed": "Falha ao obter blocos de texto", "fetchChunksError": "Erro ao obter blocos de texto", "fileResultReceived": "Resultado do arquivo recebido", "fileUploadSuccess": "Arquivo enviado com sucesso", "splitTextFailed": "Falha na divisão de texto", "splitTextError": "Erro na divisão de texto", "deleteChunkFailed": "Falha ao excluir bloco de texto", "deleteChunkError": "Erro ao excluir bloco de texto", "selectModelFirst": "Por favor, selecione um modelo primeiro, pode selecionar na barra de navegação superior", "modelNotAvailable": "O modelo selecionado não está disponível, por favor, selecione novamente", "generateQuestionsFailed": "Falha ao gerar perguntas para o bloco de texto {{chunkId}}", "questionsGenerated": "{{total}} perguntas geradas", "customSplitMode": "Modo de Divisão Personalizado", "customSplitInstructions": "Selecione o conteúdo do texto para adicionar pontos de divisão. O sistema adicionará marcadores de divisão na posição selecionada.", "splitPointsList": "Pontos de Divisão Adicionados", "saveSplitPoints": "Salvar Pontos de Divisão", "confirmCustomSplitTitle": "Confirmar Substituição da Divisão Original", "confirmCustomSplitMessage": "Atenção: A divisão personalizada substituirá o resultado da divisão automática anterior deste arquivo. Tem certeza de que deseja continuar salvando?", "customSplitSuccess": "Divisão personalizada salva com sucesso", "customSplitFailed": "Falha ao salvar divisão personalizada", "missingRequiredData": "Dados necessários ausentes", "chunksPreview": "Pré-visualização do Tamanho dos Blocos", "chunk": "Bloco de Texto", "characters": "caracteres", "questionsGeneratedSuccess": "{{total}} perguntas geradas com sucesso para o bloco de texto", "generateQuestionsForChunkFailed": "Falha ao gerar perguntas para o bloco de texto {{chunkId}}", "generateQuestionsForChunkError": "Erro ao gerar perguntas para o bloco de texto {{chunkId}}", "generateQuestionsError": "Erro ao gerar perguntas", "partialSuccess": "Geração de perguntas parcialmente bem-sucedida para blocos de texto ({{successCount}}/{{total}}), {{errorCount}} blocos de texto falharam", "allSuccess": "{{totalQuestions}} perguntas geradas com sucesso para {{successCount}} blocos de texto", "fileDeleted": "Arquivo {{fileName}} excluído, atualizando lista de blocos de texto", "tabs": { "smartSplit": "Divisão Inteligente", "domainAnalysis": "Análise de Domínio" }, "loading": "Carregando...", "fetchingDocuments": "Obtendo dados de arquivos", "processing": "Processando...", "progressStatus": "{{total}} blocos de texto selecionados, {{completed}} processados", "processingPleaseWait": "Processando, por favor, aguarde!", "oneFileLimit": "Arquivo já enviado, não é permitido selecionar novo arquivo", "unsupportedFormat": "Formato de arquivo não suportado: {{files}}", "modelInfoParseError": "Falha ao analisar informações do modelo", "uploadFailed": "Falha no upload, por favor, atualize a página e tente novamente!", "uploadSuccess": "{{count}} arquivo(s) enviado(s) com sucesso", "deleteFailed": "Falha ao excluir arquivo", "deleteSuccess": "Arquivo {{fileName}} excluído com sucesso", "generatedQuestions": "{{count}} perguntas geradas", "generatedEvalQuestions": "{{count}} questões de teste geradas", "generateQuestions": "Gerar Perguntas", "generateEvalQuestions": "Gerar Conjunto de Teste", "evalQuestionsGeneratedSuccess": "{{total}} questões de avaliação geradas com sucesso", "generateEvalQuestionsFailed": "Falha ao gerar questões de avaliação", "dataCleaning": "Limpeza de Dados", "batchDataCleaning": "Limpeza de Dados em Lote", "autoDataCleaning": "Limpeza de Dados Automática", "autoDataCleaningTip": "Criar tarefa de processamento em lote em segundo plano: limpar automaticamente todos os blocos de texto", "autoEvalGeneration": "Geração Automática de Conjunto de Avaliação", "autoEvalGenerationTip": "Criar tarefa de processamento em lote em segundo plano: gerar automaticamente conjunto de dados de avaliação para todos os blocos de texto sem questões de avaliação geradas", "autoTasks": "Tarefas Automáticas", "dataCleaningSuccess": "Limpeza de dados concluída, comprimento original: {{originalLength}}, comprimento após limpeza: {{cleanedLength}}", "dataCleaningFailed": "Falha na limpeza de dados para o bloco de texto {{chunkId}}", "dataCleaningForChunkSuccess": "Limpeza de dados concluída para o bloco de texto {{chunkId}}", "dataCleaningForChunkFailed": "Falha na limpeza de dados para o bloco de texto {{chunkId}}", "dataCleaningForChunkError": "Erro na limpeza de dados para o bloco de texto {{chunkId}}", "dataCleaningPartialSuccess": "Limpeza de dados parcialmente bem-sucedida para blocos de texto ({{successCount}}/{{total}}), {{errorCount}} blocos de texto falharam", "dataCleaningAllSuccess": "Limpeza de dados concluída com sucesso para {{successCount}} blocos de texto", "charsCount": "caracteres", "pdfProcess": "Arquivo PDF detectado, por favor, selecione o método de processamento do arquivo PDF!", "pdfProcessStatus": "Total de {{total}} arquivo(s), {{completed}} processado(s)", "pdfPageProcessStatus": "Processando {{fileName}} total de {{total}} páginas, {{completed}} páginas convertidas", "pdfProcessing": "Convertendo arquivo...", "pdfProcessingFailed": "Falha no processamento do arquivo!", "selectPdfProcessingStrategy": "Por favor, selecione o método de processamento do arquivo PDF:", "pdfProcessingStrategyDefault": "Padrão", "pdfProcessingStrategyDefaultHelper": "Usar estratégia de análise de PDF integrada", "pdfProcessingStrategyMinerUHelper": "Usar análise MinerU API, por favor, configure o Token da API MinerU primeiro", "pdfProcessingStrategyVision": "Modelo de Visão Personalizado", "pdfProcessingStrategyVisionHelper": "Usar modelo de visão personalizado para análise", "pdfProcessingToast": "Arquivo enviado com sucesso, o sistema criará tarefa em segundo plano para analisar o arquivo!", "pdfProcessingLoading": "Executando tarefa de processamento de arquivo, por favor, aguarde a conclusão da tarefa antes de enviar novo arquivo...", "pdfProcessingWaring": "Executando tarefa de processamento de arquivo, recomenda-se aguardar a conclusão da tarefa antes de realizar outras operações, caso contrário, pode afetar a qualidade da geração de dados!", "basicPdfParsing": "Análise Básica de PDF", "basicPdfParsingDesc": "Pode reconhecer arquivos PDF simples, incluindo estrutura de diretório principal, velocidade mais rápida", "mineruApiDesc": "Pode reconhecer arquivos PDF complexos, incluindo fórmulas, gráficos (necessita configurar MinerU API Key)", "mineruLocalDesc": "Pode reconhecer arquivos PDF complexos, incluindo fórmulas, gráficos (necessita configurar MinerU Local URL)", "mineruApiDescDisabled": "Por favor, vá para [Configuração do Projeto - Configuração de Tarefas] para definir o Token do MinerU primeiro", "mineruLocalDisabled": "Por favor, vá para [Configuração do Projeto - Configuração de Tarefas] para definir o URL do MinerU Local primeiro", "mineruWebPlatform": "Análise da Plataforma Online MinerU", "mineruWebPlatformDesc": "Pode reconhecer arquivos PDF complexos, incluindo fórmulas, gráficos (necessita ir para outro site)", "mineruSelected": "MinerU selecionado para análise de PDF", "mineruLocalSelected": "MinerU Local selecionado para análise de PDF", "customVisionModel": "Análise por Modelo de Visão Personalizado", "customVisionModelDesc": "Pode reconhecer arquivos PDF complexos, incluindo fórmulas, gráficos (necessita adicionar configuração de modelo de visão na configuração do modelo)", "customVisionModelSelected": "Modelo de visão grande {{name}} ({{provider}}) selecionado para análise de PDF", "defaultSelected": "Estratégia padrão integrada selecionada para análise de PDF", "download": "Baixar Arquivo", "deleteFile": "Excluir Arquivo", "batchDelete": "Excluir em Lote ({{count}})", "batchDeleteTitle": "Exclusão em Lote de Arquivos", "batchDeleteConfirm": "Tem certeza de que deseja excluir os {{count}} arquivos selecionados? Esta ação não pode ser desfeita.", "batchDeleteSuccess": "{{count}} arquivo(s) excluído(s) com sucesso", "batchDeleteFailed": "Falha na exclusão em lote", "searchFiles": "Pesquisar nome do arquivo...", "searchResults": "{{count}} arquivo(s) encontrado(s) (total de {{total}})", "noSearchResults": "Nenhum arquivo contendo \"{{searchTerm}}\" encontrado", "noResultsOnCurrentPage": "Nenhum resultado de pesquisa na página atual, por favor, volte para a primeira página para visualizar", "noDataOnCurrentPage": "Nenhum dado na página atual", "viewChunk": "Ver Bloco de Texto", "editChunk": "Editar Bloco de Texto {{chunkId}}", "editChunkSuccess": "Bloco de texto editado com sucesso", "editChunkFailed": "Falha ao editar bloco de texto", "editChunkError": "Erro ao editar bloco de texto", "deleteFileWarning": "Aviso: A exclusão do arquivo também excluirá o seguinte conteúdo relacionado", "deleteFileWarningChunks": "Todos os blocos de texto associados", "deleteFileWarningQuestions": "Todas as perguntas geradas pelos blocos de texto", "deleteFileWarningDatasets": "Todos os conjuntos de dados gerados pelas perguntas", "domainTree": { "firstUploadTitle": "Geração da Árvore de Domínio", "uploadTitle": "Envio de Arquivo - Processamento da Árvore de Domínio", "deleteTitle": "Exclusão de Arquivo - Processamento da Árvore de Domínio", "reviseOption": "Revisar Árvore de Domínio", "reviseDesc": "Corrigir a árvore de domínio atual com base nas informações de arquivos adicionados ou excluídos, afetando apenas as partes alteradas", "rebuildOption": "Reconstruir Árvore de Domínio", "rebuildDesc": "Regenerar a árvore de domínio completa com base nas informações de diretório de todos os arquivos", "keepOption": "Manter Inalterado", "keepDesc": "Manter a estrutura atual da árvore de domínio inalterada, sem fazer modificações" } }, "domain": { "title": "Árvore de Conhecimento de Domínio", "addRootTag": "Adicionar Tag de Primeiro Nível", "addFirstTag": "Adicionar Primeira Tag", "noTags": "Nenhum dado de árvore de tags de domínio disponível", "docStructure": "Estrutura de Diretório do Documento", "noToc": "Nenhuma estrutura de diretório disponível, por favor, envie e processe o arquivo primeiro", "editTag": "Editar Tag", "deleteTag": "Excluir Tag", "addChildTag": "Adicionar Tag Filha", "deleteTagConfirmTitle": "Excluir Tag", "deleteTagConfirmMessage": "Tem certeza de que deseja excluir a tag \"{{tag}}\"?", "deleteWarning": "Esta ação excluirá esta tag e todas as suas tags filhas, perguntas e conjuntos de dados, e não poderá ser recuperada!", "dialog": { "addTitle": "Adicionar Tag", "editTitle": "Editar Tag", "addChildTitle": "Adicionar Tag Filha", "inputRoot": "Por favor, insira o nome da nova tag de primeiro nível", "inputEdit": "Por favor, edite o nome da tag", "inputChild": "Por favor, adicione tag filha para \"{label}\"", "labelName": "Nome da Tag", "saving": "Salvando...", "save": "Salvar", "deleteConfirm": "Tem certeza de que deseja excluir a tag \"{label}\"?", "deleteWarning": "Esta ação também excluirá todas as tags filhas e não poderá ser recuperada.", "emptyLabel": "O nome da tag não pode estar vazio" }, "tabs": { "tree": "Árvore de Domínio", "structure": "Estrutura de Diretório" }, "errors": { "saveFailed": "Falha ao salvar tag" }, "messages": { "updateSuccess": "Tag atualizada com sucesso" } }, "export": { "alpacaSettings": "Configurações de Formato Alpaca", "questionFieldType": "Tipo de Campo de Pergunta", "useInstruction": "Usar campo instruction", "useInput": "Usar campo input", "customInstruction": "Conteúdo personalizado do campo instruction", "instructionPlaceholder": "Por favor, insira o conteúdo fixo da instrução", "instructionHelperText": "Quando usar o campo input, pode especificar aqui o conteúdo fixo do instruction", "title": "Exportar", "format": "Estilo do Conjunto de Dados", "fileFormat": "Formato do Arquivo", "systemPrompt": "Prompt do Sistema", "systemPromptPlaceholder": "Por favor, insira o prompt do sistema...", "ReasoninglanguagePlaceholder": "Por favor, insira a linguagem de Raciocínio: Inglês ou Chinês ou outras", "Reasoninglanguage": "Linguagem de Raciocínio", "onlyConfirmed": "Exportar apenas dados confirmados", "example": "Exemplo de Formato", "confirmExport": "Confirmar Exportação", "includeCOT": "Incluir Cadeia de Pensamento", "cotDescription": "Incluir o processo de raciocínio antes da resposta final", "customFormat": "Formato Personalizado", "customFormatSettings": "Configurações de Formato Personalizado", "questionFieldName": "Nome do Campo de Pergunta", "answerFieldName": "Nome do Campo de Resposta", "cotFieldName": "Nome do Campo de Cadeia de Pensamento", "includeLabels": "Incluir Tags", "includeChunk": "Incluir Bloco de Texto", "questionOnly": "Exportar apenas perguntas", "localTab": "Exportar para Local", "llamaFactoryTab": "Usar no LLaMA Factory", "huggingFaceTab": "Enviar para Hugging Face", "configExists": "Arquivo de configuração já existe", "configPath": "Caminho do arquivo de configuração", "updateConfig": "Atualizar Configuração do LLaMA Factory", "noConfig": "Nenhum arquivo de configuração ainda, clique no botão abaixo para gerar", "generateConfig": "Gerar Configuração do LLaMA Factory", "huggingFaceComingSoon": "Funcionalidade de exportação para HuggingFace em breve", "uploadToHuggingFace": "Enviar para HuggingFace", "datasetName": "Nome do Conjunto de Dados", "datasetNameHelp": "Formato: nome de usuário/nome do conjunto de dados", "privateDataset": "Conjunto de Dados Privado", "datasetSettings": "Configurações do Conjunto de Dados", "exportOptions": "Opções de Exportação", "uploadSuccess": "Conjunto de dados enviado com sucesso para HuggingFace", "viewOnHuggingFace": "Ver no HuggingFace", "noTokenWarning": "Token do HuggingFace não encontrado. Por favor, configure o token nas configurações do projeto.", "goToSettings": "Ir para Configurações", "tokenHelp": "Você pode obter o token na página de configurações do HuggingFace", "multilingualThinkingFormat": "Pensamento Multilíngue", "sampleInstruction": "Instrução Humana (obrigatório)", "sampleOutput": "Resposta do Modelo (obrigatório)", "sampleSystem": "Prompt do Sistema (opcional)", "sampleInstruction2": "Segunda Instrução", "sampleOutput2": "Segunda Resposta", "sampleSystemShort": "Prompt do Sistema", "fixedInstruction": "Conteúdo fixo da instrução", "sampleInput": "Pergunta Humana (obrigatório)", "sampleInput2": "Segunda Pergunta", "sampleInputOptional": "Entrada Humana (opcional)", "sampleUserMessage": "Instrução Humana", "sampleAssistantMessage": "Resposta do Modelo", "sampleAnalysis": "Conteúdo da cadeia de pensamento do modelo", "sampleFinal": "Resposta do Modelo", "sampleThinking": "Conteúdo da cadeia de pensamento do modelo", "fetchLabelStatsError": "Falha ao obter estatísticas de tags:" }, "datasets": { "loadingDataset": "Carregando detalhes do conjunto de dados...", "datasetNotFound": "Conjunto de dados não encontrado", "optimizeTitle": "Otimização de IA", "optimizeAdvice": "Sugestão de Otimização", "optimizePlaceholder": "Por favor, insira suas sugestões de melhoria para a resposta, a IA otimizará a resposta e a cadeia de pensamento de acordo com suas sugestões", "generatingDataset": "Gerando conjunto de dados", "aiOptimizeAdvicePlaceholder": "Por favor, insira suas sugestões de melhoria para a resposta, a IA otimizará a resposta e a cadeia de pensamento de acordo com suas sugestões", "aiOptimizeAdvice": "Por favor, insira suas sugestões de melhoria para a resposta, a IA otimizará a resposta e a cadeia de pensamento de acordo com suas sugestões", "aiOptimize": "Otimização Inteligente de IA", "generating": "Gerando conjunto de dados", "partialSuccess": "Geração de conjunto de dados parcialmente bem-sucedida para perguntas ({{successCount}}/{{total}}), {{failCount}} perguntas falharam", "generateError": "Falha ao gerar conjunto de dados", "management": "Conjunto de Dados", "question": "Pergunta", "filterAll": "Todos", "filterConfirmed": "Confirmado", "filterUnconfirmed": "Não Confirmado", "createdAt": "Tempo de Criação", "model": "Modelo Usado", "domainTag": "Tag de Domínio", "cot": "Cadeia de Pensamento", "answer": "Resposta", "chunkId": "Bloco de Texto", "confirmed": "Confirmado", "noTag": "Sem Tag", "noData": "Nenhum dado", "rowsPerPage": "Linhas por Página", "pagination": "{{from}}-{{to}} de {{count}}", "confirmDeleteMessage": "Tem certeza de que deseja excluir este conjunto de dados? Esta ação não pode ser desfeita.", "questionLabel": "Pergunta", "fetchFailed": "Falha ao obter conjunto de dados", "deleteFailed": "Falha ao excluir conjunto de dados", "deleteSuccess": "Excluído com sucesso", "exportSuccess": "Conjunto de dados exportado com sucesso", "exportFailed": "Falha na exportação", "exportProgress": "Progresso da Exportação", "exportingData": "Exportando conjunto de dados", "processedCount": "Processado {{processed}} / {{total}} itens", "exportInProgress": "Obtendo dados, por favor, aguarde...", "exportFinalizing": "Gerando arquivo, quase concluído...", "loading": "Carregando conjunto de dados...", "stats": "Total de {{total}} conjuntos de dados, {{confirmed}} confirmados ({{percentage}}%)", "selected": "{{ count }} conjuntos de dados selecionados no total", "batchconfirmDeleteMessage": "Tem certeza de que deseja excluir os {{count}} conjuntos de dados selecionados? Esta ação não pode ser desfeita.", "batchDelete": "Excluir em Lote", "batchDeleteProgress": "Concluído: {{completed}}/{{total}}", "batchDeleteCount": "Gerado: {{count}}", "evaluation": "Anotação do Conjunto de Dados", "rating": "Avaliação", "ratingExcellent": "Excelente", "ratingGood": "Bom", "ratingAverage": "Médio", "ratingPoor": "Ruim", "ratingVeryPoor": "Muito Ruim", "ratingUnrated": "Não Avaliado", "customTags": "Tags Personalizadas", "addCustomTag": "Adicionar tag personalizada...", "note": "Observação", "addNote": "Adicionar observação...", "noNote": "Nenhuma observação", "clickToAddNote": "Clique para adicionar observação...", "enterNote": "Por favor, insira a observação...", "noteShortcuts": "Ctrl+Enter para salvar, Esc para cancelar", "aiEvaluation": "Avaliação de Qualidade de IA", "addToEval": "Adicionar ao Conjunto de Dados de Avaliação", "addToEvalSuccess": "Adicionado ao conjunto de dados de avaliação com sucesso", "addToEvalFailed": "Falha ao adicionar", "generateEvalVariant": "Gerar Variante do Conjunto de Avaliação", "generateVariantFailed": "Falha ao gerar variante", "saveVariantSuccess": "Salvo no conjunto de dados de avaliação", "saveVariantFailed": "Falha ao salvar", "evalVariantTitle": "Gerar Variante do Conjunto de Avaliação", "evalVariantPreviewTitle": "Confirmar Questões Geradas", "saveToEval": "Salvar no Conjunto de Avaliação", "evalVariantConfigHint": "Por favor, selecione o tipo e quantidade de questões a serem geradas, a IA reescreverá com base no par de perguntas e respostas atual.", "questionType": "Tipo de Questão", "typeOpenEnded": "Pergunta e Resposta Aberta", "typeSingleChoice": "Múltipla Escolha", "typeMultipleChoice": "Múltipla Escolha (Várias Respostas)", "typeTrueFalse": "Verdadeiro/Falso", "generateCount": "Quantidade a Gerar", "evalVariantPreviewHint": "Você pode editar as questões geradas, confirme e salve no conjunto de avaliação após verificação.", "questionIndex": "Questão {{index}}", "options": "Opções (Array JSON)", "optionsHint": "Por exemplo: [\"Opção A\", \"Opção B\"]", "answerArrayHint": "Para respostas de múltipla escolha, insira um array, como [\"A\", \"C\"]", "answerBoolHint": "Para questões de verdadeiro/falso, insira ✅ ou ❌", "generate": "Gerar", "updateSuccess": "Atualização bem-sucedida", "updateFailed": "Falha na atualização", "searchPlaceholder": "Pesquisar conjunto de dados...", "fieldQuestion": "Pergunta", "fieldAnswer": "Resposta", "fieldCOT": "Cadeia de Pensamento", "fieldLabel": "Tag de Domínio", "moreFilters": "Mais", "filtersTitle": "Condições de Filtro", "filterConfirmationStatus": "Status de Confirmação", "filterCotStatus": "Status de Cadeia de Pensamento", "filterHasCot": "Inclui Cadeia de Pensamento", "filterNoCot": "Não Inclui Cadeia de Pensamento", "filterScoreRange": "Intervalo de Pontuação", "filterNoteKeyword": "Palavra-chave da Observação", "filterNoteKeywordPlaceholder": "Por favor, insira a palavra-chave da observação...", "filterChunkName": "Nome do Bloco de Texto", "filterChunkNamePlaceholder": "Por favor, insira o nome do bloco de texto...", "filterCustomTag": "Tag Personalizada", "resetFilters": "Redefinir", "applyFilters": "Aplicar Filtros", "viewDetails": "Ver Detalhes", "datasetDetail": "Detalhes do Conjunto de Dados", "metadata": "Metadados", "confirmSave": "Confirmar Retenção", "unconfirm": "Cancelar Confirmação", "unconfirming": "Cancelando confirmação...", "uncategorized": "Não Categorizado", "questionCount": "{{count}} perguntas", "source": "Fonte", "generateDataset": "Gerar Conjunto de Dados", "generateNotImplemented": "Funcionalidade de geração de conjunto de dados não implementada", "generateSuccess": "Conjunto de dados gerado com sucesso: {{question}}", "generateFailed": "Falha ao gerar conjunto de dados: {{error}}", "noTagsAndQuestions": "Nenhuma tag e pergunta disponível", "answerCount": "{{count}} respostas", "answered": "Resposta Gerada", "enableShortcuts": "Atalhos de Paginação", "shortcutsHelp": "Pressione ← para anterior, → para próximo, y para confirmar, d para excluir", "filterDistill": "É Conjunto de Dados Destilado", "filterDistillYes": "Conjunto de Dados Destilado", "filterDistillNo": "Conjunto de Dados Não Destilado", "evaluate": "Avaliação de Qualidade", "evaluating": "Avaliando...", "batchEvaluate": "Avaliação de Qualidade Automática", "selectModelFirst": "Por favor, selecione um modelo primeiro", "evaluateSuccess": "Avaliação concluída! Pontuação: {{score}}/5", "evaluateFailed": "Falha na avaliação", "evaluateError": "Falha na avaliação: {{error}}", "batchEvaluateStarted": "Tarefa de avaliação em lote iniciada, será processada em segundo plano", "batchEvaluateStartFailed": "Falha ao iniciar avaliação em lote", "batchEvaluateFailed": "Falha na avaliação em lote: {{error}}", "scoreRange": "{{min}} - {{max}} pontos", "singleTurn": "Conjunto de Dados de Pergunta e Resposta de Rodada Única", "multiTurn": "Conjunto de Dados de Diálogo de Múltiplas Rodadas", "imageQA": "Conjunto de Dados de Pergunta e Resposta de Imagens", "conversationDetail": "Detalhes do Diálogo de Múltiplas Rodadas", "conversationContent": "Conteúdo do Diálogo", "basicInfo": "Informações Básicas", "firstQuestion": "Primeira Pergunta", "conversationScenario": "Cenário do Diálogo", "conversationRounds": "Número de Rodadas do Diálogo", "modelUsed": "Modelo Usado", "qualityScore": "Pontuação de Qualidade", "notes": "Observações", "createTime": "Tempo de Criação", "notSet": "Não Definido", "noTags": "Sem Tags", "noNotes": "Sem Observações", "notEvaluated": "Ainda Não Avaliado", "round": "Rodada {{round}}", "system": "Sistema", "user": "Usuário", "assistant": "Assistente", "confirmDelete": "Confirmar Exclusão", "confirmDeleteConversation": "Tem certeza de que deseja excluir este conjunto de dados de diálogo de múltiplas rodadas? Esta ação não pode ser desfeita.", "conversationNotFound": "Conjunto de dados de diálogo não existe", "fetchDataFailed": "Falha ao obter dados", "saveFailed": "Falha ao salvar", "saveSuccess": "Salvo com sucesso", "saving": "Salvando...", "inputTagsPlaceholder": "Insira tags, separadas por espaço", "addNotesPlaceholder": "Adicionar informações de observação", "noConversations": "Nenhum conjunto de dados de diálogo de múltiplas rodadas", "notRated": "Não Avaliado", "minScore": "Pontuação Mínima", "maxScore": "Pontuação Máxima", "unconfirmed": "Não Confirmado" }, "rating": { "veryPoor": "Muito Ruim", "poor": "Ruim", "belowAverage": "Abaixo da Média", "fair": "Regular", "average": "Médio", "good": "Bom", "veryGood": "Muito Bom", "excellent": "Excelente", "outstanding": "Excepcional", "perfect": "Perfeito", "unrated": "Não Avaliado" }, "tags": { "noTags": "Nenhuma tag", "addTag": "Adicionar tag...", "addCustomTag": "Adicionar tag personalizada", "maxTagsReached": "Máximo de {{maxTags}} tags permitidas", "availableTagsHint": "Pode selecionar de tags existentes ou inserir novas tags" }, "import": { "title": "Importar", "fileUpload": "Upload de Arquivo", "fileUploadDescription": "Fazer upload de arquivo local para importar conjunto de dados", "uploadFile": "Enviar Arquivo", "supportedFormats": "Suporta arquivos nos formatos JSON, JSONL, CSV", "dragDropFile": "Arraste o arquivo para cá ou clique para selecionar o arquivo", "dropFileHere": "Solte para fazer upload do arquivo", "maxFileSize": "Tamanho máximo do arquivo: 50MB", "processingFile": "Processando arquivo...", "uploadedFiles": "Arquivos Enviados", "uploadError": "Falha no upload do arquivo, por favor, verifique se o formato do arquivo está correto", "mapFields": "Mapeamento de Campos", "importing": "Importando", "fieldMapping": "Mapeamento de Campos", "mappingDescription": "Por favor, mapeie os campos dos dados de origem para os campos de destino. O sistema já identificou automaticamente possíveis relações de mapeamento, você pode ajustar conforme necessário.", "selectMapping": "Selecionar Mapeamento de Campos", "questionField": "Campo de Pergunta", "answerField": "Campo de Resposta", "cotField": "Campo de Cadeia de Pensamento", "tagsField": "Campo de Tags", "selectField": "Selecionar Campo", "questionDesc": "Pergunta do usuário ou conteúdo de entrada (obrigatório)", "answerDesc": "Resposta da IA ou conteúdo de saída (obrigatório)", "cotDesc": "Cadeia de pensamento ou processo de raciocínio (opcional)", "tagsDesc": "Array de tags, múltiplas tags separadas por vírgula (opcional)", "dataPreview": "Pré-visualização de Dados", "previewNote": "Exibindo os primeiros 3 registros, cada valor de campo exibindo no máximo 100 caracteres", "confirmMapping": "Confirmar Mapeamento", "requiredFields": "Por favor, selecione pelo menos o mapeamento dos campos de pergunta e resposta", "mappingRequired": "Campos de pergunta e resposta são obrigatórios", "duplicateMapping": "Não é possível mapear múltiplos campos de destino para o mesmo campo de origem", "noPreviewData": "Nenhum dado disponível para pré-visualização", "preparingData": "Preparando dados...", "uploadingData": "Enviando dados...", "processing": "Processando... {{processed}}/{{total}}", "completed": "Importação Concluída", "importStats": "Estatísticas de Importação", "total": "Total: {{count}}", "success": "Sucesso: {{count}}", "failed": "Falha: {{count}}", "source": "Fonte de Dados", "description": "Descrição", "errors": "Mensagens de Erro", "moreErrors": "Mais {{count}} erros não exibidos...", "importSuccess": "Importação do conjunto de dados concluída!", "enterDatasetName": "Por favor, insira o nome do conjunto de dados", "noDatasetFound": "Nenhum conjunto de dados correspondente encontrado", "complete": "Concluir", "addToEval": "Adicionar ao Conjunto de Dados de Avaliação", "addToEvalSuccess": "Adicionado ao conjunto de dados de avaliação com sucesso", "addToEvalFailed": "Falha ao adicionar", "generateEvalVariant": "Gerar Variante do Conjunto de Avaliação", "selectModelFirst": "Por favor, selecione um modelo primeiro", "generateVariantFailed": "Falha ao gerar variante", "saveVariantSuccess": "Salvo no conjunto de dados de avaliação", "saveVariantFailed": "Falha ao salvar", "evalVariantTitle": "Gerar Variante do Conjunto de Avaliação", "evalVariantHint": "A IA gerou novas variantes de teste com base no par de perguntas e respostas original, você pode editar manualmente antes de salvar.", "saveToEval": "Salvar no Conjunto de Avaliação", "evalVariantConfigHint": "Por favor, selecione o tipo e quantidade de questões a serem geradas, a IA reescreverá com base no par de perguntas e respostas atual.", "questionType": "Tipo de Questão", "typeOpenEnded": "Pergunta e Resposta Aberta", "typeSingleChoice": "Múltipla Escolha", "typeMultipleChoice": "Múltipla Escolha (Várias Respostas)", "typeTrueFalse": "Verdadeiro/Falso", "typeShortAnswer": "Resposta Curta", "generateCount": "Quantidade a Gerar", "evalVariantPreviewHint": "Você pode editar as questões geradas, confirme e salve no conjunto de avaliação após verificação.", "questionIndex": "Questão {{index}}", "options": "Opções (Array JSON)", "optionsHint": "Por exemplo: [\"Opção A\", \"Opção B\"]", "answerArrayHint": "Para respostas de múltipla escolha, insira um array, como [\"A\", \"C\"]", "answerBoolHint": "Para questões de verdadeiro/falso, insira ✅ ou ❌", "evalVariantPreviewTitle": "Confirmar Questões Geradas", "generate": "Gerar" }, "update": { "newVersion": "Nova Versão", "newVersionAvailable": "Nova versão disponível", "currentVersion": "Versão Atual", "latestVersion": "Última Versão", "downloadNow": "Baixar Agora", "downloading": "Baixando:", "installNow": "Instalar Agora", "updating": "Atualizando...", "updateNow": "Atualizar Agora", "viewRelease": "Baixar Última Versão", "checking": "Verificando atualizações...", "noUpdates": "Já está na versão mais recente", "updateError": "Erro na atualização", "updateSuccess": "Atualização bem-sucedida", "restartRequired": "Necessário reiniciar o aplicativo", "restartNow": "Reiniciar Agora", "restartLater": "Reiniciar Mais Tarde" }, "datasetSquare": { "title": "Praça de Conjuntos de Dados", "subtitle": "Descubra e explore vários recursos de conjuntos de dados públicos para auxiliar no treinamento e pesquisa do seu modelo", "searchPlaceholder": "Pesquisar palavras-chave do conjunto de dados...", "searchVia": "Via", "categoryTitle": "Categorias de Conjuntos de Dados", "categories": { "all": "Todos", "popular": "Recomendações Populares", "chinese": "Recursos em Chinês", "english": "Recursos em Inglês", "research": "Dados de Pesquisa", "multimodal": "Multimodal" }, "foundResources": "{{count}} recursos de conjuntos de dados encontrados", "currentFilter": "Filtro atual: {{category}}", "noDatasets": "Nenhum conjunto de dados correspondente encontrado", "tryOtherCategories": "Por favor, tente outras categorias ou volte para ver todos os conjuntos de dados", "dataset": "Conjunto de Dados", "viewDataset": "Ver Conjunto de Dados" }, "playground": { "title": "Teste de Modelo", "selectModelFirst": "Por favor, selecione pelo menos um modelo", "sendFirstMessage": "Envie a primeira mensagem para iniciar o teste", "inputMessage": "Digite a mensagem...", "send": "Enviar", "outputMode": "Modo de Saída", "normalOutput": "Saída Normal", "streamingOutput": "Saída em Streaming", "clearConversation": "Limpar Diálogo", "selectModelMax3": "Selecionar Modelo (máximo 3)", "reasoningProcess": "Processo de Raciocínio" }, "chunks": { "title": "Blocos de Texto", "defaultTitle": "Título Padrão" }, "documentation": "Documentação", "models": { "configNotFound": "Configuração do modelo não encontrada", "parseError": "Falha ao analisar configuração do modelo", "fetchFailed": "Falha ao obter modelo", "saveFailed": "Falha ao salvar configuração do modelo", "pleaseSelectModel": "Por favor, selecione pelo menos um modelo", "title": "Gerenciamento de Modelos", "add": "Adicionar Modelo", "unselectedModel": "Modelo Não Selecionado", "unconfiguredAPIKey": "API Key Não Configurada", "saveAllModels": "Salvar Todas as Configurações de Modelos", "edit": "Editar", "delete": "Excluir", "modelId": "ID do Modelo", "modelName": "Nome do Modelo", "modelNamePlaceholder": "Por favor, insira o nome do modelo (opcional, padrão é o ID do modelo)", "modelIdPlaceholder": "Nome do modelo (pode inserir personalizado)", "endpoint": "Endereço da Interface", "apiKey": "Chave da API", "provider": "Provedor (pode inserir personalizado)", "localModel": "Modelo Local", "apiKeyConfigured": "API Key já configurada", "apiKeyNotConfigured": "API Key não configurada", "temperature": "Temperatura do Modelo", "maxTokens": "Número Máximo de Tokens Gerados", "maxTokensInputTip": "Intervalo do controle deslizante: 1-{{max}}. Você também pode inserir qualquer número inteiro positivo.", "topP": "Top P", "type": "Tag do Modelo", "text": "Modelo de Linguagem", "vision": "Modelo de Visão", "typeTips": "Se deseja usar modelo de visão personalizado para analisar PDF, certifique-se de configurar pelo menos um modelo grande de visão", "refresh": "Atualizar Lista de Modelos", "configuredModels": "Modelos Configurados", "unconfiguredModels": "Modelos Não Configurados", "noConfiguredModels": "Nenhum modelo configurado ainda", "noUnconfiguredModels": "Nenhum modelo não configurado ainda", "checkEndpointHealth": "Verificar Saúde do Endpoint", "checkAllEndpointHealth": "Verificar Todos os Endpoints com um Clique", "endpointHealthy": "Endpoint Saudável", "endpointCheckFailed": "Falha na verificação do endpoint", "endpointMissing": "Endpoint está vazio", "endpointReachableModelMissing": "Endpoint acessível, mas o modelo atual não está na lista de retorno", "healthCheckSummary": "Verificação de saúde concluída: {{okCount}} normal, {{failCount}} falha", "checking": "Verificando...", "healthy": "Saudável", "reachable": "Acessível", "unhealthy": "Anormal", "notChecked": "Não Verificado" }, "stats": { "ongoingProjects": "Projetos em Execução", "questionCount": "Quantidade de Perguntas", "generatedDatasets": "Conjuntos de Dados Gerados", "supportedModels": "Modelos Suportados" }, "migration": { "title": "【Importante】Migração de Dados Históricos", "description": "Para melhorar o desempenho de recuperação de grandes conjuntos de dados, a partir da versão 1.3.1, o Easy Dataset mudou o método de armazenamento de arquivos para armazenamento em banco de dados local. Detectamos que você tem projetos históricos que ainda não foram migrados. Antes de concluir a migração, você não poderá acessar esses projetos. Por favor, conclua a migração o mais rápido possível!", "projectsList": "Projetos Não Migrados", "migrate": "Iniciar Migração", "migrating": "Migrando...", "success": "{{count}} projeto(s) migrado(s) com sucesso", "failed": "Falha na migração", "checkFailed": "Falha ao verificar projetos não migrados", "checkError": "Erro ao verificar projetos não migrados", "starting": "Iniciando tarefa de migração...", "processing": "Processando tarefa de migração...", "completed": "Migração concluída", "startFailed": "Falha ao iniciar tarefa de migração", "statusFailed": "Falha ao obter status da migração", "taskNotFound": "Tarefa de migração não existe", "progressStatus": "{{completed}}/{{total}} projeto(s) migrado(s)", "openDirectory": "Abrir Diretório do Projeto", "deleteDirectory": "Excluir Diretório do Projeto", "confirmDelete": "Tem certeza de que deseja excluir este diretório de projeto? Esta ação não pode ser desfeita.", "openDirectoryFailed": "Falha ao abrir diretório do projeto", "deleteDirectoryFailed": "Falha ao excluir diretório do projeto" }, "distill": { "title": "Destilação de Dados", "generateRootTags": "Gerar Tags de Primeiro Nível", "generateSubTags": "Gerar Tags Filhas", "generateQuestions": "Gerar Perguntas", "generateRootTagsTitle": "Gerar Tags de Domínio de Primeiro Nível", "generateSubTagsTitle": "Gerar Tags Filhas para {{parentTag}}", "generateQuestionsTitle": "Gerar Perguntas para {{tag}}", "parentTag": "Tag Pai", "parentTagPlaceholder": "Por favor, insira o nome da tag pai (ex: Esportes, Tecnologia, etc.)", "parentTagHelp": "Insira um tema de domínio, o sistema gerará tags relacionadas com base nisso", "tagCount": "Quantidade de Tags", "tagCountHelp": "Insira a quantidade de tags a serem geradas, máximo de 100", "questionCount": "Quantidade de Perguntas", "questionCountHelp": "Insira a quantidade de perguntas a serem geradas, máximo de 100", "generatedTags": "Tags Geradas", "generatedQuestions": "Perguntas Geradas", "generateTags": "Gerar Tags", "tagPath": "Caminho da Tag", "noTags": "Nenhuma tag ainda", "noQuestions": "Nenhuma pergunta ainda", "clickGenerateButton": "Clique no botão de gerar acima para começar a criar tags", "selectModelFirst": "Por favor, selecione um modelo primeiro", "selectModel": "Selecionar Modelo", "generateTagsError": "Falha ao gerar tags", "generateQuestionsError": "Falha ao gerar perguntas", "deleteTagConfirmTitle": "Confirmar exclusão da tag? Isso excluirá todas as tags filhas, perguntas e conjuntos de dados associados a esta tag", "editTagTitle": "Editar Tag", "tagName": "Nome da Tag", "labelRequired": "O nome da tag não pode estar vazio", "tagUpdateSuccess": "Tag atualizada com sucesso", "tagUpdateFailed": "Falha ao atualizar tag", "unknownTag": "Tag desconhecida", "autoDistillButton": "Destilação Automática Completa de Conjunto de Dados", "autoDistillTitle": "Configuração de Destilação Automática Completa de Conjunto de Dados", "distillTopic": "Tema da Destilação", "tagLevels": "Níveis de Tags", "tagLevelsHelper": "Definir a quantidade de níveis, máximo de {{max}} níveis", "tagsPerLevel": "Quantidade de Tags por Nível", "tagsPerLevelHelper": "Quantidade de tags filhas geradas sob cada tag pai, máximo de {{max}}", "questionsPerTag": "Quantidade de Perguntas por Tag", "questionsPerTagHelper": "Quantidade de perguntas geradas para cada tag folha, máximo de {{max}}", "estimationInfo": "Informações Estimadas da Tarefa", "estimatedTags": "Quantidade Estimada de Tags a Serem Geradas", "estimatedQuestions": "Quantidade Estimada de Perguntas a Serem Geradas", "currentTags": "Quantidade Atual de Tags", "currentQuestions": "Quantidade Atual de Perguntas", "newTags": "Quantidade Estimada de Novas Tags", "newQuestions": "Quantidade Estimada de Novas Perguntas", "startAutoDistill": "Iniciar Destilação Automática", "autoDistillProgress": "Progresso da Destilação Automática", "overallProgress": "Progresso Geral", "tagsProgress": "Progresso da Construção de Tags", "questionsProgress": "Progresso da Geração de Perguntas", "currentStage": "Estágio Atual", "realTimeLogs": "Logs em Tempo Real", "waitingForLogs": "Aguardando saída de logs...", "autoDistillStarted": "{{time}} Tarefa de destilação automática iniciada", "autoDistillInsufficientError": "A configuração atual não produzirá novas tags ou perguntas, por favor, ajuste os parâmetros", "stageInitializing": "Inicializando...", "stageBuildingLevel1": "Construindo tags do primeiro nível", "stageBuildingLevel2": "Construindo tags do segundo nível", "stageBuildingLevel3": "Construindo tags do terceiro nível", "stageBuildingLevel4": "Construindo tags do quarto nível", "stageBuildingLevel5": "Construindo tags do quinto nível", "stageBuildingQuestions": "Gerando perguntas", "stageBuildingDatasets": "Gerando conjuntos de dados", "stageCompleted": "Tarefa concluída", "datasetsProgress": "Progresso da Geração de Conjuntos de Dados", "rootTopicHelperText": "Por padrão, usa o nome do projeto como tema de destilação de primeiro nível. Se precisar alterar, vá para as configurações do projeto para alterar o nome do projeto.", "addChildTag": "Gerar Tag Filha", "datasetType": "Tipo de Conjunto de Dados", "singleTurnDataset": "Conjunto de Dados de Diálogo de Rodada Única", "multiTurnDataset": "Conjunto de Dados de Diálogo de Múltiplas Rodadas", "bothDatasetTypes": "Gerar ambos os tipos de conjuntos de dados", "autoDistillTaskDetail": "Tarefa de destilação automática: {{topic}}", "backgroundTaskCreated": "Tarefa de destilação em segundo plano criada, pode verificar o progresso no gerenciamento de tarefas", "backgroundTaskFailed": "Falha ao criar tarefa em segundo plano", "taskExecutionError": "Erro na execução da tarefa: {{error}}" }, "tasks": { "pending": "{{count}} tarefa(s) em processamento", "completed": "Todas as tarefas concluídas", "title": "Centro de Gerenciamento de Tarefas", "loading": "Carregando lista de tarefas...", "empty": "Nenhum registro de tarefa ainda", "confirmDelete": "Confirmar exclusão desta tarefa?", "confirmAbort": "Confirmar interrupção desta tarefa? A tarefa será interrompida.", "deleteSuccess": "Tarefa excluída", "deleteFailed": "Falha ao excluir tarefa", "abortSuccess": "Tarefa interrompida", "abortFailed": "Falha ao interromper tarefa", "status": { "processing": "Processando", "completed": "Concluído", "failed": "Falhou", "aborted": "Interrompido", "unknown": "Desconhecido" }, "types": { "text-processing": "Processamento de Texto", "file-processing": "Processamento de Arquivo", "data-cleaning": "Limpeza de Dados", "question-generation": "Geração de Perguntas", "answer-generation": "Geração de Respostas", "multi-turn-generation": "Geração de Diálogo de Múltiplas Rodadas", "eval-generation": "Geração de Conjunto de Avaliação", "image-question-generation": "Geração de Perguntas de Imagem", "data-distillation": "Destilação de Dados", "pdf-processing": "Análise de PDF" }, "filters": { "status": "Status da Tarefa", "type": "Tipo de Tarefa" }, "actions": { "refresh": "Atualizar Lista de Tarefas", "delete": "Excluir Tarefa", "abort": "Interromper Tarefa" }, "table": { "type": "Tipo de Tarefa", "status": "Status", "progress": "Progresso", "note": "Observação", "createTime": "Tempo de Criação", "endTime": "Tempo de Conclusão", "duration": "Tempo de Execução", "model": "Modelo Usado", "detail": "Detalhes da Tarefa", "actions": "Ações" }, "duration": { "seconds": "{{seconds}} segundos", "minutes": "{{minutes}} minutos {{seconds}} segundos", "hours": "{{hours}} horas {{minutes}} minutos" }, "fetchFailed": "Falha ao obter lista de tarefas", "createSuccess": "Tarefa criada com sucesso", "createFailed": "Falha ao criar tarefa", "multiTurnCreateSuccess": "Tarefa de conjunto de dados de diálogo de múltiplas rodadas criada com sucesso", "notes": { "selectedChunks": "{{count}} blocos de texto selecionados", "fileBatch": "Parâmetros de processamento de arquivo: {{count}} arquivo(s) (estratégia: {{strategy}})", "jsonParams": "Parâmetros da tarefa configurados", "noChunksQuestion": "Nenhum bloco de texto precisa gerar perguntas", "noChunksCleaning": "Nenhum bloco de texto precisa ser limpo", "processingFailed": "Falha na tarefa: {{error}}", "questionSummary": "Processado {{processed}}/{{total}}, sucesso {{succeeded}}, falha {{failed}}, perguntas geradas {{generated}}", "datasetSummary": "Processado {{processed}}/{{total}}, sucesso {{succeeded}}, falha {{failed}}, conjuntos de dados gerados {{generated}}", "cleaningSummary": "Processado {{processed}}/{{total}}, sucesso {{succeeded}}, falha {{failed}}, comprimento original {{original}}, após limpeza {{cleaned}}", "genericSummary": "Processado {{processed}}/{{total}}, sucesso {{succeeded}}, falha {{failed}}" } }, "gaPairs": { "title": "Gerenciamento de Pares Gênero-Público", "loading": "Carregando pares gênero-público...", "addPair": "Adicionar Par Gênero-Público", "saveChanges": "Salvar Alterações", "saving": "Salvando...", "restoreBackup": "Restaurar Backup", "noGaPairsTitle": "Nenhum Par Gênero-Público Encontrado", "noGaPairsDescription": "Gerar pares gênero-público impulsionados por IA para este arquivo", "generateGaPairs": "Gerar Pares Gênero-Público", "generating": "Gerando...", "generateMore": "Gerar Mais Pares Gênero-Público", "activePairs": "Pares Gênero-Público Ativos ({{active}}/{{total}})", "pairNumber": "Par Gênero-Público #{{number}}", "active": "Ativo", "deleteTooltip": "Excluir Par Gênero-Público", "genre": "Gênero", "genreDescription": "Descrição do Gênero", "audience": "Público", "audienceDescription": "Descrição do Público", "addDialogTitle": "Adicionar Novo Par Gênero-Público", "genreTitle": "Título do Gênero", "audienceTitle": "Título do Público", "genreTitlePlaceholder": "Por favor, insira o título do gênero...", "genreDescPlaceholder": "Descreva detalhadamente este gênero...", "audienceTitlePlaceholder": "Por favor, insira o título do público...", "audienceDescPlaceholder": "Descreva detalhadamente o público-alvo...", "cancel": "Cancelar", "addPairButton": "Adicionar Par Gênero-Público", "requiredFields": "Título do gênero e título do público são obrigatórios", "restoredFromBackup": "Restaurado do backup", "allPairsDeleted": "Todos os pares gênero-público excluídos com sucesso", "pairsSaved": "{{count}} pares gênero-público salvos com sucesso", "additionalPairsGenerated": "{{count}} pares gênero-público adicionais gerados com sucesso. Total: {{total}}", "validationError": "Par Gênero-Público {{number}}: Título do gênero e título do público são obrigatórios", "loadError": "Não foi possível carregar pares gênero-público: {{error}}", "generateError": "Falha ao gerar pares gênero-público", "saveError": "Falha ao salvar pares gênero-público", "noActiveModel": "Por favor, configure o modelo de IA nas configurações antes de gerar pares gênero-público.", "contentTooShort": "O conteúdo do arquivo é muito curto ou inadequado para gerar pares gênero-público.", "configError": "Erro na configuração do modelo de IA. Podem faltar dependências necessárias.", "serverError": "Erro do servidor ({{status}}). Por favor, tente novamente mais tarde.", "emptyResponse": "O serviço de geração retornou resposta vazia", "generationFailed": "Falha na geração", "saveOperationFailed": "Falha na operação de salvamento", "serviceNotAvailable": "Serviço de geração de pares gênero-público não disponível. Por favor, verifique sua configuração de API.", "requestFailed": "Falha na solicitação ({{status}}). Por favor, tente novamente.", "internalServerError": "Ocorreu um erro interno do servidor.", "batchGenerate": "Gerar Pares Gênero-Público em Lote", "batchGenerateDescription": "Gerará pares gênero-público em lote para {{count}} arquivo(s) selecionado(s), esta operação pode levar algum tempo.", "appendMode": "Modo de Anexação", "appendModeDescription": "Gerar mais pares gênero-público para arquivos que já possuem pares gênero-público, em vez de substituir", "selectAtLeastOneFile": "Por favor, selecione pelo menos um arquivo", "noDefaultModel": "Nenhum modelo padrão definido, por favor, configure o modelo nas configurações do projeto primeiro", "incompleteModelConfig": "Configuração do modelo incompleta, por favor, verifique as configurações do modelo", "missingApiKey": "API Key não configurada para o modelo, por favor, adicione a API Key nas configurações do modelo", "loadingProjectModel": "Carregando modelo do projeto...", "usingModel": "Usando modelo", "startGeneration": "Iniciar Geração", "batchGenCompleted": "Geração em lote concluída! Pares gênero-público gerados com sucesso para {{success}}/{{total}} arquivo(s).", "generationError": "Erro durante o processo de geração: {{error}}", "fetchProjectInfoFailed": "Falha ao obter informações do projeto: {{status}}", "fetchModelConfigFailed": "Falha ao obter configuração do modelo: {{status}}", "fetchProjectModelError": "Erro ao obter configuração do modelo do projeto", "batchGenerationFailed": "Falha na geração em lote de pares gênero-público", "batchGenerationSuccess": "Pares gênero-público gerados com sucesso para {{count}} arquivo(s)", "selectAllFiles": "Selecionar Todos", "deselectAllFiles": "Desmarcar Todos", "batchGenerateTitle": "Gerar Pares Gênero-Público em Lote", "generationMode": "Modo de Geração", "aiGenerateMode": "Geração por IA", "manualAddMode": "Adição Manual", "genreDesc": "Descrição do Gênero", "audienceDesc": "Descrição do Público", "manualGaPairRequired": "Por favor, preencha o título do gênero e o título do público", "batchAddManual": "Adicionar em Lote" }, "batchEdit": { "title": "Edição em Lote de Blocos de Texto", "batchEdit": "Edição em Lote", "batchEditTooltip": "Editar em lote os blocos de texto selecionados", "position": "Posição de Adição", "atBeginning": "Adicionar no Início", "atEnd": "Adicionar no Final", "contentToAdd": "Conteúdo a Adicionar", "contentPlaceholder": "Por favor, insira o conteúdo a ser adicionado ao bloco de texto...", "contentRequired": "Por favor, insira o conteúdo a ser adicionado", "contentHelp": "Este conteúdo será adicionado a todos os blocos de texto selecionados", "preview": "Efeito de Pré-visualização", "allChunksSelected": "Todos os {{count}} blocos de texto selecionados", "selectedChunks": "{{selected}} / {{total}} blocos de texto selecionados", "processing": "Processando...", "applyToChunks": "Aplicar a {{count}} blocos de texto", "editSuccess": "{{count}} blocos de texto editados com sucesso", "editFailed": "Falha na edição em lote", "previewNote": "Acima está o efeito de pré-visualização do primeiro bloco de texto selecionado, todos os blocos de texto selecionados serão modificados da mesma forma" }, "errors": { "projectIdRequired": "ID do projeto não pode estar vazio", "getDatasetsFailed": "Falha ao obter conjuntos de dados", "getTagStatsFailed": "Falha ao obter estatísticas de tags", "deleteFileFailed": "Erro ao excluir arquivo", "recordNotFound": "Registro atual não existe", "mineruTokenNotFound": "Configuração de token não encontrada, por favor, verifique se o token MinerU está configurado nas configurações de tarefa", "mineruLocalUrlNotFound": "Configuração de URL local do MinerU não encontrada, por favor, verifique se a URL local do MinerU está configurada nas configurações de tarefa" }, "sampleData": { "questionContent": "Conteúdo da Pergunta", "answerContent": "Conteúdo da Resposta", "cotContent": "Conteúdo do Processo de Cadeia de Pensamento", "domainLabel": "Tag de Domínio", "textChunk": "Bloco de Texto" }, "exportDialog": { "balancedExport": "Exportação Balanceada", "balancedExportTitle": "Configurações de Exportação Balanceada", "balancedExportDescription": "Configure a quantidade de dados para cada categoria de acordo com as tags de domínio, realizando exportação balanceada do conjunto de dados", "quickSettings": "Configurações Rápidas", "setAllTo50": "Definir Todos para 50", "setAllTo100": "Definir Todos para 100", "setAllTo200": "Definir Todos para 200", "customAmount": "Quantidade Personalizada", "tagName": "Nome da Tag", "availableCount": "Quantidade Disponível", "exportCount": "Quantidade a Exportar", "settings": "Configurações", "totalExportCount": "Quantidade Total de Exportação", "tagCount": "Quantidade de Tags", "export": "Exportar" }, "imageDatasets": { "title": "Conjunto de Dados de Pergunta e Resposta de Imagens", "subtitle": "Gerencie e otimize seu conjunto de dados de pergunta e resposta de imagens", "description": "Gerencie e otimize seu conjunto de dados de pergunta e resposta de imagens.", "searchPlaceholder": "Pesquisar perguntas ou respostas...", "noAnswer": "Nenhuma resposta ainda", "labels": "Tags", "typeLabel": "Tag", "typeCustom": "JSON Personalizado", "typeText": "Texto Normal", "unscored": "Não pontuado", "confirmed": "Confirmado", "unconfirmed": "Não confirmado", "view": "Ver Detalhes", "evaluate": "Avaliação de Qualidade", "delete": "Excluir", "deleteConfirm": "Tem certeza de que deseja excluir este conjunto de dados?", "imageName": "Nome da Imagem", "status": "Status", "scoreRange": "Intervalo de Pontuação", "noData": "Nenhum conjunto de dados de imagem", "noDataTip": "Por favor, gere primeiro o conjunto de dados de pergunta e resposta no gerenciamento de imagens", "fetchFailed": "Falha ao obter conjunto de dados", "fetchDetailFailed": "Falha ao obter detalhes", "deleteSuccess": "Excluído com sucesso", "deleteFailed": "Falha ao excluir", "updateSuccess": "Atualização bem-sucedida", "updateFailed": "Falha na atualização", "regenerateSuccess": "Reconhecimento de IA bem-sucedido", "regenerateFailed": "Falha no reconhecimento de IA", "notFound": "Conjunto de dados não existe", "detail": "Detalhes", "image": "Imagem", "question": "Pergunta", "answer": "Resposta", "selectLabels": "Selecionar Tags", "noLabels": "Nenhuma tag selecionada", "jsonPlaceholder": "Insira dados no formato JSON...", "metadata": "Metadados", "score": "Pontuação", "tags": "Tags", "addTag": "Adicionar tag...", "note": "Observação", "notePlaceholder": "Adicionar informações de observação...", "modelInfo": "Informações do Modelo", "createdAt": "Tempo de Criação", "updatedAt": "Tempo de Atualização", "exportTitle": "Exportar Conjunto de Dados de Imagens", "exportFormat": "Formato de Exportação", "rawFormat": "Formato Bruto", "customFormat": "Formato Personalizado", "exportImagesOption": "Exportar Arquivos de Imagem", "exportImagesDesc": "Empacotar todas as imagens em um arquivo ZIP para download, após o download, pode descompactar manualmente na pasta Images no mesmo diretório do arquivo de conjunto de dados", "includeImagePath": "Incluir caminho da imagem no conjunto de dados", "includeImagePathDesc": "Adicionar caminho da imagem na pergunta ou resposta (formato: /images/nome_da_imagem)", "systemPrompt": "Prompt do Sistema (opcional)", "systemPromptPlaceholder": "Insira o prompt do sistema...", "confirmedOnly": "Exportar apenas conjuntos de dados confirmados", "exportTip": "Respostas em formato de tag serão automaticamente analisadas como texto (separadas por vírgula)", "exportSuccess": "Conjunto de dados exportado com sucesso", "exportFailed": "Falha na exportação", "noDataToExport": "Nenhum dado para exportar", "exportImagesSuccess": "Pacote de imagens exportado com sucesso", "exportImagesFailed": "Falha na exportação de imagens" }, "images": { "resolution": "Resolução", "uploadTime": "Tempo de Envio", "fileName": "Nome do Arquivo", "title": "Gerenciamento de Imagens", "importImages": "Importar Imagens", "searchPlaceholder": "Pesquisar nome da imagem...", "hasQuestions": "Status de Perguntas", "hasDatasets": "Status de Conjuntos de Dados", "withQuestions": "Perguntas Geradas", "withoutQuestions": "Perguntas Não Geradas", "withDatasets": "Conjuntos de Dados Gerados", "withoutDatasets": "Conjuntos de Dados Não Gerados", "noImages": "Nenhuma imagem", "noImagesDescription": "Comece a importar imagens, crie seu primeiro conjunto de dados de imagens", "preview": "Pré-visualização", "questions": "Perguntas", "datasets": "Conjuntos de Dados", "datasetCount": "Quantidade de Conjuntos de Dados", "generateQuestions": "Gerar Perguntas", "generateDataset": "Gerar Conjunto de Dados", "deleteConfirm": "Tem certeza de que deseja excluir esta imagem?", "deleteSuccess": "Excluído com sucesso", "deleteFailed": "Falha ao excluir", "batchDelete": "Excluir em Lote", "selectImagesToDelete": "Por favor, selecione imagens para excluir", "batchDeleteConfirm": "Tem certeza de que deseja excluir as {{count}} imagens selecionadas?", "batchDeleteSuccess": "{{count}} imagem(ns) excluída(s) com sucesso", "batchDeletePartialSuccess": "{{success}} excluída(s) com sucesso, {{fail}} falha(s)", "batchDeleteFailed": "Falha na exclusão em lote", "importTip": "Selecione um ou mais diretórios contendo imagens, todas as imagens serão importadas para o projeto (imagens com o mesmo nome serão substituídas)", "selectDirectory": "Selecionar Diretório", "directoryPath": "Caminho do Diretório", "enterDirectoryPath": "Por exemplo: /Users/username/Pictures", "selectedDirectories": "Diretórios Selecionados", "selectAtLeastOne": "Por favor, selecione pelo menos um diretório", "importSuccess": "{{count}} imagem(ns) importada(s) com sucesso", "importFailed": "Falha na importação", "startImport": "Iniciar Importação", "addDirectory": "Adicionar Diretório", "importFromDirectory": "Importar do Diretório", "importFromPdf": "Importar do PDF", "importFromZip": "Importar do ZIP", "pdfImportTip": "Selecione o arquivo PDF, o sistema converterá automaticamente para imagens e importará", "zipImportTip": "Selecione o arquivo ZIP, o sistema descompactará automaticamente e importará as imagens", "clickToSelectPdf": "Clique para selecionar arquivo PDF", "clickToSelectZip": "Clique para selecionar arquivo ZIP", "supportedFormat": "Formato suportado: PDF", "supportedZipFormat": "Formato suportado: ZIP", "fileSize": "Tamanho do Arquivo", "selectedFile": "Arquivo Selecionado", "invalidPdfFile": "Por favor, selecione um arquivo PDF válido", "invalidZipFile": "Por favor, selecione um arquivo ZIP válido", "selectPdfFile": "Por favor, selecione o arquivo PDF", "selectZipFile": "Por favor, selecione o arquivo ZIP", "pdfImportSuccess": "{{count}} imagem(ns) importada(s) com sucesso do PDF \"{{name}}\"", "pdfImportFailed": "Falha na importação do PDF", "zipImportSuccess": "{{count}} imagem(ns) importada(s) com sucesso do pacote \"{{name}}\"", "zipImportFailed": "Falha na importação do pacote", "convertAndImport": "Converter e Importar", "extractAndImport": "Descompactar e Importar", "electronRequired": "Esta funcionalidade precisa ser usada no aplicativo desktop", "selectDirectoryFailed": "Falha ao selecionar diretório", "imageName": "Nome da Imagem", "questionCount": "Quantidade de Perguntas", "questionCountHelp": "Gerar 1-10 perguntas", "size": "Tamanho", "dimensions": "Dimensões", "currentModel": "Modelo Atual", "selectModelFirst": "Por favor, selecione um modelo primeiro", "visionModelRequired": "Por favor, selecione um modelo com suporte a visão (como GPT-4 Vision, Claude, etc.)", "countRange": "A quantidade de perguntas deve estar entre 1-10", "questionsGenerated": "{{count}} pergunta(s) gerada(s) com sucesso", "generateFailed": "Falha na geração", "question": "Pergunta", "questionPlaceholder": "Por favor, insira a pergunta que deseja fazer...", "questionRequired": "Por favor, insira a pergunta", "datasetGenerated": "Conjunto de dados gerado com sucesso", "autoGenerateQuestions": "Extrair Perguntas Automaticamente", "autoGenerateConfirm": "O sistema gerará automaticamente perguntas para todas as imagens sem perguntas geradas. Esta operação criará uma tarefa em segundo plano, você pode verificar o progresso no gerenciamento de tarefas.", "taskCreated": "Tarefa criada com sucesso, processando em segundo plano", "taskCreateFailed": "Falha ao criar tarefa", "manualAnnotation": "Anotação Manual", "annotationTitle": "Anotação de Imagem", "imageInfo": "Informações da Imagem", "annotatedCount": "Anotado", "selectQuestion": "Selecionar ou Criar Pergunta", "selectQuestionPlaceholder": "Por favor, selecione o modelo de pergunta...", "universalQuestions": "Perguntas Universais", "independentQuestions": "Perguntas Independentes", "answerTypeText": "Texto", "answerTypeLabel": "Tag", "answerTypeCustomFormat": "Formato Personalizado", "usedTimes": "Usado {{count}} vez(es)", "answer": "Resposta", "answerPlaceholder": "Por favor, insira a resposta...", "selectLabels": "Selecionar Tags", "availableLabels": "Tags Disponíveis", "noLabelsAvailable": "Nenhuma tag disponível ainda", "addNewLabel": "Adicionar nova tag...", "selectedLabels": "Selecionado", "customFormatAnswer": "Resposta em Formato Personalizado", "formatRequirement": "Requisito de Formato", "customFormatPlaceholder": "Por favor, insira JSON no formato correto...", "note": "Observação", "notePlaceholder": "Informações de observação (opcional)", "saveAndContinue": "Salvar e Continuar", "noImageSelected": "Nenhuma imagem selecionada", "noTemplateSelected": "Por favor, selecione a pergunta", "answerRequired": "Por favor, insira a resposta", "invalidJsonFormat": "Formato JSON incorreto", "annotationSuccess": "Anotação salva com sucesso", "annotationFailed": "Falha ao salvar anotação", "allQuestionsAnnotated": "Todas as perguntas da imagem atual foram anotadas", "allImagesAnnotated": "Todas as perguntas de todas as imagens foram anotadas", "noQuestionsAssociated": "Nenhuma pergunta associada à imagem atual", "loadImageDetailFailed": "Falha ao carregar detalhes da imagem", "answeredQuestions": "Perguntas Respondidas", "useTemplate": "Usar Modelo", "formatJson": "Formatar", "jsonFormatHelp": "Por favor, insira dados em formato JSON válido", "imageLoadError": "Falha ao carregar imagem", "annotate": "Anotar", "annotateImage": "Anotar Imagem", "createQuestion": "Criar Pergunta", "createTemplate": "Criar Modelo de Pergunta", "aiGenerate": "Reconhecimento de IA", "aiGenerateSuccess": "Geração de IA bem-sucedida", "aiGenerateFailed": "Falha na geração de IA", "missingParameters": "Parâmetros necessários ausentes", "selectNewQuestion": "Selecionar Nova Pergunta", "fetchTemplatesFailed": "Falha ao obter modelos de pergunta", "createTemplateSuccess": "Modelo de pergunta criado com sucesso", "createTemplateFailed": "Falha ao criar modelo de pergunta", "updateTemplateSuccess": "Modelo de pergunta atualizado com sucesso", "updateTemplateFailed": "Falha ao atualizar modelo de pergunta", "deleteTemplateSuccess": "Modelo de pergunta excluído com sucesso", "deleteTemplateFailed": "Falha ao excluir modelo de pergunta", "template": { "management": "Gerenciar Modelos de Pergunta", "create": "Criar Modelo", "edit": "Editar Modelo", "question": "Conteúdo da Pergunta", "description": "Descrição", "noTemplates": "Nenhum modelo de pergunta ainda, clique no botão criar para adicionar", "deleteConfirm": "Tem certeza de que deseja excluir este modelo de pergunta?", "used": "Usado", "addLabel": "Adicionar Tag", "customFormat": "Formato Personalizado", "customFormatHelp": "Insira restrições de saída no formato JSON", "customFormatInfo": "Este formato será fornecido como prompt ao modelo grande, usado para restringir o formato de saída", "type": { "label": "Tipo de Pergunta", "universal": "Pergunta Universal", "independent": "Pergunta Independente" }, "answerType": { "label": "Tipo de Resposta", "text": "Texto", "tags": "Tags", "customFormat": "Formato Personalizado" }, "errors": { "questionRequired": "Por favor, insira o conteúdo da pergunta", "labelsRequired": "Perguntas do tipo tag precisam de pelo menos uma tag", "customFormatRequired": "Por favor, insira o formato personalizado", "invalidJson": "Formato JSON incorreto" } } }, "monitoring": { "title": "Painel de Monitoramento de Recursos", "timeRange": { "24h": "24 Horas", "7d": "Últimos 7 Dias", "30d": "Últimos 30 Dias" }, "filters": { "allProjects": "Todos os Projetos", "allProviders": "Todos os Provedores", "allStatus": "Todos os Status" }, "status": { "success": "Sucesso", "failed": "Falha" }, "actions": { "export": "Exportar Relatório" }, "stats": { "totalTokens": "Consumo Total de Tokens", "avgTokensPerCall": "Consumo Médio de Tokens/Chamada", "totalCalls": "Total de Chamadas", "avgLatency": "Tempo Médio de Resposta", "inputOutput": "Entrada: {{input}} · Saída: {{output}}", "successCalls": "{{count}} sucesso", "failedCalls": "{{count}} falha", "failureRate": "{{rate}}% taxa de falha", "basedOnSuccessCalls": "Baseado em {{count}} solicitações bem-sucedidas", "noSuccessCalls": "Nenhuma solicitação bem-sucedida ainda" }, "charts": { "tokenTrend": "Tendência de Consumo de Tokens", "inputLegend": "Entrada", "outputLegend": "Saída", "distributionTitle": "Distribuição de Consumo de Tokens (por Modelo)", "distributionSubtitle": "Proporção de consumo de recursos de diferentes modelos", "tokensTooltip": "{{value}}K Tokens" }, "table": { "title": "Detalhes de Uso Detalhados", "searchPlaceholder": "Pesquisar projeto, modelo ou motivo de falha...", "empty": "Nenhum dado ainda", "rowsPerPage": "Linhas por página:", "columns": { "projectName": "Nome do Projeto", "provider": "Provedor do Modelo", "model": "Nome do Modelo", "status": "Status", "failureReason": "Motivo da Falha", "inputTokens": "TOKEN de Entrada", "outputTokens": "TOKEN de Saída", "totalTokens": "TOTAL", "calls": "Número de Chamadas", "avgLatency": "Tempo Médio" } }, "errors": { "fetchSummaryFailed": "Falha ao obter dados resumidos de monitoramento", "fetchLogsFailed": "Falha ao obter logs de monitoramento" } }, "eval": { "title": "Avaliação", "datasets": "Conjuntos de Dados de Avaliação", "tasks": "Tarefas de Avaliação Automática", "datasetsTitle": "Conjuntos de Dados de Avaliação", "datasetsDescription": "Gerenciar e visualizar todas as questões de teste de avaliação geradas", "tasksTitle": "Tarefas de Avaliação", "tasksComingSoon": "Funcionalidade em desenvolvimento", "tasksComingSoonHint": "A funcionalidade de tarefas de avaliação será lançada em breve, fique atento", "totalQuestions": "Total de Questões", "questionType": "Tipo de Questão", "question": "Questão", "answer": "Resposta", "options": "Opções", "correct": "Correto", "wrong": "Errado", "sourceChunk": "Bloco de Texto de Origem", "tags": "Tags", "tagsPlaceholder": "Insira tags, múltiplas tags separadas por vírgula", "note": "Observação", "detail": "Detalhes", "notFound": "Questão não encontrada", "noData": "Nenhum dado de avaliação ainda", "noDataHint": "Por favor, gere primeiro o conjunto de teste de avaliação na página de divisão de texto", "searchPlaceholder": "Pesquisar conteúdo da questão...", "cardView": "Visualização em Cartões", "listView": "Visualização em Lista", "deleteSelected": "Excluir Selecionados ({{count}})", "deleteConfirmTitle": "Confirmar Exclusão", "deleteConfirmMessage": "Tem certeza de que deseja excluir {{count}} questão(ões)? Esta ação não pode ser desfeita.", "questionTypes": { "true_false": "Verdadeiro/Falso", "single_choice": "Múltipla Escolha", "multiple_choice": "Múltipla Escolha (Várias Respostas)", "short_answer": "Resposta Curta", "open_ended": "Questão Aberta" } }, "evalDatasets": { "import": { "title": "Importar Conjunto de Dados de Avaliação", "questionType": "Tipo de Questão", "selectTypeFirst": "Por favor, selecione o tipo de questão primeiro", "selectFile": "Por favor, selecione o arquivo a ser importado", "invalidFileType": "Formato de arquivo não suportado, por favor, faça upload de arquivo json, xls ou xlsx", "formatPreview": "Pré-visualização do Formato de Dados", "downloadTemplate": "Baixar Modelo", "template": "Modelo", "uploadFile": "Enviar Arquivo", "dropOrClick": "Clique ou arraste o arquivo para cá", "supportedFormats": "Suporta formatos JSON, XLS, XLSX", "tags": "Tags (opcional)", "tagsPlaceholder": "Adicionar tags aos dados importados, múltiplas tags separadas por vírgula", "tagsHelp": "Todos os dados importados serão marcados com estas tags", "import": "Importar", "importing": "Importando...", "failed": "Falha na importação", "success": "Importação bem-sucedida", "successMessage": "{{count}} dados de avaliação importados com sucesso", "showingErrors": "Exibindo primeiros {{count}} erros", "custom": "Importar Conjunto de Dados Personalizado", "builtin": "Importar Conjunto de Dados Integrado", "builtinTitle": "Selecionar Conjunto de Dados Integrado", "searchPlaceholder": "Pesquisar conjunto de dados...", "confirmImportTitle": "Confirmar Importação", "confirmImportMessage": "Tem certeza de que deseja importar o conjunto de dados \"{{name}}\"? Isso adicionará novos dados de avaliação ao projeto atual.", "downloading": "Baixando..." }, "export": { "title": "Exportar Conjunto de Dados de Avaliação", "formatLabel": "Formato de Exportação", "filterLabel": "Condições de Filtro", "previewLabel": "Dados a serem exportados:", "records": "registros", "largeDataHint": "Grande volume de dados, será usada exportação em streaming, por favor, aguarde pacientemente", "exporting": "Exportando...", "exportBtn": "Exportar", "jsonDesc": "Array JSON padrão", "jsonlDesc": "Um registro por linha", "csvDesc": "Formato de tabela", "noTagsAvailable": "Nenhuma tag disponível" } }, "evalTasks": { "title": "Tarefas de Avaliação de Modelo", "createTitle": "Criar Tarefa de Avaliação", "detailTitle": "Detalhes da Tarefa de Avaliação", "createTask": "Criar Tarefa", "noTasks": "Nenhuma tarefa de avaliação ainda", "noTasksHint": "Crie tarefas de avaliação para testar o desempenho do modelo no conjunto de dados de avaliação", "selectModels": "Selecionar Modelos de Teste", "selectModelsHint": "Pode selecionar múltiplos modelos para avaliação comparativa", "selectJudgeModel": "Selecionar Modelo Professor", "selectJudgeModelPlaceholder": "Por favor, selecione...", "selectJudgeModelHint": "O modelo professor é usado para avaliar questões de resposta curta e questões abertas, não pode ser o mesmo que o modelo de teste", "judgeModel": "Modelo Professor", "filterByType": "Filtrar por Tipo de Questão", "filterByTypeHint": "Se não selecionar, usará todas as questões", "selectedQuestions": "Questões Selecionadas", "questions": "questões", "hasSubjectiveHint": "Inclui questões subjetivas (resposta curta/aberta), precisa selecionar modelo professor para pontuação", "hasSubjective": "Inclui Questões Subjetivas", "startEval": "Iniciar Avaliação", "progress": "Progresso", "totalQuestions": "Quantidade de Questões", "status": "Status", "totalScore": "Pontuação Total", "correctCount": "Quantidade de Acertos", "accuracy": "Taxa de Acerto", "statsByType": "Estatísticas por Tipo de Questão", "resultDetails": "Detalhes dos Resultados da Avaliação", "question": "Questão", "questionType": "Tipo de Questão", "result": "Resultado", "score": "Pontuação", "correctAnswer": "Resposta Correta", "modelAnswer": "Resposta do Modelo", "judgeResponse": "Pontuação do Modelo Professor", "interrupt": "Interromper Tarefa", "statusProcessing": "Em Andamento", "statusCompleted": "Concluído", "statusFailed": "Falhou", "statusInterrupted": "Interrompido", "deleteConfirmTitle": "Confirmar Exclusão", "deleteConfirmMessage": "Tem certeza de que deseja excluir esta tarefa de avaliação? Esta ação também excluirá todos os resultados de avaliação.", "interruptConfirmTitle": "Confirmar Interrupção", "interruptConfirmMessage": "Tem certeza de que deseja interromper esta tarefa de avaliação? Os resultados de avaliação concluídos serão retidos.", "errorNoModels": "Por favor, selecione pelo menos um modelo de teste", "errorNoQuestions": "Nenhuma questão de avaliação disponível", "errorNoJudgeModel": "Existem questões subjetivas, por favor, selecione um modelo professor para pontuação", "errorJudgeSameAsTest": "O modelo professor não pode ser o mesmo que o modelo de teste", "errorCreateFailed": "Falha ao criar tarefa de avaliação", "errorLoadFailed": "Falha ao carregar tarefa de avaliação", "errorDeleteFailed": "Falha ao excluir tarefa de avaliação", "errorInterruptFailed": "Falha ao interromper tarefa de avaliação", "statusSuccess": "Sucesso", "statusFormatError": "Erro de Formato", "statusApiError": "Erro de API", "statusUnknown": "Status Desconhecido", "duration": "Duração", "answerStatus": "Status da Resposta", "modelInfo": "Informações do Modelo", "reportTitle": "Relatório de Avaliação de Capacidade do Modelo", "taskIdLabel": "ID da Tarefa", "pageInfo": "Página {{page}} / {{totalPages}}", "noMatchingResults": "Nenhum resultado de avaliação correspondente ainda", "reportFooter": "Easy Dataset Evaluation System · Gerado por IA", "finalSelection": "Seleção Final:", "questionsSuffix": "questões", "noModelsAvailable": "Nenhum modelo disponível ainda, por favor, configure o modelo nas configurações primeiro", "filterTitle": "Filtro de Questões", "clearFilter": "Limpar Filtro", "searchKeyword": "Pesquisar Palavra-chave", "searchPlaceholder": "Pesquisar conteúdo da questão ou resposta...", "filterByTypeLabel": "Filtro por Tipo", "filterByTagLabel": "Filtro por Tag", "questionCountLabel": "Quantidade de Questões:", "useAllQuestions": "Usar Todos os Resultados do Filtro", "randomSampleHint": "Será extraída aleatoriamente {{questionCount}} questão(ões) de {{filteredCount}}", "durationFormat": "(Duração {{time}}s)", "totalQuestionsLabel": "Total de Questões", "correctLabel": "Correto", "incorrectLabel": "Incorreto", "judgeComment": "Comentário do Professor de IA:", "scoreUnit": "pontos", "shortAnswer": "Resposta Curta", "openEnded": "Questão Aberta", "scoreAnchorsTitle": "Regras de Pontuação para {{type}}", "customizable": "Personalizável", "scoreAnchorsHint": "Personalizar critérios de pontuação, usados para orientar o LLM na avaliação da qualidade das respostas do modelo", "restoreDefault": "Restaurar Padrão", "scoreRange": "Intervalo de Pontuação", "scoreDescriptionPlaceholder": "Por favor, insira a descrição do critério de pontuação para este intervalo..." }, "blindTest": { "title": "Tarefas de Teste Cego Manual", "createTitle": "Criar Tarefa de Teste Cego", "createTask": "Criar Tarefa", "noTasks": "Nenhuma tarefa de teste cego ainda", "noTasksHint": "Crie tarefas de teste cego para comparar a qualidade das respostas de dois modelos", "selectModels": "Selecionar Modelos para Comparação", "modelA": "Modelo A", "modelB": "Modelo B", "modelComparison": "Comparação de Modelos", "selectQuestions": "Selecionar Questões de Teste", "questionType": "Tipo de Questão", "questionTypeHint": "Tarefas de teste cego suportam apenas questões de resposta curta e questões abertas", "filterByTag": "Filtrar por Tag", "questionCount": "Quantidade de Questões", "availableQuestions": "Questões Disponíveis: {{count}}", "useAllQuestions": "Usar Todos os Resultados do Filtro", "randomSample": "Será extraída aleatoriamente {{count}} questão(ões)", "startBlindTest": "Iniciar Teste Cego", "creating": "Criando...", "noModelsAvailable": "Nenhum modelo disponível ainda, por favor, configure o modelo nas configurações primeiro", "errorSelectModelA": "Por favor, selecione o Modelo A", "errorSelectModelB": "Por favor, selecione o Modelo B", "errorSameModel": "Os dois modelos não podem ser iguais", "errorNoQuestions": "Nenhuma questão correspondente", "statusProcessing": "Em Andamento", "statusCompleted": "Concluído", "statusFailed": "Falhou", "statusInterrupted": "Interrompido", "progress": "Progresso", "viewDetails": "Ver Detalhes", "continue": "Continuar Teste Cego", "interrupt": "Interromper Tarefa", "deleteConfirmTitle": "Confirmar Exclusão", "deleteConfirmMessage": "Tem certeza de que deseja excluir esta tarefa de teste cego? Esta ação não pode ser desfeita.", "interruptConfirmTitle": "Confirmar Interrupção", "interruptConfirmMessage": "Tem certeza de que deseja interromper esta tarefa de teste cego? Os resultados de avaliação concluídos serão retidos.", "inProgress": "Teste Cego em Andamento", "generatingAnswers": "Gerando respostas...", "question": "Questão", "answerA": "Resposta A", "answerB": "Resposta B", "duration": "Duração", "whichBetter": "Qual resposta é melhor?", "leftBetter": "Esquerda é Melhor", "rightBetter": "Direita é Melhor", "bothGood": "Ambas são Boas", "bothBad": "Ambas são Ruins", "loadQuestion": "Carregar Questão", "taskNotFound": "Tarefa não existe", "resultTitle": "Resultados do Teste Cego", "resultSummary": "Resumo dos Resultados de Avaliação", "wins": "Vitórias", "times": "vezes", "totalQuestions": "Total de Questões", "ties": "Empates", "detailResults": "Resultados Detalhados", "left": "Esquerda", "right": "Direita" } } ================================================ FILE: locales/tr/translation.json ================================================ { "language": { "switchToEnglish": "İngilizce'ye Geç", "switchToChinese": "Çince'ye Geç", "switchToTurkish": "Türkçe'ye Geç", "switcherTitle": "Dil Değiştir / Change Language / 切换语言", "english": "İngilizce", "chineseSimplified": "Basitleştirilmiş Çince", "turkish": "Türkçe", "portugues": "Portugês", "en": "EN", "zh": "中", "tr": "TR" }, "theme": { "switchToLight": "Açık Moda Geç", "switchToDark": "Koyu Moda Geç" }, "settings": { "promptConfig": "İstem Yapılandırması", "promptsDescription": "Projede kullanılan istemlerinizi yapılandırın, global istemler ve senaryo özel istemler desteklenir.", "globalPrompt": "Global İstem", "questionPrompt": "Soru Üretme İstemi", "answerPrompt": "Cevap Üretme İstemi", "labelPrompt": "Soru Etiketleme İstemi", "domainTreePrompt": "Alan Ağacı Oluşturma İstemi", "globalPromptPlaceholder": "Tüm senaryolar için temel istem olacak global istemi girin", "questionPromptPlaceholder": "Soru üretmek için istemi girin", "answerPromptPlaceholder": "Cevap üretmek için istemi girin", "labelPromptPlaceholder": "Soru etiketlemek için istemi girin (şu anda desteklenmiyor)", "domainTreePromptPlaceholder": "Alan ağacı oluşturmak için istemi girin", "cleanPrompt": "Veri Temizleme İstemi", "cleanPromptPlaceholder": "Veri temizleme için özel istem girin", "loadPromptsFailed": "İstem yapılandırmaları yüklenemedi", "savePromptsSuccess": "İstem yapılandırmaları başarıyla kaydedildi", "savePromptsFailed": "İstem yapılandırmaları kaydedilemedi", "title": "Ayarlar", "basicInfo": "Temel Bilgiler", "modelConfig": "Model Yapılandırması", "taskConfig": "Görev Yapılandırması", "tabsAriaLabel": "Ayarlar Sekmeleri", "idNotEditable": "Proje ID'si düzenlenemez", "saveBasicInfo": "Temel Bilgileri Kaydet", "saveSuccess": "Başarıyla Kaydedildi", "saveFailed": "Kaydetme Başarısız", "deleteSuccess": "Başarıyla Silindi", "deleteFailed": "Silme Başarısız", "fetchTasksFailed": "Görev ayarları getirilemedi", "saveTasksFailed": "Görev ayarları kaydedilemedi", "textSplitSettings": "Metin Bölme Ayarları", "minLength": "Minimum Uzunluk", "maxLength": "Maksimum Bölme Uzunluğu", "textSplitDescription": "Metin bölme uzunluk aralığını ayarlayın", "splitType": "Bölme Stratejisi", "splitTypeMarkdown": "Belge Yapısı Bölme (Markdown)", "splitTypeMarkdownDesc": "Metni belgedeki başlıklara göre otomatik olarak bölerek anlamsal bütünlüğü korur. Net yapıya sahip Markdown belgeleri için uygundur.", "splitTypeRecursive": "Metin Yapısı Bölme (Özel Ayırıcı)", "splitTypeRecursiveDesc": "Çok seviyeli ayırıcıları (yapılandırılabilir) özyinelemeli olarak dener. Önce yüksek öncelikli ayırıcıları, sonra ikincil ayırıcıları kullanır. Karmaşık belgeler için uygundur.", "splitTypeText": "Sabit Uzunlukta Bölme (Karakter)", "splitTypeTextDesc": "Metni belirtilen ayırıcıya (yapılandırılabilir) göre böler, ardından belirtilen uzunluğa göre birleştirir. Sıradan metin dosyaları için uygundur.", "splitTypeToken": "Sabit Uzunlukta Bölme (Token)", "splitTypeTokenDesc": "Token sayısına göre (karakter sayısı değil) bloklar.", "splitTypeCode": "Program Kodu Akıllı Bölme", "splitTypeCodeDesc": "Farklı programlama dillerinin sözdizimi yapısına göre akıllıca bloklar, sözdiziminin eksik olduğu yerlerde bölmekten kaçınır.", "splitTypeCustom": "Özel Sembol Bölme", "splitTypeCustomDesc": "Belgeleri özel sembollere göre böler. Ayırıcı atılır ve bölünen metin blokları blok boyutundan etkilenmez.", "codeLanguage": "Programlama Dili", "codeLanguageHelper": "Dil sözdizimi tabanlı daha akıllı kod bölme için programlama dilini seçin.", "chunkSize": "Blok Boyutu", "chunkOverlap": "Blok Örtüşmesi", "separator": "Ayırıcı", "separatorHelper": "Metni bölmek için kullanılan ayırıcı, örn. \\n\\n boş satırlar için", "customSeparator": "Özel Ayırıcı", "customSeparatorHelper": "Metni bölmek için kullanılan özel ayırıcı, örn. --- veya ===", "separators": "Ayırıcılar Listesi", "separatorsInput": "Ayırıcılar (virgülle ayrılmış)", "separatorsHelper": "Öncelik sırasına göre virgülle ayrılmış ayırıcılar listesi", "questionGenSettings": "Soru Üretme Ayarları", "questionGenLength": "Soru Üretme Uzunluğu: {{length}}", "questionMaskRemovingProbability": "Soru İşaretlerini Kaldırma Olasılığı: {{probability}}%", "questionGenDescription": "Üretilen sorular için maksimum uzunluğu ayarlayın", "huggingfaceSettings": "Hugging Face Ayarları", "datasetUpload": "Veri Seti Yükleme Ayarları", "huggingfaceToken": "Hugging Face Token", "huggingfaceNotImplemented": "", "concurrencyLimit": "Eşzamanlılık Sınırı", "concurrencyLimitHelper": "Soru üretme ve veri seti üretme görevlerinin eşzamanlı sayısını sınırlayın.", "saveTaskConfig": "Görev Yapılandırmasını Kaydet", "pdfSettings": "PDF dosya dönüştürme yapılandırması", "minerUToken": "MinerU Token yapılandırması", "minerUHelper": "MinerU Token sadece 14 gün geçerlidir. Lütfen Token'ı zamanında değiştirin", "minerULocalUrl": "PDF Dönüştürme (MinerU Local) URL Yapılandırması", "vision": "Özel büyük ölçekli görüş modeli yapılandırması", "visionConcurrencyLimit": "Özel büyük ölçekli görüş modelleri için eşzamanlılık sınırı", "prompts": { "selectPromptFirst": "Lütfen soldan bir istem seçin", "customized": "Özelleştirilmiş", "editPrompt": "İstemi Düzenle", "restoreDefault": "Varsayılana Geri Dön", "promptType": "İstem Türü", "keyName": "Anahtar Adı", "contentPlaceholder": "Lütfen özel istem içeriğini girin...", "restoreDefaultContent": "Varsayılan İçeriği Geri Yükle", "noPromptsAvailable": "Kullanılabilir istem yok", "restoreSuccess": "Varsayılan isteme başarıyla geri dönüldü", "restoreFailed": "Varsayılan isteme geri dönülemedi", "deleteError": "İstem silinirken hata:", "saveSuccess": "İstem başarıyla kaydedildi", "saveFailed": "İstem kaydedilemedi", "saveError": "İstem kaydedilirken hata:", "createCustomPrompt": "Özel İstem Oluştur", "fetchContentError": "En son istem içeriği getirilemedi:" }, "multiTurnSettings": "Çok Turlu Konuşma Ayarları", "multiTurnSystemPrompt": "Sistem İstemi", "multiTurnSystemPromptHelper": "Çok turlu konuşma üretimi için sistem istemi", "multiTurnScenario": "Konuşma Senaryosu", "multiTurnScenarioHelper": "Konuşma senaryosunu veya bağlamını açıklayın", "multiTurnRounds": "Tur Sayısı", "multiTurnRoleA": "Rol A", "multiTurnRoleAHelper": "Konuşmadaki ilk katılımcının açıklaması", "multiTurnRoleB": "Rol B", "multiTurnRoleBHelper": "Konuşmadaki ikinci katılımcının açıklaması", "multiTurnDescription": "Çok turlu konuşma üretim yapılandırması" }, "questions": { "autoGenerateDataset": "Veri Setini Otomatik Oluştur", "autoGenerateDatasetTip": "Arka plan toplu işleme görevi oluştur: bekleyen soru üretimi olan metin bloklarını otomatik olarak sorgula ve soruları çıkar.", "filterAll": "Tüm Sorular", "filterAnswered": "Cevaplı", "filterUnanswered": "Cevapsız", "filterChunkNamePlaceholder": "Blok adına göre filtrele...", "sourceTypeAll": "Tüm Kaynaklar", "sourceTypeText": "Metin Kaynağı", "sourceTypeImage": "Görsel Kaynağı", "title": "Sorular", "confirmDeleteTitle": "Soruyu Silmeyi Onayla", "confirmDeleteContent": "\"{{question}}\" sorusunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "deleting": "Soru siliniyor...", "batchDeleteTitle": "Toplu Silmeyi Onayla", "batchDeleting": "{{count}} soru siliniyor...", "deleteSuccess": "Soru başarıyla silindi", "deleteFailed": "Soru silinemedi", "batchDeleteSuccess": "{{count}} soru başarıyla silindi", "batchDeletePartial": "Silme tamamlandı, başarılı: {{success}}, başarısız: {{failed}}", "batchDeleteFailed": "Sorular toplu olarak silinemedi", "noQuestionsSelected": "Lütfen önce soruları seçin", "batchGenerateStart": "{{count}} soru için veri setleri oluşturuluyor", "invalidQuestionKey": "Geçersiz soru anahtarı", "listView": "Soru Listesi", "treeView": "Alan Ağacı", "selectAll": "Tümünü Seç", "selectedCount": "{{count}} soru seçildi", "totalCount": "Toplam {{count}} soru", "searchPlaceholder": "Soruları veya etiketleri ara...", "searchModeInclude": "Dahil et", "searchModeExclude": "Hariç tut", "searchTargetAll": "Soru+Etiket", "searchTargetQuestion": "Soru", "searchTargetTag": "Etiket", "deleteSelected": "Seçilenleri Sil", "batchGenerate": "Toplu Veri Seti Oluştur", "generating": "Veri Seti Oluşturuluyor", "generatingProgress": "Tamamlanan: {{completed}}/{{total}}", "generatedCount": "Oluşturulan: {{count}}", "pleaseWait": "Lütfen bekleyin, işleniyor...", "createSuccess": "Soru başarıyla oluşturuldu", "updateSuccess": "Soru başarıyla güncellendi", "operationSuccess": "İşlem başarılı", "operationFailed": "İşlem başarısız", "editQuestion": "Soruyu Düzenle", "questionContent": "Soru İçeriği", "sourceType": "Veri Kaynağı Türü", "sourceType.text": "Metin", "sourceType.image": "Görsel", "selectChunk": "Metin Bloğu Seç", "searchChunk": "Metin bloklarını ara...", "selectImage": "Görsel Seç", "searchImage": "Görselleri ara...", "selectTag": "Etiket Seç", "searchTag": "Etiketleri ara...", "createQuestion": "Soru Oluştur", "createNormalQuestion": "Normal Soru Oluştur", "createQuestionTemplate": "Soru Şablonu Oluştur", "questionPlaceholder": "Lütfen sorunuzu girin", "noChunkSelected": "Lütfen önce bir metin bloğu seçin", "noTagSelected": "Lütfen bir etiket seçin", "fetchTemplatesFailed": "Soru şablonları getirilemedi", "createTemplateSuccess": "Soru şablonu başarıyla oluşturuldu", "createTemplateFailed": "Soru şablonu oluşturulamadı", "updateTemplateSuccess": "Soru şablonu başarıyla güncellendi", "updateTemplateFailed": "Soru şablonu güncellenemedi", "deleteTemplateSuccess": "Soru şablonu başarıyla silindi", "deleteTemplateFailed": "Soru şablonu silinemedi", "template": { "management": "Soru Şablonları", "create": "Şablon Oluştur", "edit": "Şablonu Düzenle", "question": "Soru İçeriği", "description": "İstem", "descriptionHelp": "Bu soru şablonuyla ilgili AI cevapları üretilirken genel isteme dahil edilir, nihai cevap üretim sonuçlarını etkilemek için kullanılır", "noTemplates": "Henüz soru şablonu yok, eklemek için oluştur butonuna tıklayın", "deleteConfirm": "Bu soru şablonunu silmek istediğinizden emin misiniz?", "used": "Kullanıldı", "addLabel": "Etiket Ekle", "customFormat": "Özel Format", "customFormatHelp": "JSON format çıktı kısıtlaması girin", "customFormatInfo": "Bu format, çıktı formatını kısıtlamak için LLM'e istem olarak sağlanacaktır", "sourceTypeInfo": "Veri Kaynağı Türü", "sourceType": { "label": "Veri Kaynağı Türü", "image": "Görsel", "text": "Metin" }, "answerType": { "label": "Cevap Türü", "text": "Metin", "tags": "Etiketler", "customFormat": "Özel Format" }, "errors": { "questionRequired": "Lütfen soru içeriğini girin", "labelsRequired": "Etiket türü sorular en az bir etiket gerektirir", "customFormatRequired": "Lütfen özel format girin", "invalidJson": "Geçersiz JSON formatı" }, "autoGenerate": "Şablon oluşturduktan sonra otomatik soru oluştur", "autoGenerateHelpText": "Projedeki tüm metin blokları için bu şablona dayalı sorular otomatik olarak oluşturulacaktır", "autoGenerateHelpImage": "Projedeki tüm görseller için bu şablona dayalı sorular otomatik olarak oluşturulacaktır", "confirmAutoGenerate": "Otomatik Soru Oluşturmayı Onayla", "confirmAutoGenerateTextMessage": "Otomatik soru oluşturmayı seçtiniz. Sistem, projedeki tüm metin blokları için bu şablona dayalı sorular oluşturacaktır.", "confirmAutoGenerateImageMessage": "Otomatik soru oluşturmayı seçtiniz. Sistem, projedeki tüm görseller için bu şablona dayalı sorular oluşturacaktır.", "autoGenerateWarning": "Bu işlem çok sayıda soru oluşturabilir. Devam etmek için lütfen onaylayın.", "autoGenerateSuccess": "{{count}} veri kaynağı için başarıyla soru oluşturuldu", "autoGeneratePartialFail": "{{success}} soru başarıyla oluşturuldu, {{fail}} başarısız", "autoGenerateFailed": "Otomatik soru oluşturma başarısız" }, "generateSingleTurnDataset": "Tek Turlu Veri Seti Oluştur", "generateSingleTurnDatasetDesc": "Sorulara dayalı S&C veri seti oluştur", "generateMultiTurnDataset": "Çok Turlu Veri Seti Oluştur", "generateImageDataset": "Görsel S&C Veri Seti Oluştur", "generateMultiTurnDatasetDesc": "Sorulara dayalı çok turlu konuşma veri seti oluştur", "deleteConfirm": "Bu soruyu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." }, "monitoring": { "title": "Resource Monitoring Dashboard", "timeRange": { "24h": "Last 24 hours", "7d": "Last 7 days", "30d": "Last 30 days" }, "filters": { "allProjects": "All Projects", "allProviders": "All Providers", "allStatus": "All Status" }, "status": { "success": "Success", "failed": "Failed" }, "actions": { "export": "Export Report" }, "stats": { "totalTokens": "Total Token Usage", "avgTokensPerCall": "Avg Tokens per Call", "totalCalls": "Total Calls", "avgLatency": "Avg Latency", "inputOutput": "Input: {{input}} · Output: {{output}}", "successCalls": "{{count}} Success", "failedCalls": "{{count}} Failed", "failureRate": "{{rate}}% Failure Rate", "basedOnSuccessCalls": "Based on {{count}} successful calls", "noSuccessCalls": "No successful calls" }, "charts": { "tokenTrend": "Token Usage Trend", "inputLegend": "Input", "outputLegend": "Output", "distributionTitle": "Token Usage Distribution (by Model)", "distributionSubtitle": "Resource usage share by model", "tokensTooltip": "{{value}}K Tokens" }, "table": { "title": "Usage Details", "searchPlaceholder": "Search project, model, or failure reason...", "empty": "No data", "rowsPerPage": "Rows per page:", "columns": { "projectName": "Project", "provider": "Provider", "model": "Model", "status": "Status", "failureReason": "Failure Reason", "inputTokens": "Input Tokens", "outputTokens": "Output Tokens", "totalTokens": "Total", "calls": "Calls", "avgLatency": "Avg Latency" } }, "errors": { "fetchSummaryFailed": "Failed to fetch monitoring summary", "fetchLogsFailed": "Failed to fetch monitoring logs" } }, "common": { "dataSource": "Veri Kaynağı", "menu": "Menü", "openMenu": "Navigasyon menüsünü aç", "all": "Tümü", "jumpTo": "Git", "unknownError": "Bilinmeyen Hata", "create": "Oluştur", "edit": "Düzenle", "delete": "Sil", "save": "Kaydet", "cancel": "İptal", "confirm": "Onayla", "complete": "Tamamla", "close": "Kapat", "add": "Ekle", "remove": "Kaldır", "loading": "Yükleniyor...", "yes": "Evet", "no": "Hayır", "confirmDelete": "Silmeyi Onayla", "saving": "Kaydediliyor...", "deleting": "Siliniyor...", "actions": "İşlemler", "confirmDeleteDataSet": "Bu veri setini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "noData": "Yok", "failed": "Başarısız", "success": "Başarılı", "backToList": "Listeye Dön", "label": "Etiket", "confirmDeleteDescription": "Bu Dosyayı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "more": "Daha Fazla", "fetchError": "Veri getirme başarısız", "confirmDeleteQuestion": "Bu soruyu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "deleteSuccess": "Başarıyla silindi", "visitGitHub": "GitHub Deposunu Ziyaret Et", "syncOldData": "Eski Verileri Senkronize Et", "copy": "Kopyala", "enabled": "Etkin", "disabled": "Devre Dışı", "copied": "Kopyalandı", "generating": "Oluşturuluyor...", "items": "öğe", "detailInfo": "Detaylı Bilgi" }, "home": { "title": "Easy Dataset", "subtitle": "Büyük Dil Modelleri için ince ayar veri setleri oluşturmak için güçlü bir araç", "createProject": "Proje Oluştur", "searchDataset": "Açık Veri Setlerinde Ara" }, "projects": { "reuseConfig": "Model Yapılandırmasını Yeniden Kullan", "noReuse": "Yapılandırma Yeniden Kullanma", "selectProject": "Proje Seç", "fetchFailed": "Proje listesi getirilemedi", "fetchError": "Proje listesi getirilirken hata", "loading": "Projeleriniz yükleniyor...", "createFailed": "Proje oluşturulamadı", "createError": "Proje oluşturulurken hata", "createNew": "Yeni Proje Oluştur", "saveFailed": "Proje kaydedilemedi", "id": "Proje ID", "name": "Proje Adı", "description": "Proje Açıklaması", "questions": "Sorular", "datasets": "Veri Setleri", "lastUpdated": "Son Güncelleme", "viewDetails": "Detayları Görüntüle", "createFirst": "Lütfen önce bir proje oluşturun", "noProjects": "Proje bulunamadı", "notExist": "Proje mevcut değil.", "createProject": "Proje Oluştur", "deleteConfirm": "Bu projeyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "deleteSuccess": "Proje başarıyla silindi", "deleteFailed": "Proje silinemedi", "backToHome": "Ana Sayfaya Dön", "deleteConfirmTitle": "Silmeyi Onayla" }, "textSplit": { "dragToUpload": "Yüklemek için dosyaları sürükleyin", "fileList": "Dosya Listesi", "autoGenerateQuestions": "Otomatik Oluştur", "autoGenerateQuestionsTip": "Arka plan toplu işleme görevi oluştur: bekleyen soru üretimi olan metin bloklarını otomatik olarak sorgula ve soruları çıkar.", "exportChunks": "Blokları Dışa Aktar", "allChunks": "Tüm Metin Blokları", "generatedQuestions2": "Sorulu", "ungeneratedQuestions": "Sorusuz", "noFilesUploaded": "Henüz dosya yüklenmedi", "unknownFile": "Bilinmeyen Dosya", "fetchFilesFailed": "Dosyalar getirilemedi", "editTag": "Etiketi Düzenle", "deleteTag": "Etiketi Sil", "addTag": "Etiket Ekle", "selectedCount": "{{count}} metin bloğu seçildi", "totalCount": "toplam {{count}} metin bloğu", "batchGenerateQuestions": "Toplu Oluştur", "uploadedDocuments": "{{count}} Belge Yüklendi", "title": "Metinler", "uploadNewDocument": "Yeni Belge Yükle", "selectFile": "Dosya Seç", "markdownOnly": "Şu anda yalnızca Markdown (.md) format dosyaları destekleniyor", "supportedFormats": "Desteklenen formatlar: .pdf .md, .txt, .docx", "uploadAndProcess": "Yükle ve İşle", "selectedFiles": "Seçilen Dosyalar ({{count}})", "oneFileMessage": "Dosya yüklenemez, lütfen önce mevcut dosyayı silin", "mutilFileMessage": "Yeni dosya yüklendikten sonra alan ağacı yeniden oluşturulacak", "noChunks": "Metin bloğu bulunamadı", "chunkDetails": "Blok Detayları: {{chunkId}}", "fetchChunksFailed": "Metin blokları getirilemedi", "fetchChunksError": "Metin blokları getirilirken hata", "fileResultReceived": "Dosya sonucu alındı", "fileUploadSuccess": "Dosya başarıyla yüklendi", "splitTextFailed": "Metin bölme başarısız", "splitTextError": "Metin bölme hatası", "deleteChunkFailed": "Metin bloğu silinemedi", "deleteChunkError": "Metin bloğu silinirken hata", "selectModelFirst": "Lütfen önce bir model seçin, üst gezinme çubuğundan seçebilirsiniz", "modelNotAvailable": "Seçilen model kullanılamıyor, lütfen tekrar seçin", "generateQuestionsFailed": "{{chunkId}} bloğu için soru üretilemedi", "questionsGenerated": "{{total}} soru üretildi", "customSplitMode": "Özel Bölme Modu", "customSplitInstructions": "Bölme noktaları eklemek için metin seçin. Sistem, seçtiğiniz konumlara bölme işaretleyicileri yerleştirecektir.", "splitPointsList": "Eklenen Bölme Noktaları", "saveSplitPoints": "Bölme Noktalarını Kaydet", "confirmCustomSplitTitle": "Bölme Değişimini Onayla", "confirmCustomSplitMessage": "Not: Özel bölme noktaları, bu belge için önceki otomatik bölme sonuçlarının yerini alacaktır. Devam etmek istiyor musunuz?", "customSplitSuccess": "Özel bölme başarıyla kaydedildi", "customSplitFailed": "Özel bölme kaydedilemedi", "missingRequiredData": "Gerekli veri eksik", "chunksPreview": "Blok Boyutu Önizlemesi", "chunk": "Blok", "characters": " karakter", "questionsGeneratedSuccess": "Metin bloğu için başarıyla {{total}} soru üretildi", "generateQuestionsForChunkFailed": "{{chunkId}} bloğu için soru üretilemedi", "generateQuestionsForChunkError": "{{chunkId}} bloğu için soru üretilirken hata", "generateQuestionsError": "Soru üretilirken hata", "partialSuccess": "Kısmi başarılı soru üretimi ({{successCount}}/{{total}}), {{errorCount}} blok başarısız", "allSuccess": "{{successCount}} metin bloğu için başarıyla {{totalQuestions}} soru üretildi", "fileDeleted": "{{fileName}} dosyası silindi, metin bloğu listesi yenileniyor", "tabs": { "smartSplit": "Akıllı Bölme", "domainAnalysis": "Alan Analizi" }, "loading": "Yükleniyor...", "fetchingDocuments": "Belge verileri getiriliyor", "processing": "İşleniyor...", "progressStatus": "{{total}} metin bloğu seçildi, {{completed}} tamamlandı", "processingPleaseWait": "İşleniyor, lütfen bekleyin!", "oneFileLimit": "Dosya yüklenemez, zaten yüklenmiş bir dosya var", "unsupportedFormat": "Desteklenmeyen dosya formatı: {{files}}", "modelInfoParseError": "Model bilgisi ayrıştırılamadı", "uploadFailed": "Yükleme başarısız", "uploadSuccess": "{{count}} dosya başarıyla yüklendi", "deleteFailed": "Dosya silinemedi", "deleteSuccess": "{{fileName}} dosyası başarıyla silindi", "generatedQuestions": "{{count}} Soru", "viewDetails": "Detayları Görüntüle", "generateQuestions": "Soru Üret", "dataCleaning": "Veri Temizleme", "batchDataCleaning": "Toplu Veri Temizleme", "autoDataCleaning": "Otomatik Veri Temizleme", "autoDataCleaningTip": "Arka plan toplu işleme görevi oluştur: tüm metin bloklarını otomatik olarak temizle", "dataCleaningSuccess": "Veri temizleme tamamlandı, orijinal uzunluk: {{originalLength}}, temizlenmiş uzunluk: {{cleanedLength}}", "dataCleaningFailed": "{{chunkId}} metin bloğu için veri temizleme başarısız", "dataCleaningForChunkSuccess": "{{chunkId}} metin bloğu için veri temizleme tamamlandı", "dataCleaningForChunkFailed": "{{chunkId}} metin bloğu için veri temizleme başarısız", "dataCleaningForChunkError": "{{chunkId}} metin bloğu için veri temizleme hatası", "dataCleaningPartialSuccess": "Kısmi veri temizleme başarısı ({{successCount}}/{{total}}), {{errorCount}} metin bloğu başarısız", "dataCleaningAllSuccess": "{{successCount}} metin bloğu için başarıyla veri temizleme tamamlandı", "charsCount": "Karakter", "pdfProcessStatus": "Toplam {{total}} dosya, {{completed}} dönüştürüldü", "pdfPageProcessStatus": "{{fileName}} işleniyor, {{total}} sayfadan {{completed}} sayfa dönüştürüldü", "pdfProcessing": "Dosya dönüştürülüyor...", "pdfProcessingFailed": "Dosya dönüştürme başarısız!", "pdfProcess": "Dosya algılandı!", "selectPdfProcessingStrategy": "Lütfen dosya işleme yöntemini seçin:", "pdfProcessingStrategyDefault": "Varsayılan", "pdfProcessingStrategyDefaultHelper": "Yerleşik PDF ayrıştırma stratejisini kullan", "pdfProcessingStrategyMinerUHelper": "Ayrıştırma için MinerU API kullan. Lütfen önce MinerU API Token yapılandırın", "pdfProcessingStrategyVision": "Özel Görsel Model", "pdfProcessingStrategyVisionHelper": "Ayrıştırma için özel görsel model kullan", "pdfProcessingToast": "Dosya algılandı. Sistem, dosyayı ayrıştırmak için bir arka plan görevi oluşturacak", "pdfProcessingWaring": "Devam eden bir dosya işleme görevi var. Görevin tamamlanmasını beklemeden diğer işlemleri yaparsanız veri üretim kalitesi etkilenebilir!", "pdfProcessingLoading": "Dosya dönüştürme görevi çalıştırılıyor, lütfen görev tamamlanana kadar yeni dosya yüklemeyin...", "basicPdfParsing": "Temel PDF Ayrıştırma", "basicPdfParsingDesc": "Basit PDF dosyalarının temel ana hatlarını tanıyabilir, yüksek hız", "mineruApiDesc": "Formüller ve grafikler dahil karmaşık PDF dosyalarını tanıyabilir (MinerU API Key yapılandırması gerektirir)", "mineruLocalDesc": "Formüller ve grafikler dahil karmaşık PDF dosyalarını tanıyabilir (MinerU Local URL yapılandırması gerektirir)", "mineruApiDescDisabled": "Lütfen Proje Ayarları - Görev Yapılandırması'na gidip MinerU token ayarlayın", "mineruLocalDisabled": "Lütfen önce [Proje Ayarları - Görev Yapılandırması]'nda MinerU Local URL ayarlayın", "mineruWebPlatform": "MinerU Çevrimiçi Platform Ayrıştırma", "mineruWebPlatformDesc": "Formüller ve grafikler dahil karmaşık PDF dosyalarını tanıyabilir (Başka bir web sitesine yönlendirme gerektirir)", "mineruSelected": "PDF'leri ayrıştırmak için MinerU kullanımı seçildi", "mineruLocalSelected": "PDF'leri ayrıştırmak için MinerU Local kullanımı seçildi", "customVisionModel": "Özel Görsel Model Ayrıştırma", "customVisionModelDesc": "Formüller ve grafikler dahil karmaşık PDF dosyalarını tanıyabilir (Model yapılandırmasına görsel model yapılandırması eklenmesi gerekir)", "customVisionModelSelected": "PDF'leri ayrıştırmak için {{name}} ({{provider}}) görsel büyük modeli kullanımı seçildi", "defaultSelected": "PDF'leri ayrıştırmak için varsayılan yerleşik strateji kullanımı seçildi", "download": "Belgeyi indir", "deleteFile": "Belgeyi sil", "viewChunk": "Metin Bloğunu Görüntüle", "editChunk": "Metin Bloğunu Düzenle {{chunkId}}", "editChunkSuccess": "Metin bloğu başarıyla düzenlendi", "editChunkFailed": "Metin bloğu düzenlenemedi", "editChunkError": "Metin bloğu düzenlenirken hata oluştu", "deleteFileWarning": "Uyarı: Bu belgeyi silmek aşağıdaki ilgili öğeleri de silecektir", "deleteFileWarningChunks": "İlişkili tüm metin blokları", "deleteFileWarningQuestions": "Bu bloklardan üretilen tüm sorular", "deleteFileWarningDatasets": "Bu sorulardan oluşturulan tüm veri setleri", "domainTree": { "firstUploadTitle": "Alan Ağacı Oluşturma", "uploadTitle": "Belge Yükleme - Alan Ağacı İşleme", "deleteTitle": "Belge Silme - Alan Ağacı İşleme", "reviseOption": "Alan Ağacını Revize Et", "reviseDesc": "Eklenen veya silinen belgelere göre mevcut alan ağacını değiştir, sadece değişen kısımları etkiler", "rebuildOption": "Alan Ağacını Yeniden Oluştur", "rebuildDesc": "Tüm belge içeriklerine göre tamamen yeni bir alan ağacı oluştur", "keepOption": "Değiştirme", "keepDesc": "Mevcut alan ağacı yapısını değiştirmeden koru" } }, "domain": { "title": "Alan Bilgi Ağacı", "addRootTag": "Kök Etiket Ekle", "addFirstTag": "İlk Etiket Ekle", "noTags": "Alan etiketleri mevcut değil", "docStructure": "Belge Yapısı", "noToc": "İçindekiler tablosu mevcut değil. Lütfen önce belgeyi yükleyin ve işleyin.", "editTag": "Etiketi Düzenle", "deleteTag": "Etiketi Sil", "addChildTag": "Alt Etiket Ekle", "deleteTagConfirmTitle": "Etiketi Sil", "deleteTagConfirmMessage": "\"{{tag}}\" etiketini silmek istediğinizden emin misiniz?", "deleteWarning": "Bu işlem bu etiketi ve tüm alt etiketlerini, sorularını ve veri setlerini silecektir. Bu işlem geri alınamaz!", "dialog": { "addTitle": "Etiket Ekle", "editTitle": "Etiketi Düzenle", "addChildTitle": "Alt Etiket Ekle", "inputRoot": "Lütfen yeni bir kök etiket adı girin", "inputEdit": "Lütfen etiket adını düzenleyin", "inputChild": "Lütfen \"{label}\" için bir alt etiket ekleyin", "labelName": "Etiket Adı", "saving": "Kaydediliyor...", "save": "Kaydet", "deleteConfirm": "\"{label}\" etiketini silmek istediğinizden emin misiniz?", "deleteWarning": "Bu işlem tüm alt etiketleri de silecektir ve geri alınamaz.", "emptyLabel": "Etiket adı boş olamaz" }, "tabs": { "tree": "Alan Ağacı", "structure": "Belge Yapısı" }, "errors": { "saveFailed": "Etiketler kaydedilemedi" }, "messages": { "updateSuccess": "Etiketler başarıyla güncellendi" } }, "export": { "alpacaSettings": "Alpaca Format Ayarları", "questionFieldType": "Soru Alanı Türü", "useInstruction": "instruction alanını kullan", "useInput": "input alanını kullan", "customInstruction": "Özel Instruction İçeriği", "instructionPlaceholder": "Sabit instruction içeriği girin", "instructionHelperText": "Input alanı kullanılırken burada sabit instruction içeriği belirtebilirsiniz", "title": "Dışa Aktar", "format": "Format", "fileFormat": "Dosya Formatı", "systemPrompt": "Sistem İstemi", "systemPromptPlaceholder": "Lütfen sistem istemi girin...", "ReasoninglanguagePlaceholder": "Lütfen Akıl Yürütme dilini girin: İngilizce veya Çince veya diğerleri", "onlyConfirmed": "Sadece onaylanmış verileri dışa aktar", "example": "Format Örneği", "confirmExport": "Dışa Aktarmayı Onayla", "includeCOT": "Düşünce Zincirini Dahil Et", "cotDescription": "Nihai cevaptan önceki akıl yürütme sürecini içerir", "customFormat": "Özel Format", "customFormatSettings": "Özel Format Ayarları", "questionFieldName": "Soru Alanı Adı", "multilingualThinkingFormat": "Multilingual‑Thinking", "Reasoninglanguage": "Akıl Yürütme dili", "sampleInstruction": "İnsan talimatı (gerekli)", "sampleOutput": "Model yanıtı (gerekli)", "sampleSystem": "Sistem istemi (isteğe bağlı)", "sampleInstruction2": "İkinci talimat", "sampleOutput2": "İkinci yanıt", "sampleSystemShort": "Sistem istemi", "fixedInstruction": "Sabit talimat içeriği", "sampleInput": "İnsan sorusu (gerekli)", "sampleInput2": "İkinci soru", "sampleInputOptional": "İnsan girişi (isteğe bağlı)", "sampleUserMessage": "İnsan talimatı", "sampleAssistantMessage": "Model yanıtı", "sampleAnalysis": "Modelin düşünce zinciri içeriği", "sampleFinal": "Model yanıtı", "sampleThinking": "Modelin düşünce zinciri içeriği", "fetchLabelStatsError": "Etiket istatistikleri getirilemedi:" }, "import": { "title": "İçe Aktar", "fileUpload": "Dosya Yükleme", "fileUploadDescription": "Veri setlerini içe aktarmak için yerel dosyaları yükleyin", "mapFields": "Alan Eşleme", "importing": "İçe Aktarılıyor", "uploadFile": "Dosya Yükle", "supportedFormats": "JSON, JSONL, CSV format dosyalarını destekler", "dragDropFile": "Dosyaları buraya sürükleyin veya dosya seçmek için tıklayın", "dropFileHere": "Dosyayı yüklemek için bırakın", "maxFileSize": "Maksimum dosya boyutu: 50MB", "processingFile": "Dosya işleniyor...", "uploadedFiles": "Yüklenen Dosyalar", "uploadError": "Dosya yükleme başarısız, lütfen dosya formatını kontrol edin", "selectFromSource": "{{source}} kaynağından veri seti seç", "sourceDescription": "Aramak için veri seti adı veya anahtar kelimeler girin", "datasetName": "Veri Seti Adı", "hfPlaceholder": "örn.: squad, glue, imdb", "msPlaceholder": "örn.: damo/nlp_bert_document-classification", "search": "Ara", "searching": "Aranıyor", "searchResults": "Arama Sonuçları", "downloads": "İndirmeler", "download": "İndir", "downloading": "İndiriliyor", "hfNote": "Not: Büyük veri setlerini indirmek uzun sürebilir, test için daha küçük veri setleri seçmeniz önerilir.", "msNote": "Not: ModelScope veri seti indirmesi ağ bağlantısı gerektirir, lütfen ağ bağlantınızı kontrol edin.", "fieldMapping": "Alan Eşleme", "mappingDescription": "Lütfen kaynak veri alanlarını hedef alanlara eşleyin. Sistem olası eşleme ilişkilerini otomatik olarak belirledi, gerektiğinde ayarlayabilirsiniz.", "selectMapping": "Alan Eşlemesini Seç", "questionField": "Soru Alanı", "answerField": "Cevap Alanı", "cotField": "Düşünce Zinciri Alanı", "tagsField": "Etiketler Alanı", "selectField": "Alan Seç", "questionDesc": "Kullanıcının sorusu veya giriş içeriği (gerekli)", "answerDesc": "AI'nın cevabı veya çıktı içeriği (gerekli)", "cotDesc": "Düşünce zinciri veya akıl yürütme süreci (isteğe bağlı)", "tagsDesc": "Etiket dizisi, virgülle ayrılmış birden çok etiket (isteğe bağlı)", "dataPreview": "Veri Önizlemesi", "previewNote": "İlk 3 kaydı gösterir, her alan değeri en fazla 100 karakter gösterir", "confirmMapping": "Eşlemeyi Onayla", "requiredFields": "Lütfen en az soru ve cevap alanı eşlemelerini seçin", "mappingRequired": "Soru ve cevap alanları gereklidir", "duplicateMapping": "Birden çok hedef alanı aynı kaynak alanına eşleyemezsiniz", "noPreviewData": "Önizleme verisi yok", "preparingData": "Veri hazırlanıyor...", "uploadingData": "Veri yükleniyor...", "processing": "İşleniyor... {{processed}}/{{total}}", "completed": "İçe aktarma tamamlandı", "importStats": "İçe Aktarma İstatistikleri", "total": "Toplam: {{count}}", "success": "Başarılı: {{count}}", "failed": "Başarısız: {{count}}", "source": "Veri Kaynağı", "description": "Açıklama", "errors": "Hata Mesajları", "moreErrors": "{{count}} hata daha gösterilmiyor...", "importSuccess": "Veri seti içe aktarma tamamlandı!", "enterDatasetName": "Lütfen veri seti adını girin", "noDatasetFound": "Eşleşen veri seti bulunamadı", "complete": "Tamamla" }, "export_extended": { "answerFieldName": "Cevap Alanı Adı", "cotFieldName": "Cot Alanı Adı", "includeLabels": "Etiketleri Dahil Et", "includeChunk": "Metin Bloğunu Dahil Et", "questionOnly": "Sadece Soruları Dışa Aktar", "localTab": "Yerel Dışa Aktarma", "llamaFactoryTab": "Llama Factory", "huggingFaceTab": "HuggingFace", "configExists": "Yapılandırma Dosyası Mevcut", "configPath": "Yapılandırma Dosyası Yolu", "updateConfig": "LLaMA Factory Yapılandırmasını Güncelle", "noConfig": "Yapılandırma dosyası mevcut değil, oluşturmak için aşağıdaki düğmeye tıklayın", "generateConfig": "LLaMA Factory Yapılandırması Oluştur", "huggingFaceComingSoon": "HuggingFace dışa aktarma özelliği yakında", "uploadToHuggingFace": "HuggingFace'e Yükle", "datasetName": "Veri Seti Adı", "datasetNameHelp": "Format: kullanıcıadı/veri-seti-adı", "privateDataset": "Özel Veri Seti", "datasetSettings": "Veri Seti Ayarları", "exportOptions": "Dışa Aktarma Seçenekleri", "uploadSuccess": "Veri seti HuggingFace'e başarıyla yüklendi", "viewOnHuggingFace": "HuggingFace'de Görüntüle", "noTokenWarning": "Hugging Face Token bulunamadı. Lütfen proje ayarlarında yapılandırın.", "goToSettings": "Ayarlara Git", "tokenHelp": "Token'ınızı HuggingFace ayarlar sayfasından alabilirsiniz" }, "datasets": { "loadingDataset": "Veri Seti Yükleniyor...", "datasetNotFound": "Veri Seti Bulunamadı", "optimizeTitle": "AI Optimize Et", "optimizeAdvice": "Optimizasyon Önerisi", "optimizePlaceholder": "Lütfen cevabı iyileştirmek için önerilerinizi girin, AI önerilerinize göre cevabı ve akıl yürütme zincirini optimize edecektir", "generatingDataset": "Veri Seti Oluşturuluyor", "aiOptimizeAdvicePlaceholder": "Lütfen cevabı iyileştirmek için önerilerinizi girin, AI önerilerinize göre cevabı ve akıl yürütme zincirini optimize edecektir", "aiOptimizeAdvice": "Lütfen cevabı iyileştirmek için önerilerinizi girin, AI önerilerinize göre cevabı ve akıl yürütme zincirini optimize edecektir", "aiOptimize": "AI Optimize Et", "partialSuccess": "Kısmi başarılı veri seti üretimi ({{successCount}}/{{total}}), {{failCount}} soru başarısız", "generating": "Veri Seti Oluşturuluyor", "generateError": "Veri seti oluşturulamadı", "management": "Veri Setleri", "question": "Soru", "filterAll": "Tümü", "filterConfirmed": "Onaylandı", "filterUnconfirmed": "Onaylanmadı", "createdAt": "Oluşturma Tarihi", "model": "Model", "domainTag": "Alan Etiketi", "cot": "COT", "answer": "Cevap", "chunkId": "Metin Bloğu", "confirmed": "Onaylandı", "noTag": "Etiket Yok", "noData": "Veri Yok", "rowsPerPage": "Sayfa başına satır", "pagination": "{{from}}-{{to}} / {{count}}", "confirmDeleteMessage": "Bu veri setini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "questionLabel": "Soru", "fetchFailed": "Veri seti getirilemedi", "deleteFailed": "Veri seti silinemedi", "deleteSuccess": "Başarıyla silindi", "exportSuccess": "Veri seti başarıyla dışa aktarıldı", "exportFailed": "Dışa aktarma başarısız", "exportProgress": "Dışa Aktarma İlerlemesi", "exportingData": "Veri seti dışa aktarılıyor", "processedCount": "İşlenen {{processed}} / {{total}} öğe", "exportInProgress": "Veri getiriliyor, lütfen bekleyin...", "exportFinalizing": "Dosya oluşturuluyor, neredeyse bitti...", "loading": "Veri setleri yükleniyor...", "stats": "Toplam {{total}} veri seti, {{confirmed}} onaylandı ({{percentage}}%)", "selected": "Toplam seçilen: {{count}}", "batchconfirmDeleteMessage": "Seçilen {{count}} soruyu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "batchDelete": "Toplu Sil", "batchDeleteProgress": "Tamamlanan: {{completed}}/{{total}}", "batchDeleteCount": "Silme sayısı: {{count}}", "searchPlaceholder": "Veri setlerinde ara...", "fieldQuestion": "Soru", "fieldAnswer": "Cevap", "fieldCOT": "COT", "fieldLabel": "Alan Etiketi", "moreFilters": "Daha Fazla Filtre", "filtersTitle": "Filtre Seçenekleri", "filterConfirmationStatus": "Onay Durumu", "filterCotStatus": "Düşünce Zinciri Durumu", "filterHasCot": "CoT Var", "filterNoCot": "CoT Yok", "filterScoreRange": "Puan Aralığı", "filterNoteKeyword": "Not Anahtar Kelimesi", "filterNoteKeywordPlaceholder": "Not anahtar kelimesi girin...", "filterChunkName": "Blok Adı", "filterChunkNamePlaceholder": "Blok adı girin...", "filterCustomTag": "Özel Etiket", "resetFilters": "Sıfırla", "applyFilters": "Uygula", "viewDetails": "Detayları Görüntüle", "datasetDetail": "Veri Seti Detayları", "metadata": "Meta Veri", "confirmSave": "Kaydetmeyi Onayla", "unconfirm": "Onayı Kaldır", "unconfirming": "Onay kaldırılıyor...", "uncategorized": "Kategorisiz", "questionCount": "{{count}} Soru", "source": "Kaynak", "generateDataset": "Veri Seti Oluştur", "generateNotImplemented": "Veri seti oluşturma uygulanmadı", "generateSuccess": "Başarıyla veri seti oluşturuldu: {{question}}", "generateFailed": "Veri seti oluşturulamadı: {{error}}", "noTagsAndQuestions": "Etiket ve soru yok", "answerCount": "{{count}} Cevap", "answered": "Cevaplandı", "enableShortcuts": "Sayfa kısayol tuşu", "shortcutsHelp": "← ileri, → geri, y onaylamak, d silmek için basın", "filterDistill": "Damıtılmış Veri Seti", "filterDistillYes": "Damıtılmış Veri Seti", "filterDistillNo": "Damıtılmamış Veri Seti", "evaluation": "Veri Seti Değerlendirmesi", "rating": "Puan", "ratingExcellent": "Mükemmel", "ratingGood": "İyi", "ratingAverage": "Orta", "ratingPoor": "Zayıf", "ratingVeryPoor": "Çok Zayıf", "ratingUnrated": "Puanlanmamış", "customTags": "Özel Etiketler", "addCustomTag": "Özel etiket ekle...", "note": "Not", "addNote": "Not ekle...", "noNote": "Not yok", "clickToAddNote": "Not eklemek için tıklayın...", "enterNote": "Not girin...", "noteShortcuts": "Kaydetmek için Ctrl+Enter, iptal için Esc", "aiEvaluation": "AI Kalite Değerlendirmesi", "addToEval": "Değerlendirme Veri Setine Ekle", "addToEvalSuccess": "Değerlendirme veri setine başarıyla eklendi", "addToEvalFailed": "Ekleme başarısız", "generateEvalVariant": "Değerlendirme Varyantı Oluştur", "generateVariantFailed": "Varyant oluşturulamadı", "saveVariantSuccess": "Değerlendirme veri setine kaydedildi", "saveVariantFailed": "Kaydetme başarısız", "evalVariantTitle": "Değerlendirme Varyantı Oluştur", "evalVariantPreviewTitle": "Oluşturulan Soruları Onayla", "saveToEval": "Değerlendirme Veri Setine Kaydet", "evalVariantConfigHint": "Lütfen soru türünü ve sayısını seçin. AI mevcut S&C çiftine göre yeniden yazacaktır.", "questionType": "Soru Türü", "typeOpenEnded": "Açık uçlu", "typeSingleChoice": "Tek Seçimli", "typeMultipleChoice": "Çok Seçimli", "typeTrueFalse": "Doğru/Yanlış", "typeShortAnswer": "Kısa Cevap", "generateCount": "Oluşturma Sayısı", "evalVariantPreviewHint": "Oluşturulan soruları düzenleyebilir ve doğruladıktan sonra değerlendirme setine kaydedebilirsiniz.", "questionIndex": "Soru {{index}}", "options": "Seçenekler (JSON Dizisi)", "optionsHint": "Örn.: [\"Seçenek A\", \"Seçenek B\"]", "answerArrayHint": "Çok seçimli için lütfen bir dizi girin, örn. [\"A\", \"C\"]", "answerBoolHint": "Doğru/Yanlış için lütfen ✅ veya ❌ girin", "generate": "Oluştur", "updateSuccess": "Güncelleme başarılı", "updateFailed": "Güncelleme başarısız", "evaluate": "Değerlendir", "evaluating": "Değerlendiriliyor...", "batchEvaluate": "Toplu Değerlendir", "selectModelFirst": "Lütfen önce bir model seçin", "evaluateSuccess": "Değerlendirme tamamlandı! Puan: {{score}}/5", "evaluateFailed": "Değerlendirme başarısız", "evaluateError": "Değerlendirme başarısız: {{error}}", "batchEvaluateStarted": "Toplu değerlendirme görevi başlatıldı, arka planda işleniyor", "batchEvaluateStartFailed": "Toplu değerlendirme başlatılamadı", "batchEvaluateFailed": "Toplu değerlendirme başarısız: {{error}}", "scoreRange": "{{min}} - {{max}} puan", "singleTurn": "Tek Turlu S&C Veri Seti", "multiTurn": "Çok Turlu Konuşma Veri Seti", "imageQA": "Görsel S&C Veri Seti", "conversationDetail": "Çok Turlu Konuşma Detayları", "conversationContent": "Konuşma İçeriği", "basicInfo": "Temel Bilgiler", "firstQuestion": "İlk Soru", "conversationScenario": "Konuşma Senaryosu", "conversationRounds": "Konuşma Turları", "modelUsed": "Kullanılan Model", "qualityScore": "Kalite Puanı", "notes": "Notlar", "createTime": "Oluşturma Zamanı", "notSet": "Ayarlanmadı", "noTags": "Etiket Yok", "noNotes": "Not Yok", "notEvaluated": "Değerlendirilmedi", "round": "Tur {{round}}", "system": "Sistem", "user": "Kullanıcı", "assistant": "Asistan", "confirmDelete": "Silmeyi Onayla", "confirmDeleteConversation": "Bu çok turlu konuşma veri setini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "conversationNotFound": "Konuşma veri seti bulunamadı", "fetchDataFailed": "Veri getirilemedi", "saveFailed": "Kaydetme başarısız", "saveSuccess": "Başarıyla kaydedildi", "saving": "Kaydediliyor...", "inputTagsPlaceholder": "Etiketleri girin, boşlukla ayırın", "addNotesPlaceholder": "Not ekle", "noConversations": "Çok turlu konuşma yok", "notRated": "Puanlanmadı", "minScore": "Min Puan", "maxScore": "Max Puan", "unconfirmed": "Onaylanmadı" }, "rating": { "veryPoor": "Çok Zayıf", "poor": "Zayıf", "belowAverage": "Ortalamanın Altında", "fair": "Vasat", "average": "Orta", "good": "İyi", "veryGood": "Çok İyi", "excellent": "Mükemmel", "outstanding": "Üstün", "perfect": "Kusursuz", "unrated": "Puanlanmamış" }, "tags": { "noTags": "Etiket yok", "addTag": "Etiket ekle...", "addCustomTag": "Özel etiket ekle", "maxTagsReached": "Maksimum {{maxTags}} etikete ulaşıldı", "availableTagsHint": "Mevcut etiketlerden seçin veya yeni etiket girin" }, "update": { "newVersion": "Yeni Sürüm", "newVersionAvailable": "Yeni Sürüm Mevcut", "currentVersion": "Mevcut Sürüm", "latestVersion": "En Son Sürüm", "downloadNow": "Şimdi İndir", "downloading": "İndiriliyor", "installNow": "Şimdi Yükle", "updating": "Güncelleniyor...", "updateNow": "Şimdi Güncelle", "viewRelease": "Sürüm Notlarını Görüntüle", "checking": "Güncellemeler kontrol ediliyor...", "noUpdates": "Zaten güncel", "updateError": "Güncelleme Hatası", "updateSuccess": "Güncelleme Başarılı", "restartRequired": "Yeniden Başlatma Gerekli", "restartNow": "Şimdi Yeniden Başlat", "restartLater": "Daha Sonra Yeniden Başlat" }, "datasetSquare": { "title": "Veri Seti Meydanı", "subtitle": "Model eğitimi ve araştırmanızı desteklemek için çeşitli genel veri seti kaynaklarını keşfedin", "searchPlaceholder": "Veri seti anahtar kelimeleri ara...", "searchVia": "Arama yolu", "categoryTitle": "Veri Seti Kategorileri", "categories": { "all": "Tümü", "popular": "Popüler", "chinese": "Çince Kaynaklar", "english": "İngilizce Kaynaklar", "research": "Araştırma Verileri", "multimodal": "Çok Modlu" }, "foundResources": "{{count}} veri seti kaynağı bulundu", "currentFilter": "Mevcut filtre: {{category}}", "noDatasets": "Kriterlerinize uygun veri seti bulunamadı", "tryOtherCategories": "Lütfen diğer kategorileri deneyin veya tüm veri setlerini görüntülemek için geri dönün", "dataset": "Veri Seti", "viewDataset": "Veri Setini Görüntüle" }, "playground": { "title": "Model Testi", "selectModelFirst": "Lütfen bir model seçin", "sendFirstMessage": "Test başlatmak için ilk mesajınızı gönderin", "inputMessage": "Mesaj girin...", "send": "Gönder", "outputMode": "Çıktı Modu", "normalOutput": "Normal Çıktı", "streamingOutput": "Akış Çıktısı", "clearConversation": "Konuşmayı Temizle", "selectModelMax3": "Test için en fazla 3 model seçin", "reasoningProcess": "Akıl Yürütme Zinciri" }, "chunks": { "title": "Metin Bloğu", "defaultTitle": "Varsayılan Başlık" }, "documentation": "Dokümantasyon", "models": { "configNotFound": "Model yapılandırması bulunamadı", "parseError": "Model yapılandırması ayrıştırılamadı", "fetchFailed": "Model getirilemedi", "saveFailed": "Model yapılandırması kaydedilemedi", "pleaseSelectModel": "Lütfen en az bir model seçin", "title": "Model Ayarları", "add": "Model Ekle", "unselectedModel": "Seçilmemiş Model", "unconfiguredAPIKey": "Yapılandırılmamış API Anahtarı", "saveAllModels": "Tüm Modelleri Kaydet", "edit": "Düzenle", "delete": "Sil", "modelName": "Model Adı", "endpoint": "Uç Nokta", "apiKey": "API Anahtarı", "provider": "Sağlayıcı", "localModel": "Yerel Model", "apiKeyConfigured": "API Anahtarı Yapılandırıldı", "apiKeyNotConfigured": "API Anahtarı Yapılandırılmadı", "temperature": "Sıcaklık", "maxTokens": "Maks Token", "maxTokensInputTip": "Kaydırıcı aralığı: 1-{{max}}. Ayrıca herhangi bir pozitif tam sayı girebilirsiniz.", "topP": "Top P", "type": "Model Türü", "text": "Büyük Dil Modeli", "vision": "Görsel Büyük Model", "typeTips": "PDF'leri ayrıştırmak için özel görsel model kullanmak istiyorsanız, lütfen en az bir görsel büyük model yapılandırın", "refresh": "Modelleri Yenile", "configuredModels": "Yapılandırılmış Modeller", "unconfiguredModels": "Yapılandırılmamış Modeller", "noConfiguredModels": "Yapılandırılmış model yok", "noUnconfiguredModels": "Yapılandırılmamış model yok", "checkEndpointHealth": "Uç nokta sağlığını kontrol et", "checkAllEndpointHealth": "Tüm uç noktaları kontrol et", "endpointHealthy": "Uç nokta sağlıklı", "endpointCheckFailed": "Uç nokta kontrolü başarısız", "endpointMissing": "Uç nokta boş", "endpointReachableModelMissing": "Uç nokta erişilebilir, ancak mevcut model dönen listede yok", "healthCheckSummary": "Sağlık kontrolü tamamlandı: {{okCount}} sağlıklı, {{failCount}} başarısız", "checking": "Kontrol ediliyor...", "healthy": "Sağlıklı", "reachable": "Erişilebilir", "unhealthy": "Sağlıksız", "notChecked": "Kontrol edilmedi" }, "stats": { "ongoingProjects": "Devam Eden Projeler", "questionCount": "Soru Sayısı", "generatedDatasets": "Oluşturulan Veri Setleri", "supportedModels": "Desteklenen Modeller" }, "migration": { "title": "Proje Taşıma", "description": "Bazı projelerin veritabanına taşınması gerekiyor. Taşıma, performansı artırabilir ve daha fazla özelliği destekleyebilir.", "projectsList": "Taşınmamış Projeler", "migrate": "Taşımayı Başlat", "migrating": "Taşınıyor...", "success": "{{count}} proje başarıyla taşındı", "failed": "Taşıma başarısız", "checkFailed": "Taşınmamış projeler kontrol edilemedi", "checkError": "Taşınmamış projeler kontrol edilirken hata", "starting": "Taşıma görevi başlatılıyor...", "processing": "Taşıma görevi işleniyor...", "completed": "Taşıma tamamlandı", "startFailed": "Taşıma görevi başlatılamadı", "statusFailed": "Taşıma durumu alınamadı", "taskNotFound": "Taşıma görevi bulunamadı", "progressStatus": "{{completed}}/{{total}} proje taşındı", "openDirectory": "Dizini Aç", "deleteDirectory": "Dizini Sil", "confirmDelete": "Bu proje dizinini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.", "openDirectoryFailed": "Proje dizini açılamadı", "deleteDirectoryFailed": "Proje dizini silinemedi" }, "distill": { "title": "Damıt", "generateRootTags": "Kök Etiketler Oluştur", "generateSubTags": "Alt Etiketler Oluştur", "generateQuestions": "Soru Oluştur", "generateRootTagsTitle": "Kök Alan Etiketleri Oluştur", "generateSubTagsTitle": "{{parentTag}} için Alt Etiketler Oluştur", "generateQuestionsTitle": "{{tag}} için Sorular Oluştur", "parentTag": "Üst Etiket", "parentTagPlaceholder": "Üst etiket adını girin (örn., Spor, Teknoloji)", "parentTagHelp": "Bir alan konusu girin, sistem buna göre ilgili etiketler oluşturacaktır", "generateQuestionsError": "Sorular oluşturulamadı", "tagCount": "Etiket Sayısı", "tagCountHelp": "Oluşturulacak etiket sayısını girin, maksimum 100", "questionCount": "Soru Sayısı", "questionCountHelp": "Oluşturulacak soru sayısını girin, maksimum 100", "generatedTags": "Oluşturulan Etiketler", "generatedQuestions": "Oluşturulan Sorular", "tagPath": "Etiket Yolu", "noTags": "Etiket Yok", "noQuestions": "Soru Yok", "clickGenerateButton": "Etiket oluşturmak için yukarıdaki oluştur butonuna tıklayın", "selectModelFirst": "Lütfen önce bir model seçin", "selectModel": "Model Seç", "generateTagsError": "Etiketler oluşturulamadı", "generateTags": "Etiket Oluştur", "subTags": "alt-etiket", "questions": "soru", "deleteTagConfirmTitle": "Etiketi silmeyi onayla?", "editTagTitle": "Etiketi Düzenle", "tagName": "Etiket Adı", "labelRequired": "Etiket adı boş olamaz", "tagUpdateSuccess": "Etiket başarıyla güncellendi", "tagUpdateFailed": "Etiket güncellenemedi", "unknownTag": "Bilinmeyen Etiket", "autoDistillButton": "Otomatik Damıtma Veri Seti", "autoDistillTitle": "Otomatik Veri Seti Damıtma Yapılandırması", "distillTopic": "Damıtma Konusu", "tagLevels": "Etiket Seviyeleri", "tagLevelsHelper": "Seviye sayısını ayarlayın, maksimum {{max}}", "tagsPerLevel": "Seviye Başına Etiket", "tagsPerLevelHelper": "Her üst etiket altında oluşturulacak alt etiket sayısı, maksimum {{max}}", "questionsPerTag": "Etiket Başına Soru", "questionsPerTagHelper": "Her yaprak etiket için oluşturulacak soru sayısı, maksimum {{max}}", "estimationInfo": "Görev Tahmin Bilgisi", "estimatedTags": "Tahmini Etiketler", "estimatedQuestions": "Tahmini Sorular", "currentTags": "Mevcut Etiketler", "currentQuestions": "Mevcut Sorular", "newTags": "Yeni Etiketler", "newQuestions": "Yeni Sorular", "startAutoDistill": "Otomatik Damıtmayı Başlat", "autoDistillProgress": "Otomatik Damıtma İlerlemesi", "overallProgress": "Genel İlerleme", "tagsProgress": "Etiket Oluşturma İlerlemesi", "questionsProgress": "Soru Üretme İlerlemesi", "currentStage": "Mevcut Aşama", "realTimeLogs": "Gerçek Zamanlı Günlükler", "waitingForLogs": "Günlükler bekleniyor...", "autoDistillStarted": "{{time}} Otomatik damıtma görevi başladı", "autoDistillInsufficientError": "Mevcut yapılandırma yeni etiket veya soru üretmeyecek, lütfen parametreleri ayarlayın", "stageInitializing": "Başlatılıyor...", "stageBuildingLevel1": "Seviye 1 Etiketleri Oluşturuluyor", "stageBuildingLevel2": "Seviye 2 Etiketleri Oluşturuluyor", "stageBuildingLevel3": "Seviye 3 Etiketleri Oluşturuluyor", "stageBuildingLevel4": "Seviye 4 Etiketleri Oluşturuluyor", "stageBuildingLevel5": "Seviye 5 Etiketleri Oluşturuluyor", "stageBuildingQuestions": "Sorular Oluşturuluyor", "stageBuildingDatasets": "Veri Setleri Oluşturuluyor", "stageCompleted": "Görev Tamamlandı", "datasetsProgress": "Veri Setleri İlerlemesi", "rootTopicHelperText": "Varsayılan olarak proje adı üst düzey damıtma konusu olarak kullanılır. Değiştirmek isterseniz, lütfen proje ayarlarına giderek proje adını değiştirin.", "addChildTag": "Alt Etiket Ekle", "datasetType": "Veri Seti Türü", "singleTurnDataset": "Tek Turlu Veri Seti", "multiTurnDataset": "Çok Turlu Veri Seti", "bothDatasetTypes": "Her İki Veri Seti Türünü Oluştur", "autoDistillTaskDetail": "Otomatik Damıtma Görevi: {{topic}}", "backgroundTaskCreated": "Arka plan damıtma görevi oluşturuldu. İlerlemeyi görev yönetiminde kontrol edebilirsiniz.", "backgroundTaskFailed": "Arka plan görevi oluşturulamadı", "taskExecutionError": "Görev yürütme hatası: {{error}}" }, "tasks": { "pending": "{{count}} görev işleniyor", "completed": "görevler tamamlandı", "title": "Görev Yönetim Merkezi", "loading": "Görevler yükleniyor...", "empty": "Görev bulunamadı", "confirmDelete": "Bu görevi silmek istediğinizden emin misiniz?", "confirmAbort": "Bu görevi iptal etmek istediğinizden emin misiniz? Görev durdurulacaktır.", "deleteSuccess": "Görev silindi", "deleteFailed": "Görev silinemedi", "abortSuccess": "Görev iptal edildi", "abortFailed": "Görev iptal edilemedi", "status": { "processing": "İşleniyor", "completed": "Tamamlandı", "failed": "Başarısız", "aborted": "İptal Edildi", "unknown": "Bilinmiyor" }, "types": { "text-processing": "Metin İşleme", "question-generation": "Soru Üretimi", "answer-generation": "Cevap Üretimi", "data-distillation": "Veri Damıtma", "pdf-processing": "PDF İşleme" }, "filters": { "status": "Görev Durumu", "type": "Görev Türü" }, "actions": { "refresh": "Görev listesini yenile", "delete": "Görevi sil", "abort": "Görevi iptal et" }, "table": { "type": "Tür", "status": "Durum", "progress": "İlerleme", "success": "Başarılı", "failed": "Başarısız", "createTime": "Oluşturuldu", "endTime": "Tamamlandı", "duration": "Süre", "model": "Model", "detail": "Detaylar", "actions": "İşlemler" }, "duration": { "seconds": "{{seconds}}s", "minutes": "{{minutes}}d {{seconds}}s", "hours": "{{hours}}s {{minutes}}d" }, "fetchFailed": "Görev listesi getirilemedi", "createSuccess": "Görev başarıyla oluşturuldu", "createFailed": "Görev oluşturulamadı", "multiTurnCreateSuccess": "Çok turlu konuşma veri seti görevi başarıyla oluşturuldu" }, "gaPairs": { "title": "Tür-Kitle Çiftleri Yönetimi", "loading": "GA çiftleri yükleniyor...", "addPair": "GA Çifti Ekle", "saveChanges": "Değişiklikleri Kaydet", "saving": "Kaydediliyor...", "restoreBackup": "Yedeği Geri Yükle", "noGaPairsTitle": "Tür-Kitle Çifti Bulunamadı", "noGaPairsDescription": "Bu dosya için AI destekli Tür-Kitle çiftleri oluştur", "generateGaPairs": "Tür-Kitle Çiftleri Oluştur", "generating": "Oluşturuluyor...", "generateMore": "Daha Fazla Tür-Kitle Çifti Oluştur", "activePairs": "Aktif Tür-Kitle Çiftleri ({{active}}/{{total}})", "pairNumber": "Tür-Kitle Çifti #{{number}}", "active": "Aktif", "deleteTooltip": "GA Çiftini Sil", "genre": "Tür", "genreDescription": "Tür Açıklaması", "audience": "Kitle", "audienceDescription": "Kitle Açıklaması", "addDialogTitle": "Yeni Tür-Kitle Çifti Ekle", "genreTitle": "Tür Başlığı", "audienceTitle": "Kitle Başlığı", "genreTitlePlaceholder": "Tür başlığını girin...", "genreDescPlaceholder": "Türü detaylı olarak açıklayın...", "audienceTitlePlaceholder": "Kitle başlığını girin...", "audienceDescPlaceholder": "Hedef kitleyi detaylı olarak açıklayın...", "cancel": "İptal", "addPairButton": "Tür-Kitle Çifti Ekle", "requiredFields": "Tür Başlığı ve Kitle Başlığı gereklidir", "restoredFromBackup": "Yedekten geri yüklendi", "allPairsDeleted": "Tüm GA çiftleri başarıyla silindi", "pairsSaved": "{{count}} GA çifti başarıyla kaydedildi", "additionalPairsGenerated": "{{count}} ek Tür-Kitle çifti başarıyla oluşturuldu. Toplam: {{total}}", "validationError": "GA çifti {{number}}: Tür ve Kitle başlıkları gereklidir", "loadError": "GA çiftleri yüklenemedi: {{error}}", "generateError": "GA çiftleri oluşturulamadı", "saveError": "GA çiftleri kaydedilemedi", "noActiveModel": "GA çiftleri oluşturmadan önce ayarlarda bir AI modeli yapılandırın.", "contentTooShort": "Dosya içeriği çok kısa veya GA çifti oluşturma için uygun değil.", "configError": "AI model yapılandırma hatası. Gerekli bağımlılıklar yüklenmemiş olabilir.", "serverError": "Sunucu hatası ({{status}}). Lütfen daha sonra tekrar deneyin.", "emptyResponse": "Oluşturma servisinden boş yanıt", "generationFailed": "Oluşturma başarısız", "saveOperationFailed": "Kaydetme işlemi başarısız", "serviceNotAvailable": "GA Çiftleri oluşturma servisi kullanılamıyor. Lütfen API yapılandırmanızı kontrol edin.", "requestFailed": "İstek başarısız ({{status}}). Lütfen tekrar deneyin.", "internalServerError": "İç sunucu hatası oluştu.", "batchGenerate": "Toplu GA Çifti Oluştur", "batchGenerateDescription": "Seçilen {{count}} dosya için toplu GA çiftleri oluşturulacaktır. Bu işlem biraz zaman alabilir.", "appendMode": "Ekleme Modu", "appendModeDescription": "Zaten GA çiftleri olan dosyalar için üzerine yazmak yerine ek GA çiftleri oluştur", "selectAtLeastOneFile": "Lütfen önce en az bir dosya seçin", "noDefaultModel": "Varsayılan model ayarlanmamış, lütfen önce proje ayarlarında bir model yapılandırın", "incompleteModelConfig": "Model yapılandırması eksik, lütfen model ayarlarını kontrol edin", "missingApiKey": "Model API anahtarı yapılandırılmamış, lütfen model ayarlarında API anahtarı ekleyin", "loadingProjectModel": "Proje modeli yükleniyor...", "usingModel": "Kullanılan model", "startGeneration": "Oluşturmayı Başlat", "batchGenCompleted": "Toplu oluşturma tamamlandı! {{success}}/{{total}} dosya için başarıyla GA çiftleri oluşturuldu.", "generationError": "Oluşturma sırasında hata oluştu: {{error}}", "fetchProjectInfoFailed": "Proje bilgisi getirilemedi: {{status}}", "fetchModelConfigFailed": "Model yapılandırması getirilemedi: {{status}}", "fetchProjectModelError": "Proje model yapılandırması getirilirken hata", "batchGenerationFailed": "Toplu GA çifti oluşturma başarısız", "batchGenerationSuccess": "{{count}} dosya için başarıyla GA çiftleri oluşturuldu", "selectAllFiles": "Tümünü Seç", "deselectAllFiles": "Tümünü Kaldır" }, "batchEdit": { "title": "Toplu Metin Bloğu Düzenleme", "batchEdit": "Toplu Düzenle", "batchEditTooltip": "Seçilen metin bloklarını toplu düzenle", "position": "Ekleme Konumu", "atBeginning": "Başa Ekle", "atEnd": "Sona Ekle", "contentToAdd": "Eklenecek İçerik", "contentPlaceholder": "Metin bloklarına eklenecek içeriği girin...", "contentRequired": "Lütfen eklenecek içeriği girin", "contentHelp": "Bu içerik tüm seçilen metin bloklarına eklenecektir", "preview": "Önizleme", "allChunksSelected": "Tüm {{count}} metin bloğu seçildi", "selectedChunks": "{{selected}} / {{total}} metin bloğu seçildi", "processing": "İşleniyor...", "applyToChunks": "{{count}} bloğa uygula", "editSuccess": "{{count}} metin bloğu başarıyla düzenlendi", "editFailed": "Toplu düzenleme başarısız", "previewNote": "Yukarıdaki, seçilen ilk metin bloğunun önizlemesidir. Tüm seçilen metin blokları aynı değişikliği alacaktır" }, "errors": { "projectIdRequired": "Proje ID'si boş olamaz", "getDatasetsFailed": "Veri setleri alınamadı", "getTagStatsFailed": "Etiket istatistikleri alınamadı", "deleteFileFailed": "Dosya silinirken hata", "recordNotFound": "Mevcut kayıt mevcut değil", "mineruTokenNotFound": "Token yapılandırması bulunamadı, lütfen görev ayarlarında MinerU token yapılandırıldığını kontrol edin", "mineruLocalUrlNotFound": "MinerU yerel URL yapılandırması bulunamadı, lütfen görev ayarlarında MinerU yerel URL yapılandırıldığını kontrol edin" }, "sampleData": { "questionContent": "Soru içeriği", "answerContent": "Cevap içeriği", "cotContent": "Düşünce zinciri içeriği", "domainLabel": "Alan etiketi", "textChunk": "Metin bloğu" }, "exportDialog": { "balancedExport": "Dengeli Dışa Aktarma", "balancedExportTitle": "Dengeli Dışa Aktarma Ayarları", "balancedExportDescription": "Dengeli veri seti dışa aktarımı elde etmek için alan etiketlerine göre her kategori için veri miktarını yapılandırın", "quickSettings": "Hızlı Ayarlar", "setAllTo50": "Tümünü 50'ye ayarla", "setAllTo100": "Tümünü 100'e ayarla", "setAllTo200": "Tümünü 200'e ayarla", "customAmount": "Özel miktar", "tagName": "Etiket Adı", "availableCount": "Mevcut Sayı", "exportCount": "Dışa Aktarma Sayısı", "settings": "Ayarlar", "totalExportCount": "Toplam dışa aktarma sayısı", "tagCount": "Etiket sayısı", "export": "Dışa Aktar" }, "imageDatasets": { "title": "Görsel S&C Veri Seti", "subtitle": "Görsel S&C veri setlerinizi yönetin ve optimize edin", "description": "Görsel S&C veri setlerinizi yönetin ve optimize edin.", "searchPlaceholder": "Soruları veya cevapları ara...", "noAnswer": "Cevap yok", "labels": "Etiketler", "typeLabel": "Etiket", "typeCustom": "Özel", "typeText": "Metin", "unscored": "Puanlanmamış", "confirmed": "Onaylandı", "unconfirmed": "Onaylanmadı", "view": "Detayları Görüntüle", "evaluate": "Kalite Değerlendirmesi", "delete": "Sil", "deleteConfirm": "Bu veri setini silmek istediğinizden emin misiniz?", "imageName": "Görsel Adı", "status": "Durum", "scoreRange": "Puan Aralığı", "noData": "Görsel veri seti yok", "noDataTip": "Lütfen önce Görsel Yönetimi'nde S&C veri setleri oluşturun", "fetchFailed": "Veri setleri getirilemedi", "fetchDetailFailed": "Detaylar getirilemedi", "deleteSuccess": "Başarıyla silindi", "deleteFailed": "Silme başarısız", "updateSuccess": "Başarıyla güncellendi", "updateFailed": "Güncelleme başarısız", "regenerateSuccess": "AI tanıma başarılı", "regenerateFailed": "AI tanıma başarısız", "notFound": "Veri seti bulunamadı", "detail": "Detay", "image": "Görsel", "question": "Soru", "answer": "Cevap", "selectLabels": "Etiketleri Seç", "noLabels": "Etiket seçilmedi", "jsonPlaceholder": "JSON format verisi girin...", "metadata": "Meta Veri", "score": "Puan", "tags": "Etiketler", "addTag": "Etiket ekle...", "note": "Not", "notePlaceholder": "Not ekle...", "modelInfo": "Model Bilgisi", "createdAt": "Oluşturulma Tarihi", "updatedAt": "Güncellenme Tarihi", "exportTitle": "Görsel Veri Setini Dışa Aktar", "exportFormat": "Dışa Aktarma Formatı", "rawFormat": "Ham Format", "customFormat": "Özel Format", "exportImagesOption": "Görsel Dosyalarını Dışa Aktar", "exportImagesDesc": "Tüm görselleri indirmek için ZIP dosyasına paketle", "includeImagePath": "Veri Setine Görsel Yolunu Dahil Et", "includeImagePathDesc": "Soru veya cevapta görsel yolu ekle (format: /images/görsel_adı)", "systemPrompt": "Sistem İstemi (İsteğe Bağlı)", "systemPromptPlaceholder": "Sistem istemi girin...", "confirmedOnly": "Sadece Onaylananları Dışa Aktar", "exportTip": "Etiket formatı cevapları otomatik olarak metne (virgülle ayrılmış) ayrıştırılacaktır", "exportSuccess": "Veri seti başarıyla dışa aktarıldı", "exportFailed": "Dışa aktarma başarısız", "noDataToExport": "Dışa aktarılacak veri yok", "exportImagesSuccess": "Görsel ZIP paketi başarıyla dışa aktarıldı", "exportImagesFailed": "Görseller dışa aktarılamadı" }, "images": { "resolution": "Çözünürlük", "uploadTime": "Yükleme Zamanı", "fileName": "Dosya Adı", "title": "Görsel Yönetimi", "importImages": "Görselleri İçe Aktar", "searchPlaceholder": "Görsel adı ara...", "hasQuestions": "Soru Durumu", "hasDatasets": "Veri Seti Durumu", "withQuestions": "Sorulu", "withoutQuestions": "Sorusuz", "withDatasets": "Veri Setli", "withoutDatasets": "Veri Setsiz", "noImages": "Görsel yok", "questions": "Sorular", "datasets": "Veri Setleri", "generateQuestions": "Soru Oluştur", "generateDataset": "Veri Seti Oluştur", "deleteConfirm": "Bu görseli silmek istediğinizden emin misiniz?", "deleteSuccess": "Başarıyla silindi", "deleteFailed": "Silme başarısız", "importTip": "Görsel içeren bir veya daha fazla dizin seçin. Tüm görseller projeye içe aktarılacaktır (yinelenen adlar üzerine yazılacaktır)", "selectDirectory": "Dizin Seç", "directoryPath": "Dizin Yolu", "enterDirectoryPath": "örn., /Users/kullanıcıadı/Resimler", "selectedDirectories": "Seçilen Dizinler", "selectAtLeastOne": "Lütfen en az bir dizin seçin", "importSuccess": "{{count}} görsel başarıyla içe aktarıldı", "importFailed": "İçe aktarma başarısız", "startImport": "İçe Aktarmayı Başlat", "addDirectory": "Dizin Ekle", "importFromDirectory": "Dizinden İçe Aktar", "importFromPdf": "PDF'den İçe Aktar", "pdfImportTip": "PDF dosyası seçin, sistem otomatik olarak görsellere dönüştürüp içe aktaracaktır", "clickToSelectPdf": "PDF dosyası seçmek için tıklayın", "supportedFormat": "Desteklenen format: PDF", "fileSize": "Dosya boyutu", "selectedFile": "Seçilen dosya", "invalidPdfFile": "Lütfen geçerli bir PDF dosyası seçin", "selectPdfFile": "Lütfen bir PDF dosyası seçin", "pdfImportSuccess": "\"{{name}}\" PDF'sinden {{count}} görsel başarıyla içe aktarıldı", "pdfImportFailed": "PDF içe aktarma başarısız", "convertAndImport": "Dönüştür ve İçe Aktar", "electronRequired": "Bu özellik masaüstü uygulamasını gerektirir", "selectDirectoryFailed": "Dizin seçilemedi", "imageName": "Görsel Adı", "questionCount": "Soru Sayısı", "questionCountHelp": "1-10 soru oluştur", "currentModel": "Mevcut Model", "selectModelFirst": "Lütfen önce bir model seçin", "visionModelRequired": "Lütfen görsel yetenekli bir model seçin (örn., GPT-4 Vision, Claude, vb.)", "countRange": "Soru sayısı 1-10 arasında olmalıdır", "questionsGenerated": "{{count}} soru başarıyla oluşturuldu", "generateFailed": "Oluşturma başarısız", "question": "Soru", "questionPlaceholder": "Sorunuzu girin...", "questionRequired": "Lütfen bir soru girin", "datasetGenerated": "Veri seti başarıyla oluşturuldu", "autoGenerateQuestions": "Otomatik Soru Oluştur", "autoGenerateConfirm": "Sistem, sorusu olmayan tüm görseller için otomatik olarak sorular oluşturacaktır. Bu işlem bir arka plan görevi oluşturacaktır, ilerlemeyi Görev Yönetimi'nde görüntüleyebilirsiniz.", "taskCreated": "Görev başarıyla oluşturuldu, arka planda işleniyor", "taskCreateFailed": "Görev oluşturulamadı", "manualAnnotation": "Manuel Etiketleme", "annotationTitle": "Görsel Etiketleme", "imageInfo": "Görsel Bilgisi", "annotatedCount": "Etiketlendi", "selectQuestion": "Soru Seç veya Oluştur", "selectQuestionPlaceholder": "Soru şablonu seçin...", "universalQuestions": "Evrensel Sorular", "independentQuestions": "Bağımsız Sorular", "answerTypeText": "Metin", "answerTypeLabel": "Etiket", "answerTypeCustomFormat": "Özel Format", "usedTimes": "{{count}} kez kullanıldı", "answer": "Cevap", "answerPlaceholder": "Cevap girin...", "selectLabels": "Etiketleri Seç", "availableLabels": "Mevcut Etiketler", "noLabelsAvailable": "Mevcut etiket yok", "addNewLabel": "Yeni etiket ekle...", "selectedLabels": "Seçildi", "customFormatAnswer": "Özel Format Cevabı", "formatRequirement": "Format Gereksinimi", "customFormatPlaceholder": "Gerekli formatta JSON girin...", "note": "Not", "notePlaceholder": "Not (isteğe bağlı)", "saveAndContinue": "Kaydet ve Devam Et", "noImageSelected": "Görsel seçilmedi", "noTemplateSelected": "Lütfen bir soru seçin", "answerRequired": "Lütfen bir cevap girin", "invalidJsonFormat": "Geçersiz JSON formatı", "annotationSuccess": "Etiketleme başarıyla kaydedildi", "annotationFailed": "Etiketleme kaydedilemedi", "allQuestionsAnnotated": "Bu görsel için tüm sorular etiketlendi", "allImagesAnnotated": "Tüm görseller için tüm sorular etiketlendi", "noQuestionsAssociated": "Bu görsel ile ilişkilendirilmiş soru yok", "loadImageDetailFailed": "Görsel detayları yüklenemedi", "answeredQuestions": "Etiketlenmiş Sorular", "useTemplate": "Şablon Kullan", "formatJson": "Formatla", "jsonFormatHelp": "Lütfen geçerli JSON format verisi girin", "imageLoadError": "Görsel yüklenemedi", "annotateImage": "Görseli Etiketle", "createQuestion": "Soru Oluştur", "createTemplate": "Soru Şablonu Oluştur", "aiGenerate": "AI Tanı", "aiGenerateSuccess": "AI oluşturma başarılı", "aiGenerateFailed": "AI oluşturma başarısız", "missingParameters": "Gerekli parametreler eksik", "selectNewQuestion": "Yeni Soru Seç", "fetchTemplatesFailed": "Soru şablonları getirilemedi", "createTemplateSuccess": "Soru şablonu başarıyla oluşturuldu", "createTemplateFailed": "Soru şablonu oluşturulamadı", "updateTemplateSuccess": "Soru şablonu başarıyla güncellendi", "updateTemplateFailed": "Soru şablonu güncellenemedi", "deleteTemplateSuccess": "Soru şablonu başarıyla silindi", "deleteTemplateFailed": "Soru şablonu silinemedi", "template": { "management": "Soru Şablonlarını Yönet", "create": "Şablon Oluştur", "edit": "Şablonu Düzenle", "question": "Soru İçeriği", "description": "Açıklama", "noTemplates": "Henüz soru şablonu yok, eklemek için oluştur butonuna tıklayın", "deleteConfirm": "Bu soru şablonunu silmek istediğinizden emin misiniz?", "used": "Kullanıldı", "addLabel": "Etiket Ekle", "customFormat": "Özel Format", "customFormatHelp": "JSON format çıktı kısıtlaması girin", "customFormatInfo": "Bu format, çıktı formatını kısıtlamak için LLM'e istem olarak sağlanacaktır", "type": { "label": "Soru Türü", "universal": "Evrensel Soru", "independent": "Bağımsız Soru" }, "answerType": { "label": "Cevap Türü", "text": "Metin", "tags": "Etiketler", "customFormat": "Özel Format" }, "errors": { "questionRequired": "Lütfen soru içeriğini girin", "labelsRequired": "Etiket türü sorular en az bir etiket gerektirir", "customFormatRequired": "Lütfen özel format girin", "invalidJson": "Geçersiz JSON formatı" } } } } ================================================ FILE: locales/zh-CN/translation.json ================================================ { "language": { "switchToEnglish": "切换到英文", "switchToChinese": "切换到中文", "switcherTitle": "切换语言 / Change Language / Dil Değiştir", "english": "English", "chineseSimplified": "简体中文", "turkish": "Türkçe", "en": "EN", "zh": "中" }, "theme": { "switchToLight": "切换到亮色模式", "switchToDark": "切换到暗色模式" }, "settings": { "promptConfig": "提示词配置", "promptsDescription": "配置项目中使用的各类自定义提示词,可用于人工干预数据集的生成效果。", "globalPrompt": "全局提示词", "questionPrompt": "生成问题提示词", "answerPrompt": "生成答案提示词", "labelPrompt": "问题打标提示词", "domainTreePrompt": "构建领域树提示词", "globalPromptPlaceholder": "请输入全局提示词(慎用,可能影响整体生成效果)", "questionPromptPlaceholder": "请输入自定义生成问题的提示词", "answerPromptPlaceholder": "请输入自定义生成答案的提示词", "labelPromptPlaceholder": "请输入自定义问题打标的提示词(暂不支持配置)", "domainTreePromptPlaceholder": "请输入自定义构建领域树的提示词", "cleanPrompt": "数据清洗提示词", "cleanPromptPlaceholder": "请输入自定义数据清洗的提示词", "loadPromptsFailed": "加载提示词配置失败", "savePromptsSuccess": "保存提示词配置成功", "savePromptsFailed": "保存提示词配置失败", "title": "项目设置", "basicInfo": "基本信息", "modelConfig": "模型配置", "taskConfig": "任务配置", "tabsAriaLabel": "设置选项卡", "idNotEditable": "项目 ID 不可编辑", "saveBasicInfo": "保存基本信息", "saveSuccess": "保存成功", "saveFailed": "保存失败", "deleteSuccess": "删除成功", "deleteFailed": "删除失败", "fetchTasksFailed": "获取任务配置失败", "saveTasksFailed": "保存任务配置失败", "textSplitSettings": "文本分块设置", "minLength": "最小长度", "maxLength": "最大分割长度", "textSplitDescription": "调整文本分割的长度范围,影响分割结果的粒度", "splitType": "分块策略", "splitTypeMarkdown": "文档结构分块(Markdown)", "splitTypeMarkdownDesc": "根据文档中的标题自动分割文本,保持语义完整性,适合结构化清晰的 Markdown 文档", "splitTypeRecursive": "文本结构分块(自定义分隔符)", "splitTypeRecursiveDesc": "递归地尝试多级分隔符(可配置),先用优先级高的分隔符,再用次级分隔符,适合复杂文档", "splitTypeText": "固定长度分块(字符)", "splitTypeTextDesc": "按指定分隔符(可配置)切分文本,然后按指定长度组合,适合普通文本文件", "splitTypeToken": "固定长度分块(Token)", "splitTypeTokenDesc": "基于 Token 数量(而非字符数)分块", "splitTypeCode": "程序代码智能分块", "splitTypeCodeDesc": "根据不同编程语言的语法结构进行智能分块,避免在语法不完整处分割", "splitTypeCustom": "自定义符号分块", "splitTypeCustomDesc": "根据自定义符号进行文档分割,分隔符将被舍弃,分割的文本块不受块大小影响", "codeLanguage": "代码语言", "codeLanguageHelper": "选择要分块的代码语言,会根据语言特性进行智能分块", "chunkSize": "块大小", "chunkOverlap": "块重叠长度", "separator": "分隔符", "separatorHelper": "用于分割文本的分隔符,如 \n\n 表示空行", "customSeparator": "自定义分隔符", "customSeparatorHelper": "用于分割文本的自定义分符,如 --- 或 ===", "separators": "分隔符列表", "separatorsInput": "分隔符(逗号分隔)", "separatorsHelper": "用逗号分隔的分隔符列表,按优先级排序", "questionGenSettings": "问题生成设置", "questionGenLength": "{{length}} 个字符生成一个问题", "questionMaskRemovingProbability": "将 {{probability}}% 问题结尾的问号去除", "questionGenDescription": "设置生成问题的最大长度", "concurrencyLimit": "并发限制数量", "concurrencyLimitHelper": "限制同时生成问题、生成数据集的任务数量", "saveTaskConfig": "保存任务配置", "pdfSettings": "PDF文件转换配置", "minerUToken": "PDF 转换(MinerU API)Token 配置", "minerUHelper": "MinerU Token 只有14天有效期,请及时更换Token", "minerULocalUrl": "PDF 转换(MinerU Local)URL 配置", "vision": "自定义视觉模型选择", "visionConcurrencyLimit": "视觉模型并发限制", "huggingfaceSettings": "Hugging Face 设置", "huggingfaceToken": "Hugging Face Token", "multiTurnSettings": "多轮对话数据集设置", "multiTurnSystemPrompt": "系统提示词", "multiTurnSystemPromptHelper": "设定AI助手的身份和行为规范", "multiTurnScenario": "对话场景", "multiTurnScenarioHelper": "描述对话的具体场景和目标", "multiTurnRounds": "对话轮数:{{rounds}} 轮", "multiTurnRoleA": "角色A设定(用户)", "multiTurnRoleAHelper": "定义用户角色的身份和特征", "multiTurnRoleB": "角色B设定(助手)", "multiTurnRoleBHelper": "定义助手角色的身份和特征", "multiTurnDescription": "多轮对话配置用于生成连贯的多轮对话数据集,支持自定义角色和场景", "evalQuestionSettings": "测试集生成设置", "evalQuestionSettingsDescription": "配置生成测试集时各题型的比例,比例为0表示不生成该类型题目", "evalTrueFalseRatio": "判断题比例", "evalSingleChoiceRatio": "单选题比例", "evalMultipleChoiceRatio": "多选题比例", "evalShortAnswerRatio": "固定短答案比例", "evalOpenEndedRatio": "开放式回答比例", "evalQuestionRatioHelper": "系统会根据设置的比例自动分配各题型的生成数量,所有比例之和不需要等于特定值", "prompts": { "selectPromptFirst": "请在左侧选择一个提示词", "customized": "已自定义", "editPrompt": "编辑提示词", "restoreDefault": "恢复默认", "promptType": "提示词类型", "keyName": "键名", "contentPlaceholder": "请输入自定义提示词内容...", "restoreDefaultContent": "恢复默认内容", "noPromptsAvailable": "没有可用的提示词", "restoreSuccess": "已恢复为默认提示词", "restoreFailed": "恢复默认提示词失败", "deleteError": "删除提示词出错:", "saveSuccess": "提示词保存成功", "saveFailed": "提示词保存失败", "saveError": "保存提示词出错:", "createCustomPrompt": "创建自定义提示词", "fetchContentError": "获取最新提示词内容失败:" } }, "questions": { "autoGenerateDataset": "自动生成数据集", "autoGenerateDatasetTip": "创建后台批量处理任务:自动查询待生成答案的问题并生成数据集", "generateSingleTurnDataset": "生成单轮对话数据集", "generateSingleTurnDatasetDesc": "基于问题生成问答数据集", "generateMultiTurnDataset": "生成多轮对话数据集", "generateImageDataset": "生成图像问答数据集", "generateMultiTurnDatasetDesc": "基于问题生成多轮对话数据集", "multiTurnNotConfigured": "请先在项目设置中配置多轮对话相关参数", "filterAll": "全部问题", "filterAnswered": "已生成答案", "filterUnanswered": "未生成答案", "filterChunkNamePlaceholder": "按文本块名称筛选...", "sourceTypeAll": "全部数据源", "sourceTypeText": "文本数据源", "sourceTypeImage": "图片数据源", "title": "问题", "confirmDeleteTitle": "确认删除问题", "confirmDeleteContent": "您确定要删除问题\"{{question}}\"吗?此操作不可恢复。", "deleting": "正在删除问题...", "batchDeleteTitle": "确认批量删除问题", "batchDeleting": "正在删除 {{count}} 个问题...", "deleteSuccess": "问题删除成功", "deleteFailed": "删除问题失败", "batchDeleteSuccess": "成功删除 {{count}} 个问题", "batchDeletePartial": "删除完成,成功: {{success}}, 失败: {{failed}}", "batchDeleteFailed": "批量删除问题失败", "noQuestionsSelected": "请先选择问题", "batchGenerateStart": "开始生成 {{count}} 个问题的数据集", "invalidQuestionKey": "无效的问题键", "listView": "问题列表", "treeView": "领域树视图", "selectAll": "全选", "selectedCount": "已选择 {{count}} 个问题", "totalCount": "共 {{count}} 个问题", "searchPlaceholder": "搜索问题或标签...", "searchMatch": "匹配", "searchNotMatch": "不匹配", "deleteSelected": "删除所选", "batchGenerate": "批量构造数据集", "generating": "正在生成数据集", "generatingProgress": "已完成: {{completed}}/{{total}}", "generatedCount": "已生成 {{count}} 个数据集", "pleaseWait": "请稍候...", "selectAllLimitReached": "已选中 {{count}} 条问题(已达最大限制)", "selectAllFailed": "全选操作失败,请稍后重试", "createSuccess": "问题创建成功", "updateSuccess": "问题更新成功", "operationSuccess": "操作成功", "operationFailed": "操作失败", "editQuestion": "编辑问题", "questionContent": "问题内容", "sourceType": "数据源类型", "sourceType.text": "文本", "sourceType.image": "图片", "selectChunk": "选择文本块", "searchChunk": "搜索文本块...", "selectImage": "选择图片", "searchImage": "搜索图片...", "selectTag": "选择标签", "searchTag": "搜索标签...", "createQuestion": "创建问题", "createNormalQuestion": "创建普通问题", "createQuestionTemplate": "创建问题模板", "questionPlaceholder": "请输入问题内容", "noChunkSelected": "请先选择文本块", "fetchTemplatesFailed": "获取问题模板失败", "createTemplateSuccess": "问题模板创建成功", "createTemplateFailed": "创建问题模板失败", "updateTemplateSuccess": "问题模板更新成功", "updateTemplateFailed": "更新问题模板失败", "deleteTemplateSuccess": "问题模板删除成功", "deleteTemplateFailed": "删除问题模板失败", "exportQuestions": "导出问题集", "exportScope": "导出范围", "exportAll": "导出全部({{count}} 个问题)", "exportSelected": "导出已选({{count}} 个问题)", "exportFormat": "导出格式", "txtFormat": "纯文本(仅问题内容)", "exportSuccess": "问题集导出成功", "exportFailed": "问题集导出失败", "template": { "management": "问题模板", "create": "创建问题模板", "edit": "编辑问题模板", "question": "问题内容", "description": "提示词", "descriptionHelp": "用于在后续 AI 生成这个问题模版相关的答案时,加入到整体提示词中,以用于干预最终答案生成的结果", "noTemplates": "暂无问题模板,点击创建按钮添加", "deleteConfirm": "确定要删除这个问题模板吗?", "used": "已使用", "addLabel": "添加标签", "customFormat": "自定义格式", "customFormatHelp": "输入 JSON 格式的输出约束", "customFormatInfo": "此格式将作为提示词提供给大模型,用于约束输出格式", "sourceTypeInfo": "数据源类型", "sourceType": { "label": "数据源类型", "image": "图像(用于对图像生成QA)", "text": "文本(用于对文本块生成QA)" }, "answerType": { "label": "答案输出格式", "text": "普通文本", "tags": "标签数组", "customFormat": "自定义格式" }, "errors": { "questionRequired": "请输入问题内容", "labelsRequired": "标签类型问题至少需要一个标签", "customFormatRequired": "请输入自定义格式", "invalidJson": "JSON 格式不正确" }, "autoGenerate": "创建模板后自动生成问题", "autoGenerateHelpText": "将为项目中的所有文本块自动创建基于此模板的问题", "autoGenerateHelpImage": "将为项目中的所有图片自动创建基于此模板的问题", "confirmAutoGenerate": "确认自动生成问题", "confirmAutoGenerateTextMessage": "您选择了自动生成问题。系统将为项目中的所有文本块创建基于此模板的问题。", "confirmAutoGenerateImageMessage": "您选择了自动生成问题。系统将为项目中的所有图片创建基于此模板的问题。", "autoGenerateWarning": "此操作可能会创建大量问题,请确认后继续。", "autoGenerateSuccess": "成功为 {{count}} 个数据源创建了问题", "autoGeneratePartialFail": "成功创建 {{success}} 个问题,{{fail}} 个失败", "autoGenerateFailed": "自动生成问题失败" }, "noTagSelected": "请选择标签", "deleteConfirm": "确认要删除这个问题吗?", "generateMultiTurn": "生成多轮对话", "multiTurnGenerated": "多轮对话数据集生成成功!" }, "common": { "dataSource": "数据源", "menu": "菜单", "openMenu": "打开导航菜单", "all": "全部", "jumpTo": "跳转至", "unknownError": "未知错误", "create": "创建", "confirm": "确认", "edit": "编辑", "delete": "删除", "save": "保存", "cancel": "取消", "complete": "完成", "close": "关闭", "add": "添加", "remove": "删除", "loading": "加载中...", "yes": "是", "no": "否", "confirmDelete": "确认删除吗?此操作不可撤销!", "saving": "保存中...", "deleting": "删除中...", "actions": "操作", "confirmDeleteDataSet": "确认删除数据集吗?操作不可撤销!", "noData": "无", "failed": "失败", "success": "成功", "backToList": "返回列表", "label": "标签", "confirmDeleteDescription": "确认删除吗?此操作不可恢复。", "more": "更多", "import": "导入", "export": "导出", "fetchError": "获取数据出错", "confirmDeleteQuestion": "确认删除此问题吗?此操作不可恢复。", "deleteSuccess": "删除成功", "visitGitHub": "访问GitHub仓库", "syncOldData": "同步文件数据", "copy": "复制", "copied": "已复制", "enabled": "已启用", "disabled": "已禁用", "generating": "正在生成...", "processing": "处理中...", "items": "项", "detailInfo": "详细信息", "reset": "重置", "apply": "应用", "mainNavigation": "主导航", "goHome": "回到首页", "goToHomePage": "回到首页", "mobileNavigation": "移动端导航菜单", "navigation": "导航", "closeMenu": "关闭菜单", "documentation": "文档", "viewOnGitHub": "在 GitHub 上查看", "back": "返回", "refresh": "刷新", "expand": "展开全部", "collapse": "收起内容" }, "home": { "title": "Easy Dataset", "subtitle": "一个强大的大型语言模型微调数据集创建工具", "createProject": "创建项目", "searchDataset": "搜索公开数据集" }, "projects": { "reuseConfig": "复用模型配置", "noReuse": "不复用配置", "selectProject": "选择项目", "fetchFailed": "获取项目列表失败", "fetchError": "获取项目列表出错", "loading": "正在加载您的项目...", "createFailed": "创建项目失败", "createError": "创建项目出错", "createNew": "创建新项目", "saveFailed": "保存项目失败", "id": "项目ID", "name": "项目名称", "description": "项目描述", "questions": "问题", "datasets": "数据集", "evalDatasets": "评估集", "tokens": "Tokens", "lastUpdated": "最后更新", "viewDetails": "查看详情", "createFirst": "创建第一个项目", "noProjects": "暂无项目", "notExist": "项目不存在", "createProject": "创建项目", "deleteConfirm": "确认删除项目吗?此操作不可恢复。", "deleteSuccess": "项目删除成功", "deleteFailed": "删除项目失败", "backToHome": "返回首页", "deleteConfirmTitle": "确认删除项目", "title": "项目管理", "openDirectory": "打开项目目录" }, "textSplit": { "dragToUpload": "拖拽文件到此处上传", "fileList": "文件列表", "autoGenerateQuestions": "自动提取问题", "autoGenerateQuestionsTip": "创建后台批量处理任务:自动查询待生成问题的文本块并提取问题", "exportChunks": "导出文本块", "allChunks": "全部文本块", "generatedQuestions2": "已生成问题", "ungeneratedQuestions": "未生成问题", "contentKeyword": "文本块内容", "contentKeywordPlaceholder": "输入关键词搜索文本块内容", "characterRange": "字数范围", "questionStatus": "问题状态", "noFilesUploaded": "暂未上传文件", "unknownFile": "未知文件", "fetchFilesFailed": "获取文件列表出错", "editTag": "编辑标签", "deleteTag": "删除标签", "addTag": "添加标签", "selectedCount": "已选择 {{count}} 个文本块", "totalCount": "共 {{count}} 个文本块", "batchGenerateQuestions": "批量生成问题", "batchDeleteChunks": "批量删除", "batchDeleteChunksConfirmTitle": "确认批量删除", "batchDeleteChunksConfirmMessage": "您确定要删除选中的 {{count}} 个文本块吗?此操作不可恢复。", "uploadedDocuments": "已上传 {{count}}个文档", "title": "文件处理", "uploadNewDocument": "上传新文件", "selectFile": "选择文件(支持多个)", "markdownOnly": "目前仅支持上传 Markdown (.md) 格式文件(建议上传同一领域的文件)", "supportedFormats": "支持的格式: .pdf .md, .txt, .docx(建议上传同一领域的文件)", "uploadAndProcess": "上传并处理文件", "selectedFiles": "已选择文件({{count}})", "oneFileMessage": "一个项目限制处理一个文件,如需上传新文件请先删除现有文件", "mutilFileMessage": "上传新文件后会重新构建领域树", "noChunks": "暂无文本块,请先上传并处理文件", "chunkDetails": "文本块详情: {{chunkId}}", "fetchChunksFailed": "获取文本块失败", "fetchChunksError": "获取文本块出错", "fileResultReceived": "获取到文件结果", "fileUploadSuccess": "文件上传成功", "splitTextFailed": "文本分割失败", "splitTextError": "文本分割出错", "deleteChunkFailed": "删除文本块失败", "deleteChunkError": "删除文本块出错", "selectModelFirst": "请先选择一个模型,可以在顶部导航栏选择", "modelNotAvailable": "选择的模型不可用,请重新选择", "generateQuestionsFailed": "为文本块 {{chunkId}} 生成问题失败", "questionsGenerated": "已生成 {{total}} 个问题", "customSplitMode": "自定义分块模式", "customSplitInstructions": "选择文本内容以添加分块点。系统会在选中位置添加分割标记。", "splitPointsList": "已添加的分块点", "saveSplitPoints": "保存分块点", "confirmCustomSplitTitle": "确认替换原有分块", "confirmCustomSplitMessage": "注意:自定义分块将替换该文件之前自动分块的结果。确定要继续保存吗?", "customSplitSuccess": "自定义分块保存成功", "customSplitFailed": "自定义分块保存失败", "missingRequiredData": "缺少必要的数据", "chunksPreview": "分块字数预览", "chunk": "文本块", "characters": "字", "questionsGeneratedSuccess": "成功为文本块生成了 {{total}} 个问题", "generateQuestionsForChunkFailed": "为文本块 {{chunkId}} 生成问题失败", "generateQuestionsForChunkError": "为文本块 {{chunkId}} 生成问题出错", "generateQuestionsError": "生成问题出错", "partialSuccess": "部分文本块生成问题成功 ({{successCount}}/{{total}}),{{errorCount}} 个文本块失败", "allSuccess": "成功为 {{successCount}} 个文本块生成了 {{totalQuestions}} 个问题", "fileDeleted": "文件 {{fileName}} 已删除,刷新文本块列表", "tabs": { "smartSplit": "智能分割", "domainAnalysis": "领域分析" }, "loading": "加载中...", "fetchingDocuments": "正在获取文件数据", "processing": "处理中...", "progressStatus": "已选择 {{total}} 个文本块,已处理完成 {{completed}} 个", "processingPleaseWait": "正在努力处理中,请稍候!", "oneFileLimit": "已有上传文件,不允许选择新文件", "unsupportedFormat": "不支持的文件格式: {{files}}", "modelInfoParseError": "解析模型信息失败", "uploadFailed": "上传失败,请刷新页面后重试!", "uploadSuccess": "成功上传 {{count}} 个文件", "deleteFailed": "删除文件失败", "deleteSuccess": "文件 {{fileName}} 已成功删除", "generatedQuestions": "已生成 {{count}} 个问题", "generatedEvalQuestions": "已生成 {{count}} 道测试题", "generateQuestions": "生成问题", "generateEvalQuestions": "生成测试集", "evalQuestionsGeneratedSuccess": "成功生成 {{total}} 道测评题目", "generateEvalQuestionsFailed": "生成测评题目失败", "dataCleaning": "数据清洗", "batchDataCleaning": "批量数据清洗", "autoDataCleaning": "自动数据清洗", "autoDataCleaningTip": "创建后台批量处理任务:自动对所有文本块进行数据清洗", "autoEvalGeneration": "自动生成评估集", "autoEvalGenerationTip": "创建后台批量处理任务:自动为所有未生成评估题目的文本块生成评估数据集", "autoTasks": "自动任务", "dataCleaningSuccess": "数据清洗完成,原长度: {{originalLength}},清洗后长度: {{cleanedLength}}", "dataCleaningFailed": "为文本块 {{chunkId}} 数据清洗失败", "dataCleaningForChunkSuccess": "文本块 {{chunkId}} 数据清洗完成", "dataCleaningForChunkFailed": "为文本块 {{chunkId}} 数据清洗失败", "dataCleaningForChunkError": "为文本块 {{chunkId}} 数据清洗出错", "dataCleaningPartialSuccess": "部分文本块数据清洗成功 ({{successCount}}/{{total}}),{{errorCount}} 个文本块失败", "dataCleaningAllSuccess": "成功为 {{successCount}} 个文本块完成数据清洗", "charsCount": "字符", "pdfProcess": "检测到PDF文件,请选择 PDF 文件处理方式!", "pdfProcessStatus": "共 {{total}} 个文件,{{completed}} 个已处理完成", "pdfPageProcessStatus": "正在处理 {{fileName}} 共{{total}}页,{{completed}}页已完成转换", "pdfProcessing": "正在转换文件...", "pdfProcessingFailed": "文件处理失败!", "selectPdfProcessingStrategy": "请选择PDF文件处理方式:", "pdfProcessingStrategyDefault": "默认", "pdfProcessingStrategyDefaultHelper": "使用内置PDF解析策略", "pdfProcessingStrategyMinerUHelper": "使用MinerU API解析,请先配置MinerU API Token", "pdfProcessingStrategyVision": "自定义视觉模型", "pdfProcessingStrategyVisionHelper": "使用自定义视觉模型解析", "pdfProcessingToast": "上传文件成功,系统将创建后台任务解析文件!", "pdfProcessingLoading": "正在执行文件处理任务,请等待任务完成后再上传新文件...", "pdfProcessingWaring": "正在执行文件处理任务,建议当任务完成后再进行其他操作,否则可能会影响数据生成质量!", "basicPdfParsing": "基础 PDF 解析", "basicPdfParsingDesc": "可识别简单的 PDF 文件,包括关键目录结构,速度更快", "mineruApiDesc": "可识别复杂 PDF 文件,包括公式、图表(需要配置 MinerU API Key)", "mineruLocalDesc": "可识别复杂 PDF 文件,包括公式、图表(需要配置 MinerU Local URL)", "mineruApiDescDisabled": "请先到【项目配置 - 任务配置】设置 MinerU Token", "mineruLocalDisabled": "请先到【项目配置 - 任务配置】设置 MinerU Local URL", "mineruWebPlatform": "MinerU 在线平台解析", "mineruWebPlatformDesc": "可识别复杂 PDF 文件,包括公式、图表(需跳转到其他网站)", "mineruSelected": "已选择使用 MinerU 解析PDF", "mineruLocalSelected": "已选择使用 MinerU Local 解析PDF", "customVisionModel": "自定义视觉模型解析", "customVisionModelDesc": "可识别复杂 PDF 文件,包括公式、图表(需在模型配置增加视觉模型配置)", "customVisionModelSelected": "已选择使用视觉大模型 {{name}}({{provider}}) 解析PDF)", "defaultSelected": "已选择使用默认内置策略解析PDF", "download": "下载文件", "deleteFile": "删除文件", "batchDelete": "批量删除 ({{count}})", "batchDeleteTitle": "批量删除文件", "batchDeleteConfirm": "确定要删除选中的 {{count}} 个文件吗?此操作不可恢复。", "batchDeleteSuccess": "成功删除 {{count}} 个文件", "batchDeleteFailed": "批量删除失败", "searchFiles": "搜索文件名...", "searchResults": "找到 {{count}} 个文件(共 {{total}} 个)", "noSearchResults": "未找到包含 \"{{searchTerm}}\" 的文件", "noResultsOnCurrentPage": "当前页面没有搜索结果,请返回第一页查看", "noDataOnCurrentPage": "当前页面没有数据", "viewChunk": "查看文本块", "editChunk": "编辑文本块 {{chunkId}}", "editChunkSuccess": "文本块编辑成功", "editChunkFailed": "文本块编辑失败", "editChunkError": "编辑文本块时出错", "deleteFileWarning": "警告:删除文件将同时删除以下相关内容", "deleteFileWarningChunks": "所有关联的文本块", "deleteFileWarningQuestions": "所有文本块生成的问题", "deleteFileWarningDatasets": "所有问题生成的数据集", "domainTree": { "firstUploadTitle": "领域树生成", "uploadTitle": "文件上传 - 领域树处理", "deleteTitle": "文件删除 - 领域树处理", "reviseOption": "修订领域树", "reviseDesc": "针对新增或删除的文件信息,对当前的领域树进行修正,只影响变更的部分", "rebuildOption": "重建领域树", "rebuildDesc": "基于所有文件的目录信息重新生成完整的领域树", "keepOption": "保持不变", "keepDesc": "保持当前领域树结构不变,不做任何修改" } }, "domain": { "title": "领域知识树", "addRootTag": "添加一级标签", "addFirstTag": "添加第一个标签", "noTags": "暂无领域标签树数据", "docStructure": "文档目录结构", "noToc": "暂无目录结构,请先上传并处理文件", "editTag": "编辑标签", "deleteTag": "删除标签", "addChildTag": "添加子标签", "deleteTagConfirmTitle": "删除标签", "deleteTagConfirmMessage": "您确定要删除标签 \"{{tag}}\" 吗?", "deleteWarning": "此操作将删除该标签及其所有子标签、问题和数据集,且无法恢复!", "dialog": { "addTitle": "添加标签", "editTitle": "编辑标签", "addChildTitle": "添加子标签", "inputRoot": "请输入新的一级标签名称", "inputEdit": "请编辑标签名称", "inputChild": "请为\"{label}\"添加子标签", "labelName": "标签名称", "saving": "保存中...", "save": "保存", "deleteConfirm": "确定要删除标签\"{label}\"吗?", "deleteWarning": "此操作将同时删除所有子标签,且无法恢复。", "emptyLabel": "标签名称不能为空" }, "tabs": { "tree": "领域树", "structure": "目录结构" }, "errors": { "saveFailed": "保存标签失败" }, "messages": { "updateSuccess": "标签更新成功" } }, "export": { "alpacaSettings": "Alpaca 格式设置", "questionFieldType": "问题字段类型", "useInstruction": "使用 instruction 字段", "useInput": "使用 input 字段", "customInstruction": "自定义 instruction 字段内容", "instructionPlaceholder": "请输入固定的指令内容", "instructionHelperText": "当使用 input 字段时,可以在这里指定固定的 instruction 内容", "title": "导出", "format": "数据集风格", "fileFormat": "文件格式", "systemPrompt": "系统提示词", "systemPromptPlaceholder": "请输入系统提示词...", "ReasoninglanguagePlaceholder": "请输入Reasoning language : English or Chinese or others", "Reasoninglanguage": "推理语言", "onlyConfirmed": "仅导出已确认数据", "example": "格式示例", "confirmExport": "确认导出", "includeCOT": "包含思维链", "cotDescription": "包含最终答案前的推理过程", "customFormat": "自定义格式", "customFormatSettings": "自定义格式设置", "questionFieldName": "问题字段名", "answerFieldName": "答案字段名", "cotFieldName": "思维链字段名", "includeLabels": "包含标签", "includeChunk": "包含文本块", "questionOnly": "仅导出问题", "localTab": "导出到本地", "llamaFactoryTab": "在 LLaMA Factory 中使用", "huggingFaceTab": "上传至 Hugging Face", "configExists": "已存在配置文件", "configPath": "配置文件路径", "updateConfig": "更新 LLaMA Factory 配置", "noConfig": "暂无配置文件,点击下方按钮生成", "generateConfig": "生成 LLaMA Factory 配置", "huggingFaceComingSoon": "HuggingFace 导出功能即将推出", "uploadToHuggingFace": "上传至 HuggingFace", "datasetName": "数据集名称", "datasetNameHelp": "格式:用户名/数据集名称", "privateDataset": "私有数据集", "datasetSettings": "数据集设置", "exportOptions": "导出选项", "uploadSuccess": "数据集已成功上传至 HuggingFace", "viewOnHuggingFace": "在 HuggingFace查看", "noTokenWarning": "未找到 HuggingFace 令牌。请在项目设置中配置令牌。", "goToSettings": "前往设置", "tokenHelp": "您可以从HuggingFace设置页面获取令牌", "multilingualThinkingFormat": "Multilingual‑Thinking", "sampleInstruction": "人类指令(必填)", "sampleOutput": "模型回答(必填)", "sampleSystem": "系统提示词(选填)", "sampleInstruction2": "第二个指令", "sampleOutput2": "第二个回答", "sampleSystemShort": "系统提示词", "fixedInstruction": "固定的指令内容", "sampleInput": "人类问题(必填)", "sampleInput2": "第二个问题", "sampleInputOptional": "人类输入(选填)", "sampleUserMessage": "人类指令", "sampleAssistantMessage": "模型回答", "sampleAnalysis": "模型的思维链内容", "sampleFinal": "模型回答", "sampleThinking": "模型的思维链内容", "fetchLabelStatsError": "获取标签统计失败:" }, "datasets": { "loadingDataset": "正在加载数据集详情...", "datasetNotFound": "未找到数据集", "optimizeTitle": "AI 优化", "optimizeAdvice": "优化建议", "optimizePlaceholder": "请输入您对答案的改进建议,AI将根据您的建议优化答案和思维链", "generatingDataset": "正在生成数据集", "aiOptimizeAdvicePlaceholder": "请输入您对答案的改进建议,AI将根据您的建议优化答案和思维链", "aiOptimizeAdvice": "请输入您对答案的改进建议,AI将根据您的建议优化答案和思维链", "aiOptimize": "AI 智能优化", "generating": "正在生成数据集", "partialSuccess": "部分问题生成数据集成功 ({{successCount}}/{{total}}),{{failCount}} 个问题失败", "generateError": "生成数据集失败", "management": "数据集", "question": "问题", "filterAll": "全部", "filterConfirmed": "已确认", "filterUnconfirmed": "未确认", "createdAt": "创建时间", "model": "使用模型", "domainTag": "领域标签", "cot": "思维链", "answer": "回答", "chunkId": "文本块", "confirmed": "已确认", "noTag": "无标签", "noData": "暂无数据", "rowsPerPage": "每页行数", "pagination": "{{from}}-{{to}} 共 {{count}}", "confirmDeleteMessage": "确定要删除这个数据集吗?这个操作不可撤销。", "questionLabel": "问题", "fetchFailed": "获取数据集失败", "deleteFailed": "删除数据集失败", "deleteSuccess": "删除成功", "exportSuccess": "数据集导出成功", "exportFailed": "导出失败", "exportProgress": "导出进度", "exportingData": "正在导出数据集", "processedCount": "已处理 {{processed}} / {{total}} 条", "exportInProgress": "正在获取数据,请稍候...", "exportFinalizing": "正在生成文件,即将完成...", "loading": "加载数据集...", "stats": "共 {{total}} 个数据集,已确认 {{confirmed}} 个({{percentage}}%)", "selected": "共选中{{ count }}个数据集", "batchconfirmDeleteMessage": "您确定要删除选中的 {{count}} 个数据集吗?此操作不可恢复。", "batchDelete": "批量删除", "batchDeleteProgress": "已完成: {{completed}}/{{total}}", "batchDeleteCount": "已生成: {{count}}", "evaluation": "数据集标注", "rating": "评分", "ratingExcellent": "优秀", "ratingGood": "良好", "ratingAverage": "一般", "ratingPoor": "较差", "ratingVeryPoor": "很差", "ratingUnrated": "未评分", "customTags": "自定义标签", "addCustomTag": "添加自定义标签...", "note": "备注", "addNote": "添加备注...", "noNote": "暂无备注", "clickToAddNote": "点击添加备注...", "enterNote": "请输入备注...", "noteShortcuts": "Ctrl+Enter 保存,Esc 取消", "aiEvaluation": "AI 质量评估", "addToEval": "添加到评估数据集", "addToEvalSuccess": "成功添加到评估数据集", "addToEvalFailed": "添加失败", "generateEvalVariant": "生成评估集变体", "generateVariantFailed": "生成变体失败", "saveVariantSuccess": "已保存到评估数据集", "saveVariantFailed": "保存失败", "evalVariantTitle": "生成评估集变体", "evalVariantPreviewTitle": "确认生成的题目", "saveToEval": "保存到评估集", "evalVariantConfigHint": "请选择生成的题目类型和数量,AI 将基于当前问答对进行改写。", "questionType": "题目类型", "typeOpenEnded": "开放式问答", "typeSingleChoice": "单选题", "typeMultipleChoice": "多选题", "typeTrueFalse": "判断题", "typeShortAnswer": "简答题", "generateCount": "生成数量", "evalVariantPreviewHint": "您可以编辑生成的题目,确认无误后保存到评估集。", "questionIndex": "题目 {{index}}", "options": "选项 (JSON数组)", "optionsHint": "例如: [\"选项A\", \"选项B\"]", "answerArrayHint": "多选题答案请输入数组,如 [\"A\", \"C\"]", "answerBoolHint": "判断题答案请输入 ✅ 或 ❌", "generate": "生成", "updateSuccess": "更新成功", "updateFailed": "更新失败", "searchPlaceholder": "搜索数据集...", "fieldQuestion": "问题", "fieldAnswer": "回答", "fieldCOT": "思维链", "fieldLabel": "领域标签", "moreFilters": "更多", "filtersTitle": "筛选条件", "filterConfirmationStatus": "确认状态", "filterCotStatus": "思维链状态", "filterHasCot": "包含思维链", "filterNoCot": "不包含思维链", "filterScoreRange": "评分范围", "filterNoteKeyword": "备注关键字", "filterNoteKeywordPlaceholder": "请输入备注关键字...", "filterChunkName": "文本块名称", "filterChunkNamePlaceholder": "请输入文本块名称...", "filterCustomTag": "自定义标签", "resetFilters": "重置", "applyFilters": "应用筛选", "viewDetails": "查看详情", "datasetDetail": "数据集详情", "metadata": "元数据", "confirmSave": "确认保留", "unconfirm": "取消确认", "unconfirming": "取消确认中...", "uncategorized": "未分类", "questionCount": "{{count}} 个问题", "source": "来源", "generateDataset": "生成数据集", "generateNotImplemented": "生成数据集功能未实现", "generateSuccess": "成功生成数据集:{{question}}", "generateFailed": "生成数据集失败:{{error}}", "noTagsAndQuestions": "暂无标签和问题", "answerCount": "{{count}} 个答案", "answered": "已生成答案", "enableShortcuts": "翻页快捷键", "shortcutsHelp": "按 ← 向前,按 → 向后,按 y 确认,按 d 删除", "filterDistill": "是否蒸馏数据集", "filterDistillYes": "蒸馏数据集", "filterDistillNo": "非蒸馏数据集", "evaluate": "质量评估", "evaluating": "评估中...", "batchEvaluate": "自动质量评估", "selectModelFirst": "请先选择模型", "evaluateSuccess": "评估完成!评分:{{score}}/5", "evaluateFailed": "评估失败", "evaluateError": "评估失败: {{error}}", "batchEvaluateStarted": "批量评估任务已启动,将在后台进行处理", "batchEvaluateStartFailed": "启动批量评估失败", "batchEvaluateFailed": "批量评估失败: {{error}}", "scoreRange": "{{min}} - {{max}} 分", "singleTurn": "单轮问答数据集", "multiTurn": "多轮对话数据集", "imageQA": "图片问答数据集", "conversationDetail": "多轮对话详情", "conversationContent": "对话内容", "basicInfo": "基本信息", "firstQuestion": "首轮问题", "conversationScenario": "对话场景", "conversationRounds": "对话轮数", "modelUsed": "使用模型", "qualityScore": "质量评分", "notes": "备注", "createTime": "创建时间", "notSet": "未设置", "noTags": "无标签", "noNotes": "无备注", "notEvaluated": "暂未评估", "round": "第 {{round}} 轮", "system": "系统", "user": "用户", "assistant": "助手", "confirmDelete": "确认删除", "confirmDeleteConversation": "确定要删除这个多轮对话数据集吗?此操作不可恢复。", "conversationNotFound": "对话数据集不存在", "fetchDataFailed": "获取数据失败", "saveFailed": "保存失败", "saveSuccess": "保存成功", "saving": "保存中...", "inputTagsPlaceholder": "输入标签,用空格分隔", "addNotesPlaceholder": "添加备注信息", "noConversations": "暂无多轮对话数据集", "notRated": "未评分", "minScore": "最低评分", "maxScore": "最高评分", "unconfirmed": "未确认" }, "rating": { "veryPoor": "很差", "poor": "差", "belowAverage": "偏差", "fair": "一般", "average": "中等", "good": "良好", "veryGood": "很好", "excellent": "优秀", "outstanding": "杰出", "perfect": "完美", "unrated": "未评分" }, "tags": { "noTags": "暂无标签", "addTag": "添加标签...", "addCustomTag": "添加自定义标签", "maxTagsReached": "最多可添加 {{maxTags}} 个标签", "availableTagsHint": "可从已有标签中选择,或输入新标签" }, "import": { "title": "导入", "fileUpload": "文件上传", "fileUploadDescription": "上传本地文件导入数据集", "uploadFile": "上传文件", "supportedFormats": "支持 JSON、JSONL、CSV 格式文件", "dragDropFile": "拖拽文件到此处或点击选择文件", "dropFileHere": "松开以上传文件", "maxFileSize": "最大文件大小: 50MB", "processingFile": "正在处理文件...", "uploadedFiles": "已上传文件", "uploadError": "文件上传失败,请检查文件格式是否正确", "mapFields": "字段映射", "importing": "导入中", "fieldMapping": "字段映射", "mappingDescription": "请将源数据的字段映射到目标字段。系统已自动识别可能的映射关系,您可以根据需要调整。", "selectMapping": "选择字段映射", "questionField": "问题字段", "answerField": "答案字段", "cotField": "思维链字段", "tagsField": "标签字段", "selectField": "选择字段", "questionDesc": "用户的问题或输入内容(必选)", "answerDesc": "AI的回答或输出内容(必选)", "cotDesc": "思维链或推理过程(可选)", "tagsDesc": "标签数组,多个标签用逗号分隔(可选)", "dataPreview": "数据预览", "previewNote": "显示前3条记录,每个字段值最多显示100个字符", "confirmMapping": "确认映射", "requiredFields": "请至少选择问题和答案字段的映射", "mappingRequired": "问题和答案字段为必选项", "duplicateMapping": "不能将多个目标字段映射到同一个源字段", "noPreviewData": "没有可预览的数据", "preparingData": "准备数据...", "uploadingData": "上传数据...", "processing": "处理中... {{processed}}/{{total}}", "completed": "导入完成", "importStats": "导入统计", "total": "总计: {{count}}", "success": "成功: {{count}}", "failed": "失败: {{count}}", "source": "数据源", "description": "描述", "errors": "错误信息", "moreErrors": "还有 {{count}} 个错误未显示...", "importSuccess": "数据集导入完成!", "enterDatasetName": "请输入数据集名称", "noDatasetFound": "未找到匹配的数据集", "complete": "完成", "addToEval": "添加到评估数据集", "addToEvalSuccess": "成功添加到评估数据集", "addToEvalFailed": "添加失败", "generateEvalVariant": "生成评估集变体", "selectModelFirst": "请先选择模型", "generateVariantFailed": "生成变体失败", "saveVariantSuccess": "已保存到评估数据集", "saveVariantFailed": "保存失败", "evalVariantTitle": "生成评估集变体", "evalVariantHint": "AI 已根据原问答对生成了新的测试变体,您可以手动编辑后保存。", "saveToEval": "保存到评估集", "evalVariantConfigHint": "请选择生成的题目类型和数量,AI 将基于当前问答对进行改写。", "questionType": "题目类型", "typeOpenEnded": "开放式问答", "typeSingleChoice": "单选题", "typeMultipleChoice": "多选题", "typeTrueFalse": "判断题", "typeShortAnswer": "简答题", "generateCount": "生成数量", "evalVariantPreviewHint": "您可以编辑生成的题目,确认无误后保存到评估集。", "questionIndex": "题目 {{index}}", "options": "选项 (JSON数组)", "optionsHint": "例如: [\"选项A\", \"选项B\"]", "answerArrayHint": "多选题答案请输入数组,如 [\"A\", \"C\"]", "answerBoolHint": "判断题答案请输入 ✅ 或 ❌", "evalVariantPreviewTitle": "确认生成的题目", "generate": "生成" }, "update": { "newVersion": "新版本", "newVersionAvailable": "发现新版本", "currentVersion": "当前版本", "latestVersion": "最新版本", "downloadNow": "立即下载", "downloading": "正在下载:", "installNow": "立即安装", "updating": "更新中...", "updateNow": "立即更新", "viewRelease": "下载最新版本", "checking": "正在检查更新...", "noUpdates": "已是最新版本", "updateError": "更新出错", "updateSuccess": "更新成功", "restartRequired": "需要重启应用", "restartNow": "立即重启", "restartLater": "稍后重启" }, "datasetSquare": { "title": "数据集广场", "subtitle": "发现和探索各种公开数据集资源,助力您的模型训练和研究", "searchPlaceholder": "搜索数据集关键词...", "searchVia": "通过", "categoryTitle": "数据集分类", "categories": { "all": "全部", "popular": "热门推荐", "chinese": "中文资源", "english": "英文资源", "research": "研究数据", "multimodal": "多模态" }, "foundResources": "找到 {{count}} 个数据集资源", "currentFilter": "当前筛选: {{category}}", "noDatasets": "没有找到符合条件的数据集", "tryOtherCategories": "请尝试其他分类或返回全部数据集查看", "dataset": "数据集", "viewDataset": "查看数据集" }, "playground": { "title": "模型测试", "selectModelFirst": "请选择至少一个模型", "sendFirstMessage": "发送第一条消息开始测试", "inputMessage": "输入消息...", "send": "发送", "outputMode": "输出方式", "normalOutput": "普通输出", "streamingOutput": "流式输出", "clearConversation": "清空对话", "selectModelMax3": "选择模型(最多3个)", "reasoningProcess": "推理过程" }, "chunks": { "title": "文本块", "defaultTitle": "默认标题" }, "documentation": "文档", "models": { "configNotFound": "未找到模型配置", "parseError": "解析模型配置失败", "fetchFailed": "获取模型失败", "saveFailed": "保存模型配置失败", "pleaseSelectModel": "请至少选择一个模型", "title": "模型管理", "add": "添加模型", "unselectedModel": "未选择模型", "unconfiguredAPIKey": "未配置 API Key", "saveAllModels": "保存所有模型配置", "edit": "编辑", "delete": "删除", "modelId": "模型 ID", "modelName": "模型名称", "modelNamePlaceholder": "请输入模型名称(可选,默认为模型 ID)", "modelIdPlaceholder": "模型名称(可自定义输入)", "endpoint": "接口地址", "apiKey": "API密钥", "provider": "提供商(可自定义输入)", "localModel": "本地模型", "apiKeyConfigured": "API Key 已经配置", "apiKeyNotConfigured": "API Key 未配置", "temperature": "模型温度", "maxTokens": "最大生成 Token 数", "maxTokensInputTip": "滑块范围:1-{{max}}。你也可以输入任意正整数。", "topP": "Top P", "type": "模型标签", "text": "语言模型", "vision": "视觉模型", "typeTips": "如果希望使用自定义视觉模型解析PDF请务必配置至少一个视觉大模型", "refresh": "刷新模型列表", "configuredModels": "已配置模型", "unconfiguredModels": "未配置模型", "noConfiguredModels": "暂无已配置模型", "noUnconfiguredModels": "暂无未配置模型", "checkEndpointHealth": "检查端点健康度", "checkAllEndpointHealth": "一键检查全部端点", "endpointHealthy": "端点健康", "endpointCheckFailed": "端点检查失败", "endpointMissing": "端点为空", "endpointReachableModelMissing": "端点可访问,但当前模型不在返回列表中", "healthCheckSummary": "健康检查完成:正常 {{okCount}} 个,失败 {{failCount}} 个", "checking": "检查中...", "healthy": "健康", "reachable": "可达", "unhealthy": "异常", "notChecked": "未检查" }, "stats": { "ongoingProjects": "正在运行的项目", "questionCount": "问题数量", "generatedDatasets": "已生成的数据集", "supportedModels": "已支持的模型" }, "migration": { "title": "【重要】历史数据迁移", "description": "为了提升大量数据集的检索性能,自 1.3.1 版本起,Easy Dataset 将文件存储方式变更为本地数据库存储,检测到您有历史项目尚未进行迁移,在完成迁移前,您将无法访问这些项目,请尽快完成迁移!", "projectsList": "未迁移的项目", "migrate": "开始迁移", "migrating": "迁移中...", "success": "成功迁移 {{count}} 个项目", "failed": "迁移失败", "checkFailed": "检查未迁移项目失败", "checkError": "检查未迁移项目出错", "starting": "正在启动迁移任务...", "processing": "正在处理迁移任务...", "completed": "迁移已完成", "startFailed": "启动迁移任务失败", "statusFailed": "获取迁移状态失败", "taskNotFound": "迁移任务不存在", "progressStatus": "已迁移 {{completed}}/{{total}} 个项目", "openDirectory": "打开项目目录", "deleteDirectory": "删除项目目录", "confirmDelete": "确定要删除此项目目录吗?此操作不可恢复。", "openDirectoryFailed": "打开项目目录失败", "deleteDirectoryFailed": "删除项目目录失败" }, "distill": { "title": "数据蒸馏", "generateRootTags": "生成顶级标签", "generateSubTags": "生成子标签", "generateQuestions": "生成问题", "generateRootTagsTitle": "生成顶级领域标签", "generateSubTagsTitle": "为 {{parentTag}} 生成子标签", "generateQuestionsTitle": "为 {{tag}} 生成问题", "parentTag": "父标签", "parentTagPlaceholder": "请输入父标签名称(如:体育、科技等)", "parentTagHelp": "输入一个领域主题,系统将基于此生成相关标签", "tagCount": "标签数量", "tagCountHelp": "输入要生成的标签数量,最大为100个", "questionCount": "问题数量", "questionCountHelp": "输入要生成的问题数量,最大为100个", "generatedTags": "已生成的标签", "generatedQuestions": "已生成的问题", "generateTags": "生成标签", "tagPath": "标签路径", "noTags": "暂无标签", "noQuestions": "暂无问题", "clickGenerateButton": "点击上方的生成按钮开始创建标签", "selectModelFirst": "请先选择一个模型", "selectModel": "选择模型", "generateTagsError": "生成标签失败", "generateQuestionsError": "生成问题失败", "deleteTagConfirmTitle": "确认要删除标签吗?将关联删除所有该标签下的子标签、问题、数据集", "editTagTitle": "编辑标签", "tagName": "标签名称", "labelRequired": "标签名称不能为空", "tagUpdateSuccess": "标签更新成功", "tagUpdateFailed": "标签更新失败", "unknownTag": "未知标签", "autoDistillButton": "全自动蒸馏数据集", "autoDistillTitle": "全自动蒸馏数据集配置", "distillTopic": "蒸馏主题", "tagLevels": "标签层级", "tagLevelsHelper": "设置层级数量,最大为{{max}}级", "tagsPerLevel": "每层标签数量", "tagsPerLevelHelper": "每个父标签下生成的子标签数量,最大为{{max}}个", "questionsPerTag": "每个标签问题数量", "questionsPerTagHelper": "每个叶子标签生成的问题数量,最大为{{max}}个", "estimationInfo": "任务预估信息", "estimatedTags": "预计生成标签数量", "estimatedQuestions": "预计生成问题数量", "currentTags": "当前标签数量", "currentQuestions": "当前问题数量", "newTags": "预计新增标签数量", "newQuestions": "预计新增问题数量", "startAutoDistill": "开始自动蒸馏", "autoDistillProgress": "自动蒸馏进度", "overallProgress": "整体进度", "tagsProgress": "标签构建进度", "questionsProgress": "问题生成进度", "currentStage": "当前阶段", "realTimeLogs": "实时日志", "waitingForLogs": "等待日志输出...", "autoDistillStarted": "{{time}} 自动蒸馏任务开始", "autoDistillInsufficientError": "当前配置不会产生新的标签或问题,请调整参数", "stageInitializing": "初始化中...", "stageBuildingLevel1": "正在构建第一层标签", "stageBuildingLevel2": "正在构建第二层标签", "stageBuildingLevel3": "正在构建第三层标签", "stageBuildingLevel4": "正在构建第四层标签", "stageBuildingLevel5": "正在构建第五层标签", "stageBuildingQuestions": "正在生成问题", "stageBuildingDatasets": "正在生成数据集", "stageCompleted": "任务已完成", "datasetsProgress": "数据集生成进度", "rootTopicHelperText": "默认以项目名称作为顶级蒸馏主题,如需更改,请到项目设置中更改项目名称。", "addChildTag": "生成子标签", "datasetType": "数据集类型", "singleTurnDataset": "单轮对话数据集", "multiTurnDataset": "多轮对话数据集", "bothDatasetTypes": "两种数据集都生成", "autoDistillTaskDetail": "自动蒸馏任务: {{topic}}", "backgroundTaskCreated": "后台蒸馏任务已创建,可在任务管理中查看进度", "backgroundTaskFailed": "创建后台任务失败", "taskExecutionError": "任务执行出错: {{error}}" }, "tasks": { "pending": "有 {{count}} 个任务处理中", "completed": "全部任务已完成", "title": "任务管理中心", "loading": "加载任务列表...", "empty": "暂无任务记录", "confirmDelete": "确认删除该任务?", "confirmAbort": "确认中断该任务?任务将停止执行。", "deleteSuccess": "任务已删除", "deleteFailed": "删除任务失败", "abortSuccess": "任务已中断", "abortFailed": "中断任务失败", "status": { "processing": "处理中", "completed": "已完成", "failed": "失败", "aborted": "已中断", "unknown": "未知" }, "types": { "text-processing": "文本处理", "file-processing": "文件处理", "data-cleaning": "数据清洗", "question-generation": "问题生成", "answer-generation": "答案生成", "multi-turn-generation": "多轮对话生成", "eval-generation": "评估集生成", "image-question-generation": "图片问题生成", "data-distillation": "数据蒸馈", "pdf-processing": "PDF解析" }, "filters": { "status": "任务状态", "type": "任务类型" }, "actions": { "refresh": "刷新任务列表", "delete": "删除任务", "abort": "中断任务" }, "table": { "type": "任务类型", "status": "状态", "progress": "进度", "note": "备注", "createTime": "创建时间", "endTime": "完成时间", "duration": "运行时间", "model": "使用模型", "detail": "任务详情", "actions": "操作" }, "duration": { "seconds": "{{seconds}}秒", "minutes": "{{minutes}}分 {{seconds}}秒", "hours": "{{hours}}小时 {{minutes}}分" }, "fetchFailed": "获取任务列表失败", "createSuccess": "任务创建成功", "createFailed": "任务创建失败", "multiTurnCreateSuccess": "多轮对话数据集任务创建成功", "notes": { "selectedChunks": "已选择 {{count}} 个文本块", "fileBatch": "文件处理参数:{{count}} 个文件(策略:{{strategy}})", "jsonParams": "任务参数已配置", "noChunksQuestion": "没有需要生成问题的文本块", "noChunksCleaning": "没有需要清洗的文本块", "processingFailed": "任务失败:{{error}}", "questionSummary": "已处理 {{processed}}/{{total}},成功 {{succeeded}},失败 {{failed}},生成问题 {{generated}}", "datasetSummary": "已处理 {{processed}}/{{total}},成功 {{succeeded}},失败 {{failed}},生成数据集 {{generated}}", "cleaningSummary": "已处理 {{processed}}/{{total}},成功 {{succeeded}},失败 {{failed}},原文长度 {{original}},清洗后 {{cleaned}}", "genericSummary": "已处理 {{processed}}/{{total}},成功 {{succeeded}},失败 {{failed}}" } }, "gaPairs": { "title": "文体-受众对管理", "loading": "正在加载文体-受众对...", "addPair": "添加文体-受众对", "saveChanges": "保存更改", "saving": "保存中...", "restoreBackup": "恢复备份", "noGaPairsTitle": "未找到文体-受众对", "noGaPairsDescription": "为此文件生成AI驱动的文体-受众对", "generateGaPairs": "生成文体-受众对", "generating": "生成中...", "generateMore": "生成更多文体-受众对", "activePairs": "活跃的文体-受众对 ({{active}}/{{total}})", "pairNumber": "文体-受众对 #{{number}}", "active": "活跃", "deleteTooltip": "删除文体-受众对", "genre": "文体", "genreDescription": "文体描述", "audience": "受众", "audienceDescription": "受众描述", "addDialogTitle": "添加新的文体-受众对", "genreTitle": "文体标题", "audienceTitle": "受众标题", "genreTitlePlaceholder": "请输入文体标题...", "genreDescPlaceholder": "详细描述该文体...", "audienceTitlePlaceholder": "请输入受众标题...", "audienceDescPlaceholder": "详细描述目标受众...", "cancel": "取消", "addPairButton": "添加文体-受众对", "requiredFields": "文体标题和受众标题为必填项", "restoredFromBackup": "已从备份恢复", "allPairsDeleted": "已成功删除所有文体-受众对", "pairsSaved": "已成功保存 {{count}} 个文体-受众对", "additionalPairsGenerated": "成功生成了 {{count}} 个额外的文体-受众对。总计:{{total}}", "validationError": "文体-受众对 {{number}}:文体和受众标题为必填项", "loadError": "无法加载文体-受众对:{{error}}", "generateError": "生成文体-受众对失败", "saveError": "保存文体-受众对失败", "noActiveModel": "请在设置中配置AI模型后再生成文体-受众对。", "contentTooShort": "文件内容过短或不适合生成文体-受众对。", "configError": "AI模型配置错误。可能缺少必要的依赖项。", "serverError": "服务器错误 ({{status}})。请稍后重试。", "emptyResponse": "生成服务返回空响应", "generationFailed": "生成失败", "saveOperationFailed": "保存操作失败", "serviceNotAvailable": "文体-受众对生成服务不可用。请检查您的API配置。", "requestFailed": "请求失败 ({{status}})。请重试。", "internalServerError": "发生内部服务器错误。", "batchGenerate": "批量生成文体-受众对", "batchGenerateDescription": "将为选中的 {{count}} 个文件批量生成文体-受众对,该操作可能需要一些时间。", "appendMode": "追加模式", "appendModeDescription": "为已有文体-受众对的文件生成更多文体-受众对,而不是覆盖", "selectAtLeastOneFile": "请先选择至少一个文件", "noDefaultModel": "未设置默认模型,请先在项目设置中配置模型", "incompleteModelConfig": "模型配置不完整,请检查模型设置", "missingApiKey": "模型未配置API密钥,请在模型设置中添加API密钥", "loadingProjectModel": "加载项目模型中...", "usingModel": "使用模型", "startGeneration": "开始生成", "batchGenCompleted": "批量生成完成!成功为 {{success}}/{{total}} 个文件生成了文体-受众对。", "generationError": "生成过程发生错误: {{error}}", "fetchProjectInfoFailed": "获取项目信息失败: {{status}}", "fetchModelConfigFailed": "获取模型配置失败: {{status}}", "fetchProjectModelError": "获取项目模型配置时出错", "batchGenerationFailed": "批量生成文体-受众对失败", "batchGenerationSuccess": "成功为 {{count}} 个文件生成文体-受众对", "selectAllFiles": "全选", "deselectAllFiles": "取消全选", "batchGenerateTitle": "批量生成文体-受众对", "generationMode": "生成方式", "aiGenerateMode": "AI 生成", "manualAddMode": "手动添加", "genreDesc": "文体描述", "audienceDesc": "受众描述", "manualGaPairRequired": "请填写文体标题和受众标题", "batchAddManual": "批量添加" }, "batchEdit": { "title": "批量编辑文本块", "batchEdit": "批量编辑", "batchEditTooltip": "批量编辑选中的文本块", "position": "添加位置", "atBeginning": "在开头添加", "atEnd": "在结尾添加", "contentToAdd": "要添加的内容", "contentPlaceholder": "请输入要添加到文本块的内容...", "contentRequired": "请输入要添加的内容", "contentHelp": "此内容将被添加到所有选中的文本块中", "preview": "预览效果", "allChunksSelected": "已选择全部 {{count}} 个文本块", "selectedChunks": "已选择 {{selected}} / {{total}} 个文本块", "processing": "处理中...", "applyToChunks": "应用到 {{count}} 个文本块", "editSuccess": "成功编辑了 {{count}} 个文本块", "editFailed": "批量编辑失败", "previewNote": "以上是第一个选中文本块的预览效果,所有选中的文本块都将进行相同的修改" }, "errors": { "projectIdRequired": "项目ID不能为空", "getDatasetsFailed": "获取数据集失败", "getTagStatsFailed": "获取标签统计失败", "deleteFileFailed": "删除文件出错", "recordNotFound": "当前记录不存在", "mineruTokenNotFound": "未找到token配置,请检查任务设置中是否配置了MinerU token", "mineruLocalUrlNotFound": "未找到MinerU本地URL配置,请检查任务设置中是否配置了MinerU本地URL" }, "sampleData": { "questionContent": "问题内容", "answerContent": "答案内容", "cotContent": "思维链过程内容", "domainLabel": "领域标签", "textChunk": "文本块" }, "exportDialog": { "balancedExport": "平衡导出", "balancedExportTitle": "平衡导出设置", "balancedExportDescription": "根据领域标签配置每个类别的数据量,实现数据集的平衡导出", "quickSettings": "快速设置", "setAllTo50": "全部设为50", "setAllTo100": "全部设为100", "setAllTo200": "全部设为200", "customAmount": "自定义数量", "tagName": "标签名称", "availableCount": "可用数量", "exportCount": "导出数量", "settings": "设置", "totalExportCount": "总导出数量", "tagCount": "标签数量", "export": "导出" }, "imageDatasets": { "title": "图片问答数据集", "subtitle": "管理和优化您的图片问答数据集", "description": "管理和优化您的图片问答数据集。", "searchPlaceholder": "搜索问题或答案...", "noAnswer": "暂无答案", "labels": "标签", "typeLabel": "标签", "typeCustom": "自定义JSON", "typeText": "普通文本", "unscored": "未评分", "confirmed": "已确认", "unconfirmed": "未确认", "view": "查看详情", "evaluate": "质量评估", "delete": "删除", "deleteConfirm": "确定要删除这个数据集吗?", "imageName": "图片名称", "status": "状态", "scoreRange": "评分范围", "noData": "暂无图片数据集", "noDataTip": "请先在图片管理中生成问答数据集", "fetchFailed": "获取数据集失败", "fetchDetailFailed": "获取详情失败", "deleteSuccess": "删除成功", "deleteFailed": "删除失败", "updateSuccess": "更新成功", "updateFailed": "更新失败", "regenerateSuccess": "AI 识别成功", "regenerateFailed": "AI 识别失败", "notFound": "数据集不存在", "detail": "详情", "image": "图片", "question": "问题", "answer": "答案", "selectLabels": "选择标签", "noLabels": "未选择标签", "jsonPlaceholder": "输入 JSON 格式数据...", "metadata": "元数据", "score": "评分", "tags": "标签", "addTag": "添加标签...", "note": "备注", "notePlaceholder": "添加备注信息...", "modelInfo": "模型信息", "createdAt": "创建时间", "updatedAt": "更新时间", "exportTitle": "导出图片数据集", "exportFormat": "导出格式", "rawFormat": "原始格式", "customFormat": "自定义格式", "exportImagesOption": "导出图片文件", "exportImagesDesc": "将所有图片打包成 ZIP 压缩包一起下载,下载完成后可手动解压到和数据集文件同一目录下的 Images 文件夹中", "includeImagePath": "在数据集中包含图片路径", "includeImagePathDesc": "在问题或答案中添加图片路径(格式:/images/图片名称)", "systemPrompt": "系统提示词(可选)", "systemPromptPlaceholder": "输入系统提示词...", "confirmedOnly": "仅导出已确认的数据集", "exportTip": "标签格式的答案将自动解析为文本(逗号分隔)", "exportSuccess": "数据集导出成功", "exportFailed": "导出失败", "noDataToExport": "没有可导出的数据", "exportImagesSuccess": "图片压缩包导出成功", "exportImagesFailed": "图片导出失败" }, "images": { "resolution": "分辨率", "uploadTime": "上传时间", "fileName": "文件名", "title": "图片管理", "importImages": "导入图片", "searchPlaceholder": "搜索图片名称...", "hasQuestions": "问题状态", "hasDatasets": "数据集状态", "withQuestions": "已生成问题", "withoutQuestions": "未生成问题", "withDatasets": "已生成数据集", "withoutDatasets": "未生成数据集", "noImages": "暂无图片", "noImagesDescription": "开始导入图片,创建您的第一个图片数据集", "preview": "预览", "questions": "问题", "datasets": "数据集", "datasetCount": "数据集数", "generateQuestions": "生成问题", "generateDataset": "生成数据集", "deleteConfirm": "确定要删除这张图片吗?", "deleteSuccess": "删除成功", "deleteFailed": "删除失败", "batchDelete": "批量删除", "selectImagesToDelete": "请选择要删除的图片", "batchDeleteConfirm": "确定要删除选中的 {{count}} 张图片吗?", "batchDeleteSuccess": "成功删除 {{count}} 张图片", "batchDeletePartialSuccess": "成功删除 {{success}} 张,失败 {{fail}} 张", "batchDeleteFailed": "批量删除失败", "importTip": "选择一个或多个包含图片的目录,所有图片将被导入到项目中(同名图片将被覆盖)", "selectDirectory": "选择目录", "directoryPath": "目录路径", "enterDirectoryPath": "例如:/Users/username/Pictures", "selectedDirectories": "已选择的目录", "selectAtLeastOne": "请至少选择一个目录", "importSuccess": "成功导入 {{count}} 张图片", "importFailed": "导入失败", "startImport": "开始导入", "addDirectory": "添加目录", "importFromDirectory": "从目录导入", "importFromPdf": "从 PDF 导入", "importFromZip": "从压缩包导入", "pdfImportTip": "选择 PDF 文件,系统会自动将其转换为图片并导入", "zipImportTip": "选择 ZIP 压缩包文件,系统会自动解压并导入其中的图片", "clickToSelectPdf": "点击选择 PDF 文件", "clickToSelectZip": "点击选择 ZIP 文件", "supportedFormat": "支持格式:PDF", "supportedZipFormat": "支持格式:ZIP", "fileSize": "文件大小", "selectedFile": "已选择文件", "invalidPdfFile": "请选择有效的 PDF 文件", "invalidZipFile": "请选择有效的 ZIP 文件", "selectPdfFile": "请选择 PDF 文件", "selectZipFile": "请选择 ZIP 文件", "pdfImportSuccess": "成功从 PDF \"{{name}}\" 导入 {{count}} 张图片", "pdfImportFailed": "PDF 导入失败", "zipImportSuccess": "成功从压缩包 \"{{name}}\" 导入 {{count}} 张图片", "zipImportFailed": "压缩包导入失败", "convertAndImport": "转换并导入", "extractAndImport": "解压并导入", "electronRequired": "此功能需要在桌面应用中使用", "selectDirectoryFailed": "选择目录失败", "imageName": "图片名称", "questionCount": "问题数量", "questionCountHelp": "生成1-10个问题", "size": "大小", "dimensions": "尺寸", "currentModel": "当前模型", "selectModelFirst": "请先选择一个模型", "visionModelRequired": "请选择支持视觉的模型(如 GPT-4 Vision、Claude 等)", "countRange": "问题数量应在1-10之间", "questionsGenerated": "成功生成 {{count}} 个问题", "generateFailed": "生成失败", "question": "问题", "questionPlaceholder": "请输入您想问的问题...", "questionRequired": "请输入问题", "datasetGenerated": "数据集生成成功", "autoGenerateQuestions": "自动提取问题", "autoGenerateConfirm": "系统将为所有未生成问题的图片自动生成问题。此操作将创建一个后台任务,您可以在任务管理中查看进度。", "taskCreated": "任务创建成功,正在后台处理", "taskCreateFailed": "任务创建失败", "manualAnnotation": "手动标注", "annotationTitle": "图片标注", "imageInfo": "图片信息", "annotatedCount": "已标注", "selectQuestion": "选择或创建问题", "selectQuestionPlaceholder": "请选择问题模板...", "universalQuestions": "通用问题", "independentQuestions": "独立问题", "answerTypeText": "文字", "answerTypeLabel": "标签", "answerTypeCustomFormat": "自定义格式", "usedTimes": "使用 {{count}} 次", "answer": "答案", "answerPlaceholder": "请输入答案...", "selectLabels": "选择标签", "availableLabels": "可选标签", "noLabelsAvailable": "暂无可选标签", "addNewLabel": "添加新标签...", "selectedLabels": "已选择", "customFormatAnswer": "自定义格式答案", "formatRequirement": "格式要求", "customFormatPlaceholder": "请输入符合格式的 JSON...", "note": "备注", "notePlaceholder": "备注信息(可选)", "saveAndContinue": "保存并继续", "noImageSelected": "未选择图片", "noTemplateSelected": "请选择问题", "answerRequired": "请输入答案", "invalidJsonFormat": "JSON 格式不正确", "annotationSuccess": "标注保存成功", "annotationFailed": "保存标注失败", "allQuestionsAnnotated": "当前图片所有问题已标注完成", "allImagesAnnotated": "所有图片的问题都已标注完成", "noQuestionsAssociated": "当前图片未关联任何问题", "loadImageDetailFailed": "加载图片详情失败", "answeredQuestions": "已标注问题", "useTemplate": "使用模板", "formatJson": "格式化", "jsonFormatHelp": "请输入有效的JSON格式数据", "imageLoadError": "图片加载失败", "annotate": "标注", "annotateImage": "标注图片", "createQuestion": "创建问题", "createTemplate": "创建问题模板", "aiGenerate": "AI 识别", "aiGenerateSuccess": "AI 生成成功", "aiGenerateFailed": "AI 生成失败", "missingParameters": "缺少必要参数", "selectNewQuestion": "选择新问题", "fetchTemplatesFailed": "获取问题模板失败", "createTemplateSuccess": "问题模板创建成功", "createTemplateFailed": "创建问题模板失败", "updateTemplateSuccess": "问题模板更新成功", "updateTemplateFailed": "更新问题模板失败", "deleteTemplateSuccess": "问题模板删除成功", "deleteTemplateFailed": "删除问题模板失败", "template": { "management": "管理问题模板", "create": "创建模板", "edit": "编辑模板", "question": "问题内容", "description": "描述", "noTemplates": "暂无问题模板,点击创建按钮添加", "deleteConfirm": "确定要删除这个问题模板吗?", "used": "已使用", "addLabel": "添加标签", "customFormat": "自定义格式", "customFormatHelp": "输入 JSON 格式的输出约束", "customFormatInfo": "此格式将作为提示词提供给大模型,用于约束输出格式", "type": { "label": "问题类型", "universal": "通用问题", "independent": "独立问题" }, "answerType": { "label": "答案类型", "text": "文字", "tags": "标签", "customFormat": "自定义格式" }, "errors": { "questionRequired": "请输入问题内容", "labelsRequired": "标签类型问题至少需要一个标签", "customFormatRequired": "请输入自定义格式", "invalidJson": "JSON 格式不正确" } } }, "monitoring": { "title": "资源监控看板", "timeRange": { "24h": "24小时", "7d": "近7天", "30d": "近30天" }, "filters": { "allProjects": "所有项目", "allProviders": "所有提供商", "allStatus": "全部状态" }, "status": { "success": "成功", "failed": "失败" }, "actions": { "export": "导出报表" }, "stats": { "totalTokens": "总 Token 消耗", "avgTokensPerCall": "平均 Token 消耗/次", "totalCalls": "总调用次数", "avgLatency": "平均响应耗时", "inputOutput": "输入: {{input}} · 输出: {{output}}", "successCalls": "{{count}} 成功", "failedCalls": "{{count}} 失败", "failureRate": "{{rate}}% 失败率", "basedOnSuccessCalls": "基于 {{count}} 次成功请求", "noSuccessCalls": "暂无成功请求" }, "charts": { "tokenTrend": "Token 消耗趋势", "inputLegend": "输入", "outputLegend": "输出", "distributionTitle": "Token 消耗分布 (按模型)", "distributionSubtitle": "不同模型的资源消耗占比", "tokensTooltip": "{{value}}K Tokens" }, "table": { "title": "详细使用明细", "searchPlaceholder": "搜索项目、模型或失败原因...", "empty": "暂无数据", "rowsPerPage": "每页行数:", "columns": { "projectName": "项目名称", "provider": "模型提供商", "model": "模型名称", "status": "状态", "failureReason": "失败原因", "inputTokens": "输入 TOKEN", "outputTokens": "输出 TOKEN", "totalTokens": "TOTAL", "calls": "调用次数", "avgLatency": "平均耗时" } }, "errors": { "fetchSummaryFailed": "获取监控汇总数据失败", "fetchLogsFailed": "获取监控日志失败" } }, "eval": { "title": "评估", "datasets": "评估数据集", "tasks": "自动评估任务", "datasetsTitle": "评估数据集", "datasetsDescription": "管理和查看所有生成的评估测试题目", "tasksTitle": "评估任务", "tasksComingSoon": "功能开发中", "tasksComingSoonHint": "评估任务功能即将上线,敬请期待", "totalQuestions": "题目总数", "questionType": "题型", "question": "问题", "answer": "答案", "options": "选项", "correct": "正确", "wrong": "错误", "sourceChunk": "来源文本块", "tags": "标签", "tagsPlaceholder": "输入标签,多个标签用逗号分隔", "note": "备注", "detail": "详情", "notFound": "未找到该题目", "noData": "暂无评估数据", "noDataHint": "请先在文本分割页面生成评估测试集", "searchPlaceholder": "搜索题目内容...", "cardView": "卡片视图", "listView": "列表视图", "deleteSelected": "删除选中 ({{count}})", "deleteConfirmTitle": "确认删除", "deleteConfirmMessage": "确定要删除 {{count}} 个题目吗?此操作不可撤销。", "questionTypes": { "true_false": "判断题", "single_choice": "单选题", "multiple_choice": "多选题", "short_answer": "简答题", "open_ended": "开放题" } }, "evalDatasets": { "import": { "title": "导入评估数据集", "questionType": "题型", "selectTypeFirst": "请先选择题型", "selectFile": "请选择要导入的文件", "invalidFileType": "不支持的文件格式,请上传 json、xls 或 xlsx 文件", "formatPreview": "数据格式预览", "downloadTemplate": "下载模板", "template": "模板", "uploadFile": "上传文件", "dropOrClick": "点击或拖拽文件到此处", "supportedFormats": "支持 JSON、XLS、XLSX 格式", "tags": "标签(可选)", "tagsPlaceholder": "为导入的数据添加标签,多个标签用逗号分隔", "tagsHelp": "导入的所有数据将打上这些标签", "import": "导入", "importing": "导入中...", "failed": "导入失败", "success": "导入成功", "successMessage": "成功导入 {{count}} 条评估数据", "showingErrors": "显示前 {{count}} 条错误", "custom": "导入自定义数据集", "builtin": "导入内置数据集", "builtinTitle": "选择内置数据集", "searchPlaceholder": "搜索数据集...", "confirmImportTitle": "确认导入", "confirmImportMessage": "确定要导入数据集 \"{{name}}\" 吗?这将添加新的评估数据到当前项目。", "downloading": "下载中..." }, "export": { "title": "导出评估数据集", "formatLabel": "导出格式", "filterLabel": "筛选条件", "previewLabel": "将导出数据:", "records": "条记录", "largeDataHint": "数据量较大,将采用流式导出,请耐心等待", "exporting": "导出中...", "exportBtn": "导出", "jsonDesc": "标准JSON数组", "jsonlDesc": "每行一条记录", "csvDesc": "表格格式", "noTagsAvailable": "暂无可用标签" } }, "evalTasks": { "title": "模型评估任务", "createTitle": "创建评估任务", "detailTitle": "评估任务详情", "createTask": "创建任务", "noTasks": "暂无评估任务", "noTasksHint": "创建评估任务来测试模型在评估数据集上的表现", "selectModels": "选择测试模型", "selectModelsHint": "可选择多个模型进行对比评估", "selectJudgeModel": "选择教师模型", "selectJudgeModelPlaceholder": "请选择...", "selectJudgeModelHint": "教师模型用于评估简答题和开放题,不能与测试模型相同", "judgeModel": "教师模型", "filterByType": "按题型筛选", "filterByTypeHint": "不选择则使用所有题目", "selectedQuestions": "已选题目", "questions": "道", "hasSubjectiveHint": "包含主观题(简答题/开放题),需要选择教师模型进行评分", "hasSubjective": "含主观题", "startEval": "开始评估", "progress": "进度", "totalQuestions": "题目数量", "status": "状态", "totalScore": "总分", "correctCount": "正确数", "accuracy": "准确率", "statsByType": "按题型统计", "resultDetails": "评估结果详情", "question": "题目", "questionType": "题型", "result": "结果", "score": "得分", "correctAnswer": "正确答案", "modelAnswer": "模型回答", "judgeResponse": "教师模型评分", "interrupt": "中断任务", "statusProcessing": "进行中", "statusCompleted": "已完成", "statusFailed": "失败", "statusInterrupted": "已中断", "deleteConfirmTitle": "确认删除", "deleteConfirmMessage": "确定要删除这个评估任务吗?此操作将同时删除所有评估结果。", "interruptConfirmTitle": "确认中断", "interruptConfirmMessage": "确定要中断这个评估任务吗?已完成的评估结果将保留。", "errorNoModels": "请至少选择一个测试模型", "errorNoQuestions": "没有可用的评估题目", "errorNoJudgeModel": "存在主观题,请选择一个教师模型用于评分", "errorJudgeSameAsTest": "教师模型不能与测试模型相同", "errorCreateFailed": "创建评估任务失败", "errorLoadFailed": "加载评估任务失败", "errorDeleteFailed": "删除评估任务失败", "errorInterruptFailed": "中断评估任务失败", "statusSuccess": "成功", "statusFormatError": "格式错误", "statusApiError": "API错误", "statusUnknown": "未知状态", "duration": "耗时", "answerStatus": "答题状态", "modelInfo": "模型信息", "reportTitle": "模型能力评估报告", "taskIdLabel": "任务 ID", "pageInfo": "第 {{page}} / {{totalPages}} 页", "noMatchingResults": "暂无符合条件的评估结果", "reportFooter": "Easy Dataset Evaluation System · Generated by AI", "finalSelection": "最终选择:", "questionsSuffix": "道题目", "noModelsAvailable": "暂无可用模型,请先在设置中配置模型", "filterTitle": "题目筛选", "clearFilter": "清空筛选", "searchKeyword": "搜索关键字", "searchPlaceholder": "搜索题目或答案内容...", "filterByTypeLabel": "题型筛选", "filterByTagLabel": "标签筛选", "questionCountLabel": "题目数量:", "useAllQuestions": "使用全部筛选结果", "randomSampleHint": "将从 {{filteredCount}} 道题中随机抽取 {{questionCount}} 道", "durationFormat": "(耗时 {{time}}s)", "totalQuestionsLabel": "总题数", "correctLabel": "正确", "incorrectLabel": "错误", "judgeComment": "AI 教师点评:", "scoreUnit": "分", "shortAnswer": "简答题", "openEnded": "开放题", "scoreAnchorsTitle": "{{type}}评分规则", "customizable": "可自定义", "scoreAnchorsHint": "自定义评分标准,用于指导LLM评估模型的回答质量", "restoreDefault": "恢复默认", "scoreRange": "分数区间", "scoreDescriptionPlaceholder": "请输入该分数区间的评分标准描述..." }, "blindTest": { "title": "人工盲测任务", "createTitle": "创建盲测任务", "createTask": "创建任务", "noTasks": "暂无盲测任务", "noTasksHint": "创建盲测任务来对比两个模型的回答质量", "selectModels": "选择对比模型", "modelA": "模型 A", "modelB": "模型 B", "modelComparison": "模型对比", "selectQuestions": "选择测试题目", "questionType": "题型", "questionTypeHint": "盲测任务仅支持简答题和开放题", "filterByTag": "按标签筛选", "questionCount": "题目数量", "availableQuestions": "可用题目:{{count}} 道", "useAllQuestions": "使用全部筛选结果", "randomSample": "将随机抽取 {{count}} 道题目", "startBlindTest": "开始盲测", "creating": "创建中...", "noModelsAvailable": "暂无可用模型,请先在设置中配置模型", "errorSelectModelA": "请选择模型A", "errorSelectModelB": "请选择模型B", "errorSameModel": "两个模型不能相同", "errorNoQuestions": "没有符合条件的题目", "statusProcessing": "进行中", "statusCompleted": "已完成", "statusFailed": "失败", "statusInterrupted": "已中断", "progress": "进度", "viewDetails": "查看详情", "continue": "继续盲测", "interrupt": "中断任务", "deleteConfirmTitle": "确认删除", "deleteConfirmMessage": "确定要删除这个盲测任务吗?此操作不可撤销。", "interruptConfirmTitle": "确认中断", "interruptConfirmMessage": "确定要中断这个盲测任务吗?已完成的评判结果将保留。", "inProgress": "盲测进行中", "generatingAnswers": "正在生成回答...", "question": "问题", "answerA": "回答 A", "answerB": "回答 B", "duration": "耗时", "whichBetter": "哪个回答更好?", "leftBetter": "左边更好", "rightBetter": "右边更好", "bothGood": "都好", "bothBad": "都不好", "loadQuestion": "加载题目", "taskNotFound": "任务不存在", "resultTitle": "盲测结果", "resultSummary": "评测结果汇总", "wins": "胜出", "times": "次", "totalQuestions": "总题数", "ties": "平局", "detailResults": "详细结果", "left": "左", "right": "右" } } ================================================ FILE: next.config.js ================================================ // 最佳实践配置示例 module.exports = { experimental: { serverComponentsExternalPackages: ['@opendocsg/pdf2md', 'pdfjs-dist', '@hyzyla/pdfium'], esmExternals: 'loose' }, webpack: (config, { isServer }) => { if (!isServer) { config.externals.push({ unpdf: 'window.unpdf', 'pdfjs-dist': 'window.pdfjsLib' }); } else { config.externals.push('pdfjs-dist'); config.externals.push('@hyzyla/pdfium'); } return config; } }; ================================================ FILE: package.json ================================================ { "name": "easy-dataset", "version": "1.7.2", "private": true, "author": { "name": "ConardLi", "email": "1009903985@qq.com", "url": "https://github.com/ConardLi" }, "homepage": "https://github.com/ConardLi/easy-dataset", "scripts": { "db:studio": "prisma studio", "db:push": "prisma db push", "db:template": "node prisma/generate-template.js", "dev": "prisma db push && next dev -p 1717", "build": "prisma db push && next build", "start": "next start -p 1717", "lint": "next lint", "electron": "electron .", "electron-dev": "concurrently \"pnpm dev\" \"wait-on http://localhost:1717 && electron .\"", "electron-pack": "electron-builder --dir", "electron-dist": "electron-builder", "clean-dist": "rm -rf dist", "electron-build": "pnpm clean-dist && pnpm db:template && prisma db push && next build && electron-builder -mwl", "electron-build-mac": "pnpm clean-dist && pnpm db:template && prisma db push && next build && electron-builder --mac", "electron-build-win": "pnpm clean-dist && pnpm db:template && prisma db push && next build && electron-builder --win", "electron-build-linux": "pnpm clean-dist && pnpm db:template && prisma db push && next build && electron-builder --linux", "docker": "docker build -t easy-dataset .", "prettier": "npx prettier --write ." }, "bin": "desktop/server.js", "pkg": { "assets": [ ".next/**/*", "public/**/*", "locales/**/*", "package.json", "node_modules/next/**/*" ], "targets": [ "node18-macos-arm64", "node18-macos-x64", "node18-win-x64", "node18-linux-x64" ], "outputPath": "dist" }, "dependencies": { "@ai-sdk/openai": "^1.3.9", "@ai-sdk/openai-compatible": "^1.0.22", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@fontsource/inter": "^5.0.16", "@fontsource/jetbrains-mono": "^5.0.18", "@huggingface/hub": "^2.0.2", "@lobehub/icons": "^1.96.0", "@mui/icons-material": "5.16.14", "@mui/lab": "5.0.0-alpha.175", "@mui/material": "5.16.14", "@opendocsg/pdf2md": "^0.2.1", "@openrouter/ai-sdk-provider": "^0.4.5", "@prisma/client": "^6.6.0", "adm-zip": "^0.5.16", "ai": "^4.3.4", "axios": "^1.8.4", "electron-build": "^0.0.3", "electron-updater": "^6.3.9", "formidable": "^3.5.2", "framer-motion": "^12.4.10", "github-markdown-css": "^5.8.1", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", "image-size": "^2.0.2", "jotai": "^2.12.3", "jsonrepair": "^3.13.1", "jszip": "^3.10.1", "langchain": "^0.3.24", "mammoth": "^1.9.0", "nanoid": "^5.1.5", "next": "^14.2.29", "next-themes": "^0.2.1", "ollama-ai-provider": "^1.2.0", "opener": "^1.5.2", "pdf2md-js": "1.0.8", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.4.1", "react-markdown": "^10.0.1", "recharts": "^3.6.0", "sharp": "^0.33.1", "sonner": "^2.0.3", "turndown": "^7.2.0", "xlsx": "^0.18.5", "xmldom": "^0.6.0", "zhipu-ai-provider": "^0.1.1", "zod": "^3.25.76" }, "license": "AGPL 3.0", "devDependencies": { "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", "concurrently": "^8.2.2", "electron": "^35.0.0", "electron-builder": "^24.13.3", "husky": "^9.1.7", "lint-staged": "15.5.2", "pkg": "^5.8.1", "prisma": "^6.6.0", "wait-on": "^7.2.0" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,md}": "npm run prettier" }, "main": "electron/main.js", "description": "一个用于创建大模型微调数据集的应用程序", "build": { "appId": "com.easydataset.app", "productName": "Easy Dataset", "files": [ ".next/**/*", "!.next/cache/**/*", "public/**/*", "locales/**/*", "package.json", "electron/**/*", "node_modules/**/*", "!node_modules/.cache/**/*", "!node_modules/.bin/**/*", "!node_modules/.vite/**/*", "!**/*.{md,d.ts,map}", "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}" ], "extraResources": [ "prisma/schema.prisma", "prisma/template.sqlite", "prisma/sql.json", "node_modules/.prisma/**/*", "node_modules/@prisma/client/**/*" ], "directories": { "buildResources": "public", "output": "dist" }, "asar": true, "asarUnpack": [ "**/node_modules/sharp/**/*", "**/node_modules/@img/**/*" ], "compression": "maximum", "mac": { "icon": "public/imgs/logo.icns", "category": "public.app-category.developer-tools", "target": [ { "target": "dmg", "arch": [ "arm64", "x64" ] } ], "electronLanguages": [ "zh_CN", "en" ] }, "win": { "icon": "public/imgs/logo.ico", "target": [ { "target": "nsis", "arch": [ "x64" ] } ] }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, "perMachine": false } } } ================================================ FILE: prisma/generate-template.js ================================================ /** * 此脚本用于生成空的模板数据库文件(template.sqlite) * 该文件将在应用打包时被包含,并在用户首次启动应用时作为初始数据库 */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const templatePath = path.join(__dirname, 'template.sqlite'); const sqlitePath = path.join(__dirname, 'empty.db.sqlite'); // 如果存在旧的模板文件,先删除 if (fs.existsSync(templatePath)) { console.log('删除旧的模板数据库...'); fs.unlinkSync(templatePath); } // 如果存在临时数据库文件,先删除 if (fs.existsSync(sqlitePath)) { console.log('删除临时数据库文件...'); fs.unlinkSync(sqlitePath); } try { console.log('设置临时数据库路径...'); // 设置 DATABASE_URL 环境变量 process.env.DATABASE_URL = `file:${sqlitePath}`; console.log('执行 prisma db push 创建新的数据库架构...'); // 执行 prisma db push 创建数据库架构 execSync('npx prisma db push', { stdio: 'inherit' }); console.log('将生成的数据库文件复制为模板...'); // 复制生成的数据库文件为模板 fs.copyFileSync(sqlitePath, templatePath); console.log(`✅ 模板数据库已成功生成: ${templatePath}`); } catch (error) { console.error('❌ 生成模板数据库失败:', error); process.exit(1); } finally { // 清理: 删除临时数据库文件 if (fs.existsSync(sqlitePath)) { console.log('清理临时数据库文件...'); fs.unlinkSync(sqlitePath); } } ================================================ FILE: prisma/schema.prisma ================================================ generator client { provider = "prisma-client-js" binaryTargets = ["native", "darwin-arm64", "darwin", "windows", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x", "debian-openssl-1.1.x"] } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Projects { id String @id @default(nanoid(12)) name String description String globalPrompt String @default("") questionPrompt String @default("") answerPrompt String @default("") labelPrompt String @default("") domainTreePrompt String @default("") cleanPrompt String @default("") defaultModelConfigId String? test String @default("") createAt DateTime @default(now()) updateAt DateTime @updatedAt Questions Questions[] Datasets Datasets[] DatasetConversations DatasetConversations[] Chunks Chunks[] ModelConfig ModelConfig[] UploadFiles UploadFiles[] Tags Tags[] Task Task[] GaPairs GaPairs[] CustomPrompts CustomPrompts[] Images Images[] ImageDatasets ImageDatasets[] QuestionTemplates QuestionTemplates[] EvalDatasets EvalDatasets[] } model UploadFiles { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String fileName String fileExt String path String size Int md5 String createAt DateTime @default(now()) updateAt DateTime @updatedAt GaPairs GaPairs[] } model Chunks { id String @id @default(nanoid()) name String project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String fileId String fileName String content String summary String size Int createAt DateTime @default(now()) updateAt DateTime @updatedAt Questions Questions[] EvalDatasets EvalDatasets[] @@index([projectId]) } model Tags { id String @id @default(nanoid()) label String project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String parentId String? parent Tags? @relation("Tags", fields: [parentId], references: [id]) children Tags[] @relation("Tags") @@index([projectId, label]) @@index([projectId, parentId]) } model Questions { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String chunk Chunks @relation(fields: [chunkId], references: [id]) chunkId String gaPair GaPairs? @relation(fields: [gaPairId], references: [id]) gaPairId String? // Optional: links question to the GA pair that generated it question String label String answered Boolean @default(false) imageId String? // Optional: for image-based questions imageName String? // Optional: for image-based questions templateId String? // Optional: links to ImageQuestionTemplates createAt DateTime @default(now()) updateAt DateTime @updatedAt @@index([projectId]) @@index([imageId]) @@index([templateId]) @@index([projectId, label]) } model Datasets { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String questionId String question String answer String answerType String? @default("text") // 'text' | 'label' | 'custom_format' chunkName String chunkContent String model String questionLabel String cot String confirmed Boolean @default(false) score Float @default(0) aiEvaluation String @default("") // AI评估结论 tags String @default("") note String @default("") other String @default("") // 存储其他字段的JSON字符串 createAt DateTime @default(now()) updateAt DateTime @updatedAt @@index([projectId]) @@index([projectId, confirmed, createAt, id], name: "idx_export_confirmed") @@index([projectId, createAt], name: "idx_project_createAt") } model DatasetConversations { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String questionId String // 第一个问题 Id(初始问题) question String // 第一个问题(初始问题) chunkId String // 基于哪个文本块生成 model String questionLabel String score Float @default(0) aiEvaluation String @default("") // AI评估结论 tags String @default("") note String @default("") scenario String // 对话场景(教学/咨询/讨论等) roleA String // 角色A设定 roleB String // 角色B设定 turnCount Int // 实际轮数 maxTurns Int // 设置的最大轮数 rawMessages String // JSON存储完整对话(和 ShareGPT 格式保持完全一致) confirmed Boolean @default(false) createAt DateTime @default(now()) updateAt DateTime @updatedAt @@index([projectId]) } model LlmProviders { id String @id name String apiUrl String createAt DateTime @default(now()) updateAt DateTime @updatedAt LlmModels LlmModels[] } model LlmModels { id String @id @default(nanoid()) modelId String modelName String provider LlmProviders @relation(fields: [providerId], references: [id]) providerId String createAt DateTime @default(now()) updateAt DateTime @updatedAt } model ModelConfig { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String providerId String providerName String endpoint String apiKey String modelId String modelName String type String temperature Float maxTokens Int topP Float topK Float status Int createAt DateTime @default(now()) updateAt DateTime @updatedAt } model Task { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String taskType String // 任务类型: text-processing, question-generation, answer-generation, data-distillation status Int // 任务状态: 0-处理中, 1-已完成, 2-失败, 3-已中断 startTime DateTime @default(now()) endTime DateTime? completedCount Int @default(0) totalCount Int @default(0) modelInfo String // JSON格式存储,包含使用的模型信息 language String @default("zh-CN") detail String @default("") // 任务详情 note String @default("") // 任务备注 createAt DateTime @default(now()) updateAt DateTime @updatedAt @@index([projectId]) } model CustomPrompts { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String promptType String // 提示词类型,对应 lib/llm/prompts 下的文件名 promptKey String // 提示词在模块中的键名,如 QUESTION_PROMPT, QUESTION_PROMPT_EN language String // 语言: zh-CN, en content String // 自定义的提示词内容 isActive Boolean @default(true) // 是否启用 createAt DateTime @default(now()) updateAt DateTime @updatedAt @@unique([projectId, promptType, promptKey, language]) @@index([projectId, promptType]) @@index([projectId, language]) } model GaPairs { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String uploadFile UploadFiles @relation(fields: [fileId], references: [id], onDelete: Cascade) fileId String pairNumber Int // 1-5, representing the 5 generated pairs genreTitle String // Genre name/title genreDesc String // Genre description audienceTitle String // Audience name/title audienceDesc String // Audience description isActive Boolean @default(true) // Whether this pair is active for use questions Questions[] // Questions generated by this GA pair createAt DateTime @default(now()) updateAt DateTime @updatedAt @@unique([fileId, pairNumber]) @@index([projectId]) @@index([fileId]) } model Images { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String imageName String path String // 图片存储路径 size Int // 文件大小(字节) width Int? // 图片宽度 height Int? // 图片高度 createAt DateTime @default(now()) updateAt DateTime @updatedAt ImageDatasets ImageDatasets[] @@unique([projectId, imageName]) @@index([projectId]) } model ImageDatasets { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String image Images @relation(fields: [imageId], references: [id], onDelete: Cascade) imageId String imageName String questionId String? // Optional: links to Questions table question String answer String // Stores all answer types: text, JSON array for labels, or custom format JSON answerType String @default("text") // 'text' | 'label' | 'custom_format' model String confirmed Boolean @default(false) score Float @default(0) tags String @default("") note String @default("") createAt DateTime @default(now()) updateAt DateTime @updatedAt @@index([projectId]) @@index([imageId]) @@index([questionId]) } model QuestionTemplates { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String question String // Question content sourceType String // 'image' | 'text' - data source type answerType String // 'text' | 'label' | 'custom_format' description String @default("") // Question description labels String @default("") // JSON array of label options (for answerType='label') customFormat String @default("") // Custom format definition (for answerType='custom_format') order Int @default(0) // Display order createAt DateTime @default(now()) updateAt DateTime @updatedAt @@index([projectId]) @@index([projectId, sourceType]) } model LlmUsageLogs { id String @id @default(nanoid()) projectId String provider String // 提供商: openai, anthropic, google 等 model String // 模型名称 // 核心指标 inputTokens Int @default(0) outputTokens Int @default(0) totalTokens Int @default(0) latency Int @default(0) // 响应耗时(毫秒) // 状态与追踪 status String @default("SUCCESS") // 状态: "SUCCESS", "FAILED" errorMessage String? // 失败原因,status="FAILED" 时填写 // 时间维度 createAt DateTime @default(now()) dateString String // 格式 "YYYY-MM-DD",用于快速按天聚合 @@index([projectId, dateString]) @@index([dateString]) @@index([provider]) @@index([model]) } model EvalDatasets { id String @id @default(nanoid()) project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String // 题目内容 question String // 题目内容 questionType String // 题型: true_false, single_choice, multiple_choice, short_answer, open_ended // 上下文信息(关联到文本块) chunkId String? // 关联到 Chunks 表 chunks Chunks? @relation(fields: [chunkId], references: [id]) // 选项(仅选择题使用) options String @default("") // JSON数组: ["选项A", "选项B", "选项C", "选项D"] // 标准答案 correctAnswer String // 标准答案 tags String @default("") // 标签,逗号分隔 note String @default("") // 备注 // 时间戳 createAt DateTime @default(now()) updateAt DateTime @updatedAt // 关联评估结果 EvalResults EvalResults[] @@index([projectId]) @@index([projectId, questionType]) @@index([chunkId]) } model EvalResults { id String @id @default(nanoid()) projectId String taskId String // 关联到 Task 表 // 关联评估题目 evalDataset EvalDatasets @relation(fields: [evalDatasetId], references: [id], onDelete: Cascade) evalDatasetId String // 评估结果 modelAnswer String // 模型的回答 score Float @default(0) // 得分 (0-1 之间) isCorrect Boolean @default(false) // 是否正确(用于客观题) judgeResponse String @default("") // LLM 评分的响应(用于主观题) // 答题详情 duration Int @default(0) // 答题耗时(毫秒) status Int @default(0) // 答题状态:0-成功, 1-输出不符合规范, 2-LLM调用报错 errorMessage String @default("") // 答题报错信息 // 时间戳 createAt DateTime @default(now()) updateAt DateTime @updatedAt @@unique([taskId, evalDatasetId]) // 每个任务对每道题只能有一个结果 @@index([projectId]) @@index([taskId]) @@index([evalDatasetId]) } ================================================ FILE: prisma/sql.json ================================================ [ { "version": "1.2.5", "sql": "ALTER TABLE Projects ADD COLUMN test VARCHAR(255) DEFAULT '';" }, { "version": "1.3.3", "sql": "CREATE TABLE IF NOT EXISTS Task (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n taskType VARCHAR(255) NOT NULL,\n status INT NOT NULL,\n startTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n endTime TIMESTAMP NULL,\n completedCount INT DEFAULT 0,\n totalCount INT DEFAULT 0,\n modelInfo TEXT NOT NULL,\n language VARCHAR(20) DEFAULT 'zh-CN',\n detail TEXT DEFAULT '',\n note TEXT DEFAULT '',\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_task_projectId ON Task(projectId);" }, { "version": "1.3.6", "sql": "CREATE TABLE IF NOT EXISTS GaPairs (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n fileId VARCHAR(255) NOT NULL,\n pairNumber INT NOT NULL,\n genreTitle VARCHAR(255) NOT NULL,\n genreDesc TEXT NOT NULL,\n audienceTitle VARCHAR(255) NOT NULL,\n audienceDesc TEXT NOT NULL,\n isActive BOOLEAN DEFAULT 1 NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE,\n FOREIGN KEY (fileId) REFERENCES UploadFiles(id) ON DELETE CASCADE,\n UNIQUE (fileId, pairNumber)\n);\n\nCREATE INDEX idx_gapairs_projectId ON GaPairs(projectId);\nCREATE INDEX idx_gapairs_fileId ON GaPairs(fileId);" }, { "version": "1.3.6", "sql": "ALTER TABLE Questions ADD COLUMN gaPairId VARCHAR(255) NULL;" }, { "version": "1.3.6", "sql": "ALTER TABLE Questions ADD FOREIGN KEY (gaPairId) REFERENCES GaPairs(id) ON DELETE SET NULL;\n\nCREATE INDEX idx_questions_gaPairId ON Questions(gaPairId);" }, { "version": "1.4.0", "sql": "ALTER TABLE Datasets ADD COLUMN score REAL DEFAULT 0 NOT NULL;\nALTER TABLE Datasets ADD COLUMN tags TEXT DEFAULT '[]' NOT NULL;\nALTER TABLE Datasets ADD COLUMN note TEXT DEFAULT '' NOT NULL;\nALTER TABLE Datasets ADD COLUMN other TEXT DEFAULT '' NOT NULL;\nALTER TABLE Projects ADD COLUMN cleanPrompt TEXT DEFAULT '' NOT NULL;" }, { "version": "1.5.0", "sql": "CREATE TABLE IF NOT EXISTS CustomPrompts (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n promptType VARCHAR(255) NOT NULL,\n promptKey VARCHAR(255) NOT NULL,\n language VARCHAR(10) NOT NULL,\n content TEXT NOT NULL,\n isActive BOOLEAN DEFAULT 1 NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE,\n UNIQUE (projectId, promptType, promptKey, language)\n);\n\nCREATE INDEX idx_customprompts_projectId ON CustomPrompts(projectId);\nCREATE INDEX idx_customprompts_project_type ON CustomPrompts(projectId, promptType);\nCREATE INDEX idx_customprompts_project_language ON CustomPrompts(projectId, language);" }, { "version": "1.5.0", "sql": "ALTER TABLE Datasets ADD COLUMN aiEvaluation TEXT DEFAULT '' NOT NULL;" }, { "version": "1.5.0", "sql": "CREATE TABLE IF NOT EXISTS DatasetConversations (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n questionId VARCHAR(255) NOT NULL,\n question TEXT NOT NULL,\n chunkId VARCHAR(255) NOT NULL,\n model VARCHAR(255) NOT NULL,\n questionLabel VARCHAR(255) NOT NULL,\n score REAL DEFAULT 0 NOT NULL,\n aiEvaluation TEXT DEFAULT '' NOT NULL,\n tags TEXT DEFAULT '' NOT NULL,\n note TEXT DEFAULT '' NOT NULL,\n scenario TEXT NOT NULL,\n roleA TEXT NOT NULL,\n roleB TEXT NOT NULL,\n turnCount INT NOT NULL,\n maxTurns INT NOT NULL,\n rawMessages TEXT NOT NULL,\n confirmed BOOLEAN DEFAULT 0 NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_datasetconversations_projectId ON DatasetConversations(projectId);" }, { "version": "1.6.0", "description": "为 Questions 表添加图片相关字段", "sql": "ALTER TABLE Questions ADD COLUMN imageId VARCHAR(255) NULL;\nALTER TABLE Questions ADD COLUMN imageName VARCHAR(255) NULL;\nALTER TABLE Questions ADD COLUMN templateId VARCHAR(255) NULL;" }, { "version": "1.6.0", "description": "为 Questions 表添加图片相关索引", "sql": "CREATE INDEX idx_questions_imageId ON Questions(imageId);\nCREATE INDEX idx_questions_templateId ON Questions(templateId);" }, { "version": "1.6.0", "description": "为 Datasets 表添加 answerType 字段", "sql": "ALTER TABLE Datasets ADD COLUMN answerType VARCHAR(50) DEFAULT 'text';" }, { "version": "1.6.0", "description": "创建 Images 表", "sql": "CREATE TABLE IF NOT EXISTS Images (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n imageName VARCHAR(255) NOT NULL,\n path TEXT NOT NULL,\n size INT NOT NULL,\n width INT NULL,\n height INT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE,\n UNIQUE (projectId, imageName)\n);\n\nCREATE INDEX idx_images_projectId ON Images(projectId);" }, { "version": "1.6.0", "description": "创建 ImageDatasets 表", "sql": "CREATE TABLE IF NOT EXISTS ImageDatasets (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n imageId VARCHAR(255) NOT NULL,\n imageName VARCHAR(255) NOT NULL,\n questionId VARCHAR(255) NULL,\n question TEXT NOT NULL,\n answer TEXT NOT NULL,\n answerType VARCHAR(50) DEFAULT 'text',\n model VARCHAR(255) NOT NULL,\n confirmed BOOLEAN DEFAULT 0 NOT NULL,\n score REAL DEFAULT 0 NOT NULL,\n tags TEXT DEFAULT '' NOT NULL,\n note TEXT DEFAULT '' NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE,\n FOREIGN KEY (imageId) REFERENCES Images(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_imagedatasets_projectId ON ImageDatasets(projectId);\nCREATE INDEX idx_imagedatasets_imageId ON ImageDatasets(imageId);\nCREATE INDEX idx_imagedatasets_questionId ON ImageDatasets(questionId);" }, { "version": "1.6.0", "description": "创建 QuestionTemplates 表", "sql": "CREATE TABLE IF NOT EXISTS QuestionTemplates (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n question TEXT NOT NULL,\n sourceType VARCHAR(50) NOT NULL,\n answerType VARCHAR(50) NOT NULL,\n description TEXT DEFAULT '' NOT NULL,\n labels TEXT DEFAULT '' NOT NULL,\n customFormat TEXT DEFAULT '' NOT NULL,\n \"order\" INT DEFAULT 0 NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_questiontemplates_projectId ON QuestionTemplates(projectId);\nCREATE INDEX idx_questiontemplates_project_source ON QuestionTemplates(projectId, sourceType);" }, { "version": "1.6.2", "description": "创建 LlmUsageLogs 表 - LLM 调用统计日志", "sql": "CREATE TABLE IF NOT EXISTS LlmUsageLogs (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n provider VARCHAR(255) NOT NULL,\n model VARCHAR(255) NOT NULL,\n inputTokens INT DEFAULT 0 NOT NULL,\n outputTokens INT DEFAULT 0 NOT NULL,\n totalTokens INT DEFAULT 0 NOT NULL,\n latency INT DEFAULT 0 NOT NULL,\n status VARCHAR(50) DEFAULT 'SUCCESS' NOT NULL,\n errorMessage TEXT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n dateString VARCHAR(10) NOT NULL,\n PRIMARY KEY (id)\n);\n\nCREATE INDEX idx_llmusagelogs_project_date ON LlmUsageLogs(projectId, dateString);\nCREATE INDEX idx_llmusagelogs_dateString ON LlmUsageLogs(dateString);\nCREATE INDEX idx_llmusagelogs_provider ON LlmUsageLogs(provider);\nCREATE INDEX idx_llmusagelogs_model ON LlmUsageLogs(model);" }, { "version": "1.6.2", "description": "为 Tags 和 Questions 表添加索引", "sql": "CREATE INDEX idx_tags_project_label ON Tags(projectId, label);\nCREATE INDEX idx_tags_project_parentId ON Tags(projectId, parentId);\nCREATE INDEX idx_questions_project_label ON Questions(projectId, label);" }, { "version": "1.7.0", "description": "创建 EvalDatasets 表", "sql": "CREATE TABLE IF NOT EXISTS EvalDatasets (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n question TEXT NOT NULL,\n questionType VARCHAR(50) NOT NULL,\n chunkId VARCHAR(255) NULL,\n options TEXT DEFAULT '' NOT NULL,\n correctAnswer TEXT NOT NULL,\n tags TEXT DEFAULT '' NOT NULL,\n note TEXT DEFAULT '' NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE,\n FOREIGN KEY (chunkId) REFERENCES Chunks(id) ON DELETE SET NULL\n);\n\nCREATE INDEX idx_evaldatasets_projectId ON EvalDatasets(projectId);\nCREATE INDEX idx_evaldatasets_project_type ON EvalDatasets(projectId, questionType);\nCREATE INDEX idx_evaldatasets_chunkId ON EvalDatasets(chunkId);" }, { "version": "1.7.0", "description": "创建 EvalResults 表", "sql": "CREATE TABLE IF NOT EXISTS EvalResults (\n id VARCHAR(255) NOT NULL,\n projectId VARCHAR(255) NOT NULL,\n taskId VARCHAR(255) NOT NULL,\n evalDatasetId VARCHAR(255) NOT NULL,\n modelAnswer TEXT NOT NULL,\n score REAL DEFAULT 0 NOT NULL,\n isCorrect BOOLEAN DEFAULT 0 NOT NULL,\n judgeResponse TEXT DEFAULT '' NOT NULL,\n duration INT DEFAULT 0 NOT NULL,\n status INT DEFAULT 0 NOT NULL,\n errorMessage TEXT DEFAULT '' NOT NULL,\n createAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n updateAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (id),\n FOREIGN KEY (projectId) REFERENCES Projects(id) ON DELETE CASCADE,\n FOREIGN KEY (evalDatasetId) REFERENCES EvalDatasets(id) ON DELETE CASCADE,\n UNIQUE (taskId, evalDatasetId)\n);\n\nCREATE INDEX idx_evalresults_projectId ON EvalResults(projectId);\nCREATE INDEX idx_evalresults_taskId ON EvalResults(taskId);\nCREATE INDEX idx_evalresults_evalDatasetId ON EvalResults(evalDatasetId);" } ] ================================================ FILE: styles/blindTest.js ================================================ import { alpha } from '@mui/material/styles'; export const blindTestStyles = theme => ({ // 容器 container: { p: 3, height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.default' }, // 头部 header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }, headerTitle: { display: 'flex', alignItems: 'center', gap: 1.5 }, // 进度和问题区域 questionPaper: { p: 3, borderRadius: 3, border: `1px solid ${theme.palette.divider}`, boxShadow: 'none', bgcolor: theme.palette.background.paper }, // 回答区域容器 answersContainer: { display: 'flex', gap: 3, flex: 1, minHeight: 0, // 关键:允许 flex 子项收缩 mt: 2 }, // 单个回答卡片 answerPaper: { width: 'calc(50% - 12px)', display: 'flex', flexDirection: 'column', height: '100%', borderRadius: 3, border: `1px solid ${theme.palette.divider}`, boxShadow: 'none', overflow: 'hidden', bgcolor: theme.palette.background.paper }, answerHeader: { p: 2, borderBottom: `1px solid ${theme.palette.divider}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', bgcolor: theme.palette.background.default }, answerContent: { flex: 1, overflow: 'auto', p: 3, // 增加滚动条美化 '&::-webkit-scrollbar': { width: '8px' }, '&::-webkit-scrollbar-track': { background: 'transparent' }, '&::-webkit-scrollbar-thumb': { backgroundColor: theme.palette.divider, borderRadius: '4px' }, '&::-webkit-scrollbar-thumb:hover': { backgroundColor: theme.palette.text.disabled } }, answerFooter: { p: 1.5, borderTop: `1px solid ${theme.palette.divider}`, bgcolor: theme.palette.background.default, display: 'flex', justifyContent: 'flex-end' }, // 底部投票栏 voteBar: { p: 1.5, borderRadius: 4, mx: 'auto', width: 'fit-content', minWidth: 800, mt: 'auto', bgcolor: alpha(theme.palette.background.paper, 0.8), backdropFilter: 'blur(20px)', border: `1px solid ${theme.palette.divider}`, boxShadow: theme.shadows[8] }, voteButtons: { display: 'flex', justifyContent: 'center', gap: 2 }, voteBtn: { flex: 1, py: 1.2, borderRadius: 3, fontWeight: 600, textTransform: 'none', boxShadow: 'none', '&:hover': { boxShadow: theme.shadows[4] } }, // 结果页 resultContainer: { height: 'calc(100vh - 64px)', overflow: 'auto', p: 3 }, resultContent: { maxWidth: 1200, mx: 'auto' }, // 结果卡片 scoreCard: { flex: 1, borderRadius: 3, border: `1px solid ${theme.palette.divider}`, boxShadow: 'none', transition: 'all 0.3s ease', '&:hover': { transform: 'translateY(-4px)', boxShadow: theme.shadows[4] } }, scoreCardContent: { textAlign: 'center', py: 5 }, // 详细结果列表项 resultItem: { mb: 2, borderRadius: 3, border: `1px solid ${theme.palette.divider}`, boxShadow: 'none', overflow: 'hidden', transition: 'all 0.2s ease' }, resultItemHeader: { p: 2.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', '&:hover': { bgcolor: theme.palette.action.hover } } }); ================================================ FILE: styles/globals.css ================================================ /* 添加流式输出的闪烁光标动画 */ @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } } .blinking-cursor { animation: blink 1s infinite; display: inline-block; font-weight: bold; color: #666; } ================================================ FILE: styles/home.js ================================================ // styles/home.js import { alpha } from '@mui/material/styles'; export const styles = { heroSection: { pt: { xs: 6, md: 10 }, pb: { xs: 6, md: 8 }, position: 'relative', overflow: 'hidden', transition: 'all 0.3s ease-in-out' }, heroBackground: theme => ({ background: theme.palette.mode === 'dark' ? 'linear-gradient(135deg, rgba(42, 92, 170, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%)' : 'linear-gradient(135deg, rgba(42, 92, 170, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%)', '&::before': { content: '""', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'url("/imgs/grid-pattern.png") repeat', opacity: theme.palette.mode === 'dark' ? 0.05 : 0.03, zIndex: 0 } }), decorativeCircle: { position: 'absolute', width: '800px', height: '800px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(139, 92, 246, 0.15) 0%, rgba(42, 92, 170, 0) 70%)', top: '-300px', right: '-200px', zIndex: 0, animation: 'pulse 15s infinite ease-in-out', '@keyframes pulse': { '0%': { transform: 'scale(1)' }, '50%': { transform: 'scale(1.05)' }, '100%': { transform: 'scale(1)' } } }, decorativeCircleSecond: { position: 'absolute', width: '500px', height: '500px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(42, 92, 170, 0.1) 0%, rgba(139, 92, 246, 0) 70%)', bottom: '-200px', left: '-100px', zIndex: 0, animation: 'pulse2 20s infinite ease-in-out', '@keyframes pulse2': { '0%': { transform: 'scale(1)' }, '50%': { transform: 'scale(1.08)' }, '100%': { transform: 'scale(1)' } } }, gradientTitle: theme => ({ mb: 2, background: theme.palette.gradient.primary, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text', textFillColor: 'transparent' }), createButton: theme => ({ mt: 3, px: 4, py: 1.2, borderRadius: '12px', fontSize: '1rem', background: theme.palette.gradient.primary, '&:hover': { boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)' } }), statsCard: theme => ({ mt: 6, p: { xs: 2, md: 4 }, borderRadius: '16px', boxShadow: theme.palette.mode === 'dark' ? '0 8px 24px rgba(0, 0, 0, 0.2)' : '0 8px 24px rgba(0, 0, 0, 0.05)', background: theme.palette.mode === 'dark' ? 'rgba(30, 30, 30, 0.6)' : 'rgba(255, 255, 255, 0.8)', backdropFilter: 'blur(8px)' }), projectCard: theme => ({ height: '100%', display: 'flex', flexDirection: 'column', position: 'relative', transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out', borderRadius: '16px', overflow: 'visible', // 允许内容溢出(如下拉菜单) '&:hover': { transform: 'translateY(-4px)', boxShadow: theme.palette.mode === 'dark' ? '0 12px 24px rgba(0,0,0,0.3)' : '0 12px 24px rgba(0,0,0,0.1)' } }), projectCardContent: { height: '100%', display: 'flex', flexDirection: 'column', p: 2 }, projectTitle: { fontWeight: 700, fontSize: '1rem', lineHeight: 1.2, mb: 0.25, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, projectDescription: { mb: 1.5, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden', textOverflow: 'ellipsis', height: '32px', color: 'text.secondary', fontSize: '0.75rem', lineHeight: 1.4 }, statsContainer: { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1, mt: 'auto' }, statItem: theme => ({ display: 'flex', alignItems: 'center', gap: 1, p: 0.75, borderRadius: '8px', backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)', transition: 'background-color 0.2s', '&:hover': { backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)' } }), statIconBox: (theme, color) => ({ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '6px', backgroundColor: alpha(theme.palette[color].main, 0.1), color: theme.palette[color].main }), cardFooter: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' } }; ================================================ FILE: styles/playground.js ================================================ // 模型测试页面样式 import { alpha } from '@mui/material/styles'; export const playgroundStyles = theme => ({ container: { p: 3, height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }, mainPaper: { p: 3, flex: 1, display: 'flex', flexDirection: 'column', mb: 2, borderRadius: 2 }, controlsContainer: { mb: 2 }, clearButton: { height: '56px' }, divider: { mb: 2 }, emptyStateBox: { flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', mb: 2, p: 2, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)', borderRadius: 1 }, chatContainer: { flex: 1, mb: 2 }, modelPaper: { height: '100%', display: 'flex', flexDirection: 'column', border: `1px solid ${theme.palette.divider}`, borderRadius: 1, overflow: 'hidden' }, modelHeader: { p: 1, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'primary.light', color: theme.palette.mode === 'dark' ? 'white' : 'white', fontWeight: 'medium', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center' }, modelChatBox: { flex: 1, overflowY: 'auto', p: 2, bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)' }, emptyChatBox: { display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }, inputContainer: { display: 'flex', gap: 1, mt: 2 }, sendButton: { minWidth: '120px', height: '56px', marginLeft: '20px' } });