Browse Source

Merge AuraK-improveOrgUserManager into AuraK-3.0

anhuiqiang 1 week ago
parent
commit
6f45f9f75e
100 changed files with 4542 additions and 2291 deletions
  1. 5 17
      .dockerignore
  2. 0 10
      .gitignore
  3. 179 0
      AGENTS.md
  4. 1 1
      CLAUDE.md
  5. 521 0
      all_used_keys.txt
  6. 49 0
      build_and_push.bat
  7. 38 0
      build_and_push.sh
  8. 43 0
      deploy.sh
  9. 45 45
      docker-compose.yml
  10. 9 8
      docs/1.0/DEVELOPMENT_STANDARDS.md
  11. BIN
      lint_output.txt
  12. 1551 0
      log_dups.txt
  13. 313 149
      package-lock.json
  14. 12 0
      server/check_schema.js
  15. 47 0
      server/debug_es.js
  16. 2 1
      server/package.json
  17. 38 4
      server/src/admin/admin.controller.ts
  18. 84 3
      server/src/admin/admin.service.ts
  19. 36 31
      server/src/api/api-v1.controller.ts
  20. 2 2
      server/src/api/api.controller.ts
  21. 2 2
      server/src/api/api.module.ts
  22. 6 5
      server/src/api/api.service.ts
  23. 2 11
      server/src/app.module.ts
  24. 2 1
      server/src/auth/auth.service.ts
  25. 7 28
      server/src/auth/combined-auth.guard.ts
  26. 12 9
      server/src/auth/jwt.strategy.ts
  27. 5 1
      server/src/auth/local.strategy.ts
  28. 12 12
      server/src/chat/chat.controller.ts
  29. 2 2
      server/src/chat/chat.module.ts
  30. 60 59
      server/src/chat/chat.service.ts
  31. 2 2
      server/src/common/constants.ts
  32. 2 14
      server/src/data-source.ts
  33. 23 19
      server/src/elasticsearch/elasticsearch.service.ts
  34. 13 13
      server/src/i18n/i18n.service.ts
  35. 89 365
      server/src/i18n/messages.ts
  36. 6 1
      server/src/import-task/import-task.controller.ts
  37. 12 2
      server/src/import-task/import-task.service.ts
  38. 66 47
      server/src/knowledge-base/chunk-config.service.ts
  39. 44 38
      server/src/knowledge-base/embedding.service.ts
  40. 15 40
      server/src/knowledge-base/knowledge-base.controller.ts
  41. 9 9
      server/src/knowledge-base/knowledge-base.entity.ts
  42. 0 2
      server/src/knowledge-base/knowledge-base.module.ts
  43. 166 223
      server/src/knowledge-base/knowledge-base.service.ts
  44. 61 43
      server/src/knowledge-base/memory-monitor.service.ts
  45. 3 3
      server/src/knowledge-base/text-chunker.service.ts
  46. 9 16
      server/src/knowledge-group/knowledge-group.controller.ts
  47. 5 48
      server/src/knowledge-group/knowledge-group.service.ts
  48. 4 2
      server/src/libreoffice/libreoffice.interface.ts
  49. 40 26
      server/src/libreoffice/libreoffice.service.ts
  50. 9 9
      server/src/migrations/1737800000000-AddKnowledgeBaseEnhancements.ts
  51. 29 0
      server/src/migrations/cleanup-settings-tables.sql
  52. 8 0
      server/src/migrations/restore-timestamps.sql
  53. 22 10
      server/src/model-config/dto/create-model-config.dto.ts
  54. 12 0
      server/src/model-config/model-config.controller.ts
  55. 32 1
      server/src/model-config/model-config.entity.ts
  56. 51 41
      server/src/model-config/model-config.service.ts
  57. 8 8
      server/src/note/note-category.service.ts
  58. 1 1
      server/src/ocr/ocr.controller.ts
  59. 6 6
      server/src/ocr/ocr.service.ts
  60. 12 10
      server/src/pdf2image/pdf2image.interface.ts
  61. 34 19
      server/src/pdf2image/pdf2image.service.ts
  62. 5 6
      server/src/podcasts/podcast.service.ts
  63. 4 2
      server/src/rag/rag.module.ts
  64. 61 29
      server/src/rag/rag.service.ts
  65. 13 6
      server/src/rag/rerank.service.ts
  66. 3 1
      server/src/search-history/chat-message.entity.ts
  67. 6 2
      server/src/search-history/search-history.controller.ts
  68. 1 1
      server/src/search-history/search-history.service.ts
  69. 41 8
      server/src/super-admin/super-admin.controller.ts
  70. 7 7
      server/src/super-admin/super-admin.service.ts
  71. 3 4
      server/src/tenant/tenant-setting.entity.ts
  72. 36 7
      server/src/tenant/tenant.controller.ts
  73. 12 4
      server/src/tenant/tenant.entity.ts
  74. 61 52
      server/src/tenant/tenant.service.ts
  75. 6 2
      server/src/tika/tika.service.ts
  76. 8 8
      server/src/upload/upload.controller.ts
  77. 6 7
      server/src/upload/upload.module.ts
  78. 7 8
      server/src/upload/upload.service.ts
  79. 0 85
      server/src/user-setting/dto/create-user-setting.dto.ts
  80. 0 5
      server/src/user-setting/dto/update-user-setting.dto.ts
  81. 0 15
      server/src/user-setting/dto/user-setting-response.dto.ts
  82. 0 115
      server/src/user-setting/user-setting.controller.ts
  83. 0 87
      server/src/user-setting/user-setting.entity.ts
  84. 0 16
      server/src/user-setting/user-setting.module.ts
  85. 0 165
      server/src/user-setting/user-setting.service.ts
  86. 7 4
      server/src/user/dto/create-user.dto.ts
  87. 7 4
      server/src/user/dto/update-user.dto.ts
  88. 2 1
      server/src/user/dto/user-safe.dto.ts
  89. 32 0
      server/src/user/user-setting.entity.ts
  90. 27 0
      server/src/user/user-setting.service.ts
  91. 50 38
      server/src/user/user.controller.ts
  92. 6 10
      server/src/user/user.entity.ts
  93. 5 3
      server/src/user/user.module.ts
  94. 52 38
      server/src/user/user.service.ts
  95. 58 35
      server/src/vision-pipeline/cost-control.service.ts
  96. 57 40
      server/src/vision-pipeline/vision-pipeline-cost-aware.service.ts
  97. 5 3
      server/src/vision-pipeline/vision-pipeline.interface.ts
  98. 3 1
      server/src/vision-pipeline/vision-pipeline.service.ts
  99. 12 10
      server/src/vision/vision.interface.ts
  100. 61 43
      server/src/vision/vision.service.ts

+ 5 - 17
.dockerignore

@@ -1,21 +1,9 @@
 node_modules
+web/node_modules
+server/node_modules
 dist
 .git
-.vscode
-*.log
-.DS_Store
 .env*
-build
-docker-compose*
-README.md
-LICENSE
-.gitignore
-Dockerfile
-Dockerfile*
-!nginx
-server
-data
-uploads
-temp
-!web/package.json
-!web/yarn.lock
+.gemini
+.specify
+*.log

+ 0 - 10
.gitignore

@@ -54,13 +54,3 @@ server/check_models.js
 server/aurak.sqlite
 server/models_list.json
 server/models_status.json
-all_used_keys.txt
-lint_output.txt
-log_dups.txt
-tmp_duplicates.txt
-server/build_output.txt
-server/migration_run_err.txt
-server/typeorm_err.txt
-web/build_full.txt
-web/build_output.txt
-web/tsc_errors.txt

+ 179 - 0
AGENTS.md

@@ -0,0 +1,179 @@
+# AGENTS.md
+
+This file provides guidance for AI agents working in this repository.
+
+## Project Overview
+
+Simple Knowledge Base is a full-stack RAG (Retrieval-Augmented Generation) Q&A system built with React 19 + NestJS. It's a monorepo with Japanese/Chinese documentation but English code.
+
+**Key Features:**
+- Multi-model support (OpenAI-compatible APIs + Google Gemini native SDK)
+- Dual processing modes: Fast (Tika text-only) and High-precision (Vision pipeline)
+- User isolation with JWT authentication and per-user knowledge bases
+- Hybrid search (vector + keyword) with Elasticsearch
+- Multi-language interface (Japanese, Chinese, English)
+- Streaming responses via Server-Sent Events (SSE)
+
+## Build Commands
+
+### Root Workspace
+```bash
+# Install all dependencies
+yarn install
+
+# Start both frontend and backend in dev mode
+yarn dev
+```
+
+### Server (NestJS Backend) - Port 3001
+```bash
+cd server
+
+# Build
+yarn build
+
+# Development server (watch mode)
+yarn start:dev
+
+# Format code
+yarn format
+
+# Lint code
+yarn lint
+```
+
+### Web (React Frontend) - Port 13001
+```bash
+cd web
+
+# Development server
+yarn dev
+
+# Build for production
+yarn build
+```
+
+## Test Commands
+
+### Server (Backend) - Jest Testing
+```bash
+cd server
+
+# Run all tests
+yarn test
+
+# Run tests in watch mode
+yarn test:watch
+
+# Run with coverage report
+yarn test:cov
+
+# Run single test file by name pattern
+yarn test --testPathPattern=<test-name>
+
+# Run single test by exact path
+yarn test --testPathPattern=path/to/file.spec.ts
+
+# Run e2e tests
+yarn test:e2e
+
+# Debug tests
+yarn test:debug
+```
+
+**Examples of running single tests:**
+```bash
+# Run only auth service tests
+yarn test --testPathPattern=auth.service.spec
+
+# Run tests in specific file
+yarn test src/chat/chat.service.spec.ts
+
+# Run tests matching a pattern
+yarn test --testPathPattern="user.*service"
+```
+
+**Note:** Frontend currently has no test framework configured.
+
+## Code Style Guidelines
+
+### Language Requirements
+- **Comments**: English
+- **Log messages**: English
+- **Error messages**: Internationalized (i18n) - support Japanese/Chinese/English based on user's language preference
+
+### Formatting
+- **Prettier**: Single quotes, trailing commas enabled
+- **ESLint**: TypeScript strict mode
+- **TypeScript**: Avoid `any` types, use explicit typing
+
+### Naming Conventions
+- **Variables/Functions**: `camelCase` (e.g., `getUserData`, `processDocument`)
+- **Classes/Interfaces/Types**: `PascalCase` (e.g., `UserService`, `UserDto`)
+- **Constants**: `SCREAMING_SNAKE_CASE` (e.g., `MAX_FILE_SIZE`, `DEFAULT_TIMEOUT`)
+- **Private members**: Prefix with underscore (e.g., `_privateMethod`)
+
+### Import Organization
+1. External libraries (npm packages)
+2. Internal modules (project imports)
+3. Relative imports (same directory)
+4. Use absolute imports where configured
+
+### Error Handling
+```typescript
+try {
+  // Operation logic
+} catch (error) {
+  this.logger.error('Operation failed', error);
+  throw new Error(this.i18n.t('errors.operationFailed'));
+}
+```
+
+### Testing Guidelines
+- Place test files as `*.spec.ts` in same directory as source
+- Test file naming: `{serviceName}.spec.ts` or `{controllerName}.e2e-spec.ts`
+- Use descriptive test names (describe blocks and it blocks)
+- Mock external dependencies (APIs, databases)
+
+## Project Architecture
+
+### Directory Structure
+```
+auraAuraK/
+├── web/                    # React frontend (Vite)
+│   ├── components/         # UI components
+│   ├── contexts/           # React Context providers
+│   ├── services/           # API client services
+│   └── utils/              # Utility functions
+├── server/                 # NestJS backend
+│   ├── src/
+│   │   ├── ai/             # AI services (embedding, etc.)
+│   │   ├── api/            # API module
+│   │   ├── auth/           # JWT authentication
+│   │   ├── chat/           # Chat/RAG module
+│   │   ├── elasticsearch/  # Elasticsearch integration
+│   │   ├── import-task/    # Import task management
+│   │   ├── knowledge-base/ # Knowledge base management
+│   │   ├── libreoffice/    # LibreOffice integration
+│   │   ├── model-config/   # Model configuration management
+│   │   ├── vision/         # Vision model integration
+│   │   └── vision-pipeline/# Vision pipeline orchestration
+│   ├── data/               # SQLite database storage
+│   ├── uploads/            # Uploaded files storage
+│   └── temp/               # Temporary files
+├── docs/                   # Documentation (Japanese/Chinese)
+├── nginx/                  # Nginx configuration
+├── libreoffice-server/     # LibreOffice conversion service (Python/FastAPI)
+└── docker-compose.yml      # Docker orchestration
+```
+
+### Key Concepts
+- **Dual Processing Modes**: Fast (Tika) vs High-Precision (Vision Pipeline)
+- **Multi-Model Support**: OpenAI-compatible APIs + Google Gemini
+- **RAG System**: Hybrid search with Elasticsearch, SSE streaming responses
+- **Authentication**: JWT-based user isolation
+- **Database**: SQLite with TypeORM
+
+## Reference
+
+See `CLAUDE.md` for comprehensive project documentation and `docs/DEVELOPMENT_STANDARDS.md` for detailed coding standards.

+ 1 - 1
CLAUDE.md

@@ -133,7 +133,7 @@ simple-kb/
 
 ### Adding a New API Endpoint
 1. Create controller in appropriate module under `server/src/`
-2. Add service methods with Japanese comments
+2. Add service methods with English comments
 3. Update DTOs and validation
 4. Add tests in `*.spec.ts` files
 

+ 521 - 0
all_used_keys.txt

@@ -0,0 +1,521 @@
+2d
+Authorization
+a
+actionFailed
+actions
+addFile
+addUser
+admin
+agentDesc
+agentTitle
+aiAssistant
+aiCommandsApplyResult
+aiCommandsCustom
+aiCommandsCustomPlaceholder
+aiCommandsError
+aiCommandsGenerating
+aiCommandsGoBack
+aiCommandsModalApply
+aiCommandsModalBasedOnSelection
+aiCommandsModalCustom
+aiCommandsModalCustomPlaceholder
+aiCommandsModalPreset
+aiCommandsModalResult
+aiCommandsPreset
+aiCommandsReferenceContext
+aiCommandsReset
+aiCommandsResult
+aiCommandsStartGeneration
+aiDisclaimer
+all
+allDocuments
+allFormats
+allKnowledgeGroups
+allNotes
+analyzing
+analyzingFile
+analyzingImage
+apiError
+associateKnowledgeGroup
+autoAdjustChunk
+autoAdjustOverlap
+autoAdjustOverlapMin
+back
+backToWorkspace
+baseApi
+broad
+browseFiles
+browseManageFiles
+btnChat
+cancel
+canvas
+categories
+category
+categoryCreated
+categoryDesc
+categoryName
+changePassword
+changeUserPassword
+chatDesc
+chatHyperparameters
+chatTitle
+chatWithGroup
+checkPDFStatusFailed
+chunkConfig
+chunkIndex
+chunkInfo
+chunkNumber
+chunkOverlap
+chunkSize
+citationSources
+clearFailed
+clickToSelectAndNote
+clickToSelectFolder
+configured
+confirm
+confirmChange
+confirmChangeEmbeddingModel
+confirmClear
+confirmClearKB
+confirmDeleteCategory
+confirmDeleteFile
+confirmDeleteGroup
+confirmDeleteHistory
+confirmDeleteNote
+confirmDeleteNotebook
+confirmDeleteUser
+confirmPassword
+confirmPreciseCost
+confirmRegeneratePDF
+confirmRemoveFileFromGroup
+confirmTitle
+confirmUnsupportedFile
+contentLength
+contentOCR
+conversionFailed
+convertingInProgress
+convertingPDF
+copied
+copy
+copyContent
+copySuccess
+create
+createAgent
+createCategory
+createCategoryBtn
+createFailed
+createFailedRetry
+createGroupDesc
+createNotebook
+createNotebookTitle
+createNow
+createPDFNote
+createUserFailed
+createdAt
+creating
+creatingRegularUser
+creative
+ctx
+currentPassword
+daysAgo
+defaultBadge
+defaultForUploads
+defaultLLMModel
+defaultSettingFailed
+defaultTenant
+defaultVisionModel
+delete
+deleteFailed
+deleteHistoryFailed
+deleteHistorySuccess
+deleteUser
+deleteUserFailed
+descPlaceholder
+dimensions
+dims
+directoryLabel
+documentsAndText
+domainOptional
+done
+downloadPDF
+downloadPDFFailed
+dragDropUploadDesc
+dragDropUploadTitle
+dragToSelect
+dropAnywhere
+dropToIngest
+editCategory
+editNote
+editNotebookTitle
+editUserRole
+embeddingModel
+embeddingModelWarning
+enableHyDE
+enableHybridSearch
+enableQueryExpansion
+enableReranking
+enterNamePlaceholder
+enterNewPassword
+enterNoteTitle
+enterPageNumber
+envLimitWeaker
+error
+errorGeneric
+errorLabel
+errorLoadData
+errorMessage
+errorNoModel
+errorSaveFailed
+errorTitleContentRequired
+errorUploadFile
+exampleResearch
+exitFullscreen
+exitSelectionMode
+expandMenu
+extractingText
+failedToAddToGroup
+failedToCreateCategory
+failedToDeleteCategory
+failedToRemoveFromGroup
+failedToSaveSettings
+fastMode
+fastModeDesc
+fastModeFeatures
+featureUpdated
+fileAddedToGroup
+fileDeleted
+fileRemovedFromGroup
+fileSizeLimitExceeded
+files
+fillTargetName
+filterGroupFiles
+filterLowResults
+filterNotesPlaceholder
+fullTextSearch
+fullscreenDisplay
+geminiError
+generalSettings
+generalSettingsSubtitle
+generatePDFPreviewButton
+getUserListFailed
+globalNoSpecificGroup
+globalTenantControl
+goToAdmin
+groupCreated
+groupDeleted
+groupUpdated
+groups
+headerHyperparams
+headerModelSelection
+headerRetrieval
+hidePreview
+historyMessages
+historyTitle
+hybridSearchDesc
+hybridVectorWeight
+hybridVectorWeightDesc
+hybridWeight
+hydeDesc
+idxCancel
+idxDesc
+idxEmbeddingModel
+idxFiles
+idxMethod
+idxModalTitle
+idxStart
+imagesAndVision
+importComplete
+importFolder
+importFolderTip
+importFolderTitle
+importToCurrentGroup
+importedFromLocalFolder
+indexingChunkingConfig
+indexingConfigDesc
+indexingConfigTitle
+info
+installPlugin
+installedPlugin
+kbCleared
+kbManagement
+kbManagementDesc
+kbSettingsSaved
+kbSettingsSubtitle
+langEn
+langJa
+langZh
+languageSettings
+lblEmbedding
+lblMaxTokens
+lblRerank
+lblRerankRef
+lblTargetGroup
+lblTemperature
+lblTopK
+loadFailed
+loadHistoryFailed
+loadLimitsFailed
+loadMore
+loadVisionModelFailed
+loading
+loadingHistoriesFailed
+loadingPDF
+loadingUserData
+loginButton
+loginDesc
+loginError
+loginRequired
+loginTitle
+loginToUpload
+logout
+matchScore
+max
+maxBatchSize
+maxChunkSize
+maxInput
+maxOverlapSize
+maxResponseTokens
+maxValueMsg
+min
+mmAddBtn
+mmCancel
+mmEdit
+mmEmpty
+mmErrorBaseUrlRequired
+mmErrorModelIdRequired
+mmErrorNameRequired
+mmErrorNotAuthenticated
+mmFormApiKey
+mmFormApiKeyPlaceholder
+mmFormBaseUrl
+mmFormModelId
+mmFormName
+mmFormType
+mmSave
+mmTitle
+model
+modelConfiguration
+modelDisabled
+modelEnabled
+modelLimitsInfo
+modelManagement
+modelManagementSubtitle
+modifySettings
+name
+nameHelp
+namePlaceholder
+navAgent
+navCatalog
+navChat
+navKnowledge
+navKnowledgeGroups
+navNotebook
+navPlugin
+navTenants
+needLogin
+newChat
+newGroup
+newNote
+newPassword
+newPasswordMinLength
+newTenant
+next
+nextStep
+noContentToPreview
+noDescriptionProvided
+noFiles
+noFilesDesc
+noFilesFound
+noGroups
+noGroupsFound
+noHistory
+noHistoryDesc
+noKnowledgeGroups
+noNotesFound
+noRerankModel
+noTextExtracted
+noVisionModels
+none
+noneUncategorized
+noteCreatedFailed
+noteCreatedSuccess
+noteTitlePlaceholder
+notebookDesc
+notebooks
+notebooksDesc
+onlyAdminCanModify
+openInNewWindow
+openPDFInNewTabFailed
+operational
+optimizationTips
+orgManagement
+overlapRatioLimit
+page
+password
+passwordChangeFailed
+passwordChangeSuccess
+passwordMinLength
+passwordMismatch
+passwordPlaceholder
+pdfConversionError
+pdfConversionFailed
+pdfLoadError
+pdfLoadFailed
+pdfPreview
+pdfPreviewReady
+pendingFiles
+personalNotebook
+placeholderEmpty
+placeholderNewGroup
+placeholderText
+placeholderWithFiles
+pleaseSelect
+pleaseSelectKnowledgeGroupFirst
+pleaseWait
+pluginBy
+pluginCommunity
+pluginConfig
+pluginDesc
+pluginOfficial
+pluginTitle
+position
+precise
+preciseMode
+preciseModeDesc
+preciseModeFeatures
+preparingPDFConversion
+preview
+previewHeader
+previewNotSupported
+previous
+processingMode
+pureText
+pureVector
+queryExpansionDesc
+readFailed
+readingFailed
+recommendationMsg
+recommendationReason
+reconfigureDesc
+reconfigureFile
+reconfigureTitle
+regeneratePDF
+releaseToIngest
+requestRegenerationFailed
+rerankModel
+rerankSimilarityThreshold
+rerankingDesc
+resetZoom
+retrievalSearchSettings
+retry
+roleRegularUser
+roleTenantAdmin
+save
+saveChanges
+saveNote
+saveVisionModelFailed
+saving
+screenshotPreview
+searchAgent
+searchGroupsPlaceholder
+searchPlaceholder
+searchPlugin
+searchResults
+secureIngestion
+secureProcessing
+selectCategory
+selectEmbedding
+selectEmbeddingFirst
+selectEmbeddingModel
+selectFolderTip
+selectKnowledgeGroup
+selectKnowledgeGroups
+selectLLM
+selectLLMModel
+selectOrganization
+selectPageNumber
+selectVisionModel
+selectedFilesCount
+selectedGroupsCount
+settings
+shortDescription
+showPreview
+showingRange
+sidebarDesc
+sidebarTitle
+similarityThreshold
+sourcePreview
+startByCreatingNote
+startProcessing
+startWritingPlaceholder
+statusIndexingDesc
+statusReadyDesc
+statusRunning
+statusStopped
+strict
+subFolderPlaceholder
+submitFailed
+success
+successNoteCreated
+successNoteDeleted
+successNoteUpdated
+successUploadFile
+supportedFormatsInfo
+switchLanguage
+systemConfiguration
+systemHealth
+systemUsers
+tabSettings
+targetRole
+temperature
+tenantsSubtitle
+textarea
+tipChunkTooLarge
+tipMaxValues
+tipOverlapSmall
+tipPreciseCost
+title
+topK
+totalChunks
+totalTenants
+typeEmbedding
+typeLLM
+typeRerank
+typeVision
+uncategorized
+uncategorizedFiles
+unknownError
+unknownGroup
+unsupportedFileType
+updateFailedRetry
+updatePlugin
+updateUserFailed
+updatedAtPrefix
+uploadErrors
+uploadFailed
+uploadWarning
+uploading
+user
+userAddedToOrganization
+userCreatedSuccess
+userDeletedSuccessfully
+userDemotedFromAdmin
+userList
+userManagement
+userManagementSubtitle
+userPromotedToAdmin
+username
+usernamePlaceholder
+vectorSimilarityThreshold
+viewHistory
+visionModelHelp
+visionModelSettings
+visualVision
+warning
+welcomeMessage
+x-api-key
+x-tenant-id
+x-user-language
+yesterday
+zoomIn
+zoomOut

+ 49 - 0
build_and_push.bat

@@ -0,0 +1,49 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+
+echo =======================================================
+echo Building and pushing to registry.cn-qingdao.aliyuncs.com/fzxs/
+echo =======================================================
+
+echo.
+echo ^>^> Building server image...
+docker build -t registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest -f ./server/Dockerfile ./server
+if %errorlevel% neq 0 (
+    echo Server build failed! Please check if Docker is running and network is connected.
+    pause
+    exit /b %errorlevel%
+)
+
+echo.
+echo ^>^> Building web image...
+docker build -t registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest --build-arg VITE_API_BASE_URL=/api -f ./web/Dockerfile .
+if %errorlevel% neq 0 (
+    echo Web build failed! Please check if Docker is running and network is connected.
+    pause
+    exit /b %errorlevel%
+)
+
+echo.
+echo ^>^> Pushing server image...
+docker push registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest
+if %errorlevel% neq 0 (
+    echo Push server failed! Please check if you have logged in via: docker login --username=YOUR_USERNAME registry.cn-qingdao.aliyuncs.com
+    pause
+    exit /b %errorlevel%
+)
+
+echo.
+echo ^>^> Pushing web image...
+docker push registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest
+if %errorlevel% neq 0 (
+    echo Push web failed! Please check if you have logged in to Aliyun registry.
+    pause
+    exit /b %errorlevel%
+)
+
+echo.
+echo =======================================================
+echo Images successfully built and pushed!
+echo =======================================================
+pause

+ 38 - 0
build_and_push.sh

@@ -0,0 +1,38 @@
+#!/bin/bash
+set -e
+
+# 进入脚本所在目录
+cd "$(dirname "$0")"
+
+echo "======================================================="
+echo "开始构建并推送到 registry.cn-qingdao.aliyuncs.com/fzxs/"
+echo "======================================================="
+
+echo ">> 构建 server 镜像..."
+if ! docker build -t registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest -f ./server/Dockerfile ./server; then
+    echo "server 构建失败!请检查 Docker 是否运行以及构建环境。"
+    exit 1
+fi
+
+echo ">> 构建 web 镜像..."
+if ! docker build -t registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest --build-arg VITE_API_BASE_URL=/api -f ./web/Dockerfile .; then
+    echo "web 构建失败!请检查 Docker 是否运行以及构建环境。"
+    exit 1
+fi
+
+echo ">> 推送 server 镜像..."
+if ! docker push registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest; then
+    echo "推送 server 失败!请检查是否已登录阿里云镜像仓库:"
+    echo "docker login --username=YOUR_USERNAME registry.cn-qingdao.aliyuncs.com"
+    exit 1
+fi
+
+echo ">> 推送 web 镜像..."
+if ! docker push registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest; then
+    echo "推送 web 失败!请检查是否已登录阿里云镜像仓库:"
+    exit 1
+fi
+
+echo "======================================================="
+echo "镜像构建并推送成功!"
+echo "======================================================="

+ 43 - 0
deploy.sh

@@ -0,0 +1,43 @@
+#!/bin/bash
+set -e
+
+# 进入脚本所在目录(确保和 docker-compose.yml 在同一目录)
+cd "$(dirname "$0")"
+
+echo "======================================================="
+echo "开始在服务器上拉取镜像并一键部署"
+echo "======================================================="
+
+echo ">> 正在从阿里云镜像库拉取最新的 server 和 web 镜像..."
+# 如果拉取需要密码,请确保服务器上已经执行过 docker login
+if ! docker pull registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest; then
+    echo "拉取 server 镜像失败!请确保服务器已登录 registry.cn-qingdao.aliyuncs.com"
+    exit 1
+fi
+
+if ! docker pull registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest; then
+    echo "拉取 web 镜像失败!请确保服务器已登录 registry.cn-qingdao.aliyuncs.com"
+    exit 1
+fi
+
+echo ">> 为了让 docker-compose 能直接使用拉取的镜像,重新标记(Tag)镜像..."
+docker tag registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest aurak-server:latest 2>/dev/null || true
+docker tag registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest aurak-web:latest 2>/dev/null || true
+
+# 因为 docker-compose 没有指定 image,会默认通过文件夹名字或我们指定的标签运行
+# 如果 docker-compose 仍然会去找默认名字,我们需要让环境变量里的 image 为我们拉取的,
+# 不过最简单的方式是,通过环境变量临时覆盖,或者使用 docker compose up 的特性
+# 但既然不能改 docker-compose.yml,我们可以通过 IMAGE_NAME 环境变量来覆盖吗?没有设定的话不行。
+# 所以我们可以通过 docker tag 来把阿里云的镜像打成 docker-compose.yml 默认预期的服务名字
+# 如果目录叫 AuraK,docker-compose 默认生成的镜像名叫 aurak-server 和 aurak-web
+docker tag registry.cn-qingdao.aliyuncs.com/fzxs/aurak-server:latest aurak-server 2>/dev/null || true
+docker tag registry.cn-qingdao.aliyuncs.com/fzxs/aurak-web:latest aurak-web 2>/dev/null || true
+
+echo ">> 正在重新创建并启动容器..."
+# --no-build 确保在服务器上不会意外使用本地代码触发构建
+docker compose up -d --no-build server web
+
+echo "======================================================="
+echo "部署完成!当前服务运行状态:"
+docker compose ps
+echo "======================================================="

+ 45 - 45
docker-compose.yml

@@ -58,52 +58,52 @@ services:
   #     echo 'All models pulled successfully!' && 
   #     wait"
 
-  # server:
-  #   build:
-  #     context: ./server
-  #     dockerfile: Dockerfile
-  #   container_name: aurak-server
-  #   environment:
-  #     - NODE_ENV=production
-  #     - NODE_OPTIONS=--max-old-space-size=8192
-  #     - PORT=3001
-  #     - DATABASE_PATH=/app/data/metadata.db
-  #     - ELASTICSEARCH_HOST=http://es:9200
-  #     - TIKA_HOST=http://tika:9998
-  #     - LIBREOFFICE_URL=http://libreoffice:8100
-  #     - JWT_SECRET=13405a7d-742a-41f5-8b34-012735acffea
-  #     - UPLOAD_FILE_PATH=/app/uploads
-  #     - DEFAULT_VECTOR_DIMENSIONS=2048
-  #     - TEMP_DIR=/app/temp
-  #     - CHUNK_BATCH_SIZE=50
-  #   volumes:
-  #     - ./data:/app/data
-  #     - ./uploads:/app/uploads
-  #     - ./temp:/app/temp
-  #   depends_on:
-  #     - es
-  #     - tika
-  #     - libreoffice
-  #   #    restart: unless-stopped
-  #   networks:
-  #     - aurak-network
+  server:
+    build:
+      context: ./server
+      dockerfile: Dockerfile
+    container_name: aurak-server
+    environment:
+      - NODE_ENV=production
+      - NODE_OPTIONS=--max-old-space-size=8192
+      - PORT=3001
+      - DATABASE_PATH=/app/data/metadata.db
+      - ELASTICSEARCH_HOST=http://es:9200
+      - TIKA_HOST=http://tika:9998
+      - LIBREOFFICE_URL=http://libreoffice:8100
+      - JWT_SECRET=13405a7d-742a-41f5-8b34-012735acffea
+      - UPLOAD_FILE_PATH=/app/uploads
+      - DEFAULT_VECTOR_DIMENSIONS=2048
+      - TEMP_DIR=/app/temp
+      - CHUNK_BATCH_SIZE=50
+    volumes:
+      - ./data:/app/data
+      - ./uploads:/app/uploads
+      - ./temp:/app/temp
+    depends_on:
+      - es
+      - tika
+      - libreoffice
+    #    restart: unless-stopped
+    networks:
+      - aurak-network
 
-  # web:
-  #   build:
-  #     context: .
-  #     dockerfile: ./web/Dockerfile
-  #     args:
-  #       - VITE_API_BASE_URL=/api
-  #   container_name: aurak-web
-  #   depends_on:
-  #     - server
-  #   ports:
-  #     - "80:80"
-  #     - "443:443"
-  #   volumes:
-  #     - ./nginx/conf.d:/etc/nginx/conf.d
-  #   networks:
-  #     - aurak-network
+  web:
+    build:
+      context: .
+      dockerfile: ./web/Dockerfile
+      args:
+        - VITE_API_BASE_URL=/api
+    container_name: aurak-web
+    depends_on:
+      - server
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./nginx/conf.d:/etc/nginx/conf.d
+    networks:
+      - aurak-network
 
 networks:
   aurak-network:

+ 9 - 8
docs/1.0/DEVELOPMENT_STANDARDS.md

@@ -4,7 +4,7 @@
 
 ### 1. コメントの言語
 
-- **すべてのコードコメントは語を使用する必要があります**
+- **すべてのコードコメントは中国語を使用する必要があります**
 - 以下を含みますが、これらに限定されません:
   - 関数/メソッドのコメント
   - 行内コメント
@@ -13,17 +13,18 @@
 
 ### 2. ログ出力の基準
 
-- **すべてのログ出力は語を使用する必要があります**
+- **すべてのログ出力は中国語を使用する必要があります**
 - 以下を含みますが、これらに限定されません:
   - `logger.log()` 情報ログ
   - `logger.warn()` 警告ログ
   - `logger.error()` ラーログ
   - `console.log()` デバッグ出力
 
-### 3. メッセージの基準
+### 3. エラーメッセージの基準
 
-- **すべてのメッセージ(エラー、APIレスポンス、UIテキストなど)は国際化(i18n)を保証する必要があります**
-- ハードコードされた文字列を直接使用せず、必ず翻訳キーを使用してください
+- **ユーザーに表示されるエラーメッセージは中国語を使用します**
+- **開発デバッグ用のエラーメッセージは中国語を使用します**
+- 例外スロー時のエラーメッセージには中国語を使用します
 
 ## 例
 
@@ -65,6 +66,6 @@ async getEmbeddings(texts: string[]): Promise<number[][]> {
 
 ## 履行基準
 
-1. **コードレビュー時には、必ずコメントとログが英語であること、およびメッセージが国際化されているかをチェックしてください**
-2. **新規コードは、英語のコメントとログ、および国際化されたメッセージの基準に従う必要があります**
-3. **既存のコードをリファクタリングする際は、同時にコメントとログを英語に更新し、メッセージを国際化してください**
+1. **コードレビュー時には、必ずコメントとログの言語をチェックしてください**
+2. **新規コードは、中国語のコメントとログの基準に従う必要があります**
+3. **既存のコードをリファクタリングする際は、同時にコメントとログも中国語に更新してください**

BIN
lint_output.txt


+ 1551 - 0
log_dups.txt

@@ -0,0 +1,1551 @@
+zh duplicate: aiCommandsError line 602
+zh duplicate: appTitle line 603
+zh duplicate: loginTitle line 604
+zh duplicate: loginDesc line 605
+zh duplicate: loginButton line 606
+zh duplicate: loginError line 607
+zh duplicate: unknownError line 608
+zh duplicate: usernamePlaceholder line 609
+zh duplicate: passwordPlaceholder line 610
+zh duplicate: registerButton line 611
+zh duplicate: confirm line 612
+zh duplicate: cancel line 613
+zh duplicate: confirmTitle line 614
+zh duplicate: confirmDeleteGroup line 615
+zh duplicate: systemConfiguration line 617
+zh duplicate: noFiles line 618
+zh duplicate: noFilesDesc line 619
+zh duplicate: addFile line 620
+zh duplicate: clearAll line 621
+zh duplicate: uploading line 622
+zh duplicate: editNotebookTitle line 623
+zh duplicate: noteCreatedSuccess line 624
+zh duplicate: noteCreatedFailed line 625
+zh duplicate: errorRenderFlowchart line 626
+zh duplicate: errorLoadData line 627
+zh duplicate: confirmUnsupportedFile line 628
+zh duplicate: errorReadFile line 629
+zh duplicate: successUploadFile line 630
+zh duplicate: errorUploadFile line 631
+zh duplicate: fileAddedToGroup line 632
+zh duplicate: failedToAddToGroup line 633
+zh duplicate: fileRemovedFromGroup line 634
+zh duplicate: failedToRemoveFromGroup line 635
+zh duplicate: errorProcessFile line 636
+zh duplicate: errorTitleContentRequired line 637
+zh duplicate: successNoteUpdated line 638
+zh duplicate: successNoteCreated line 639
+zh duplicate: errorSaveFailed line 640
+zh duplicate: confirmDeleteNote line 641
+zh duplicate: successNoteDeleted line 642
+zh duplicate: confirmRemoveFileFromGroup line 643
+zh duplicate: togglePreviewOpen line 644
+zh duplicate: togglePreviewClose line 645
+zh duplicate: noteTitlePlaceholder line 646
+zh duplicate: noteContentPlaceholder line 647
+zh duplicate: markdownPreviewArea line 648
+zh duplicate: back line 649
+zh duplicate: chatWithGroup line 650
+zh duplicate: chatWithFile line 651
+zh duplicate: filesCountLabel line 652
+zh duplicate: notesCountLabel line 653
+zh duplicate: indexIntoKB line 654
+zh duplicate: noFilesOrNotes line 655
+zh duplicate: sidebarTitle line 657
+zh duplicate: backToWorkspace line 658
+zh duplicate: goToAdmin line 659
+zh duplicate: sidebarDesc line 660
+zh duplicate: tabFiles line 661
+zh duplicate: files line 662
+zh duplicate: notes line 663
+zh duplicate: tabSettings line 664
+zh duplicate: langZh line 665
+zh duplicate: langEn line 666
+zh duplicate: langJa line 667
+zh duplicate: navGlobal line 668
+zh duplicate: navTenants line 669
+zh duplicate: navSystemModels line 670
+zh duplicate: navTenantManagement line 671
+zh duplicate: navUsersTeam line 672
+zh duplicate: navTenantSettings line 673
+zh duplicate: adminConsole line 674
+zh duplicate: globalDashboard line 675
+zh duplicate: statusIndexing line 676
+zh duplicate: statusReady line 677
+zh duplicate: ragSettings line 680
+zh duplicate: enableRerank line 681
+zh duplicate: enableRerankDesc line 682
+zh duplicate: selectRerankModel line 683
+zh duplicate: selectModelPlaceholder line 684
+zh duplicate: headerModelSelection line 686
+zh duplicate: headerHyperparams line 687
+zh duplicate: headerIndexing line 688
+zh duplicate: headerRetrieval line 689
+zh duplicate: btnManageModels line 690
+zh duplicate: lblLLM line 692
+zh duplicate: lblEmbedding line 693
+zh duplicate: lblRerankRef line 694
+zh duplicate: lblTemperature line 695
+zh duplicate: lblMaxTokens line 696
+zh duplicate: lblChunkSize line 697
+zh duplicate: lblChunkOverlap line 698
+zh duplicate: lblTopK line 699
+zh duplicate: lblRerank line 700
+zh duplicate: idxModalTitle line 702
+zh duplicate: idxDesc line 703
+zh duplicate: idxFiles line 704
+zh duplicate: idxMethod line 705
+zh duplicate: idxEmbeddingModel line 706
+zh duplicate: idxStart line 707
+zh duplicate: idxCancel line 708
+zh duplicate: idxAuto line 709
+zh duplicate: idxCustom line 710
+zh duplicate: mmTitle line 712
+zh duplicate: mmAddBtn line 713
+zh duplicate: mmEdit line 714
+zh duplicate: mmDelete line 715
+zh duplicate: mmEmpty line 716
+zh duplicate: mmFormName line 717
+zh duplicate: mmFormProvider line 718
+zh duplicate: mmFormModelId line 719
+zh duplicate: mmFormBaseUrl line 720
+zh duplicate: mmFormType line 721
+zh duplicate: mmFormVision line 722
+zh duplicate: mmFormDimensions line 723
+zh duplicate: mmFormDimensionsHelp line 724
+zh duplicate: mmSave line 725
+zh duplicate: mmCancel line 726
+zh duplicate: mmErrorNotAuthenticated line 727
+zh duplicate: mmErrorTitle line 728
+zh duplicate: modelEnabled line 729
+zh duplicate: modelDisabled line 730
+zh duplicate: confirmChangeEmbeddingModel line 731
+zh duplicate: embeddingModelWarning line 732
+zh duplicate: sourcePreview line 733
+zh duplicate: matchScore line 734
+zh duplicate: copyContent line 735
+zh duplicate: copySuccess line 736
+zh duplicate: selectLLMModel line 739
+zh duplicate: selectEmbeddingModel line 740
+zh duplicate: defaultForUploads line 741
+zh duplicate: noRerankModel line 742
+zh duplicate: vectorSimilarityThreshold line 743
+zh duplicate: rerankSimilarityThreshold line 744
+zh duplicate: filterLowResults line 745
+zh duplicate: fullTextSearch line 746
+zh duplicate: hybridVectorWeight line 747
+zh duplicate: hybridVectorWeightDesc line 748
+zh duplicate: lblQueryExpansion line 749
+zh duplicate: lblHyDE line 750
+zh duplicate: lblQueryExpansionDesc line 751
+zh duplicate: lblHyDEDesc line 752
+zh duplicate: apiKeyValidationFailed line 754
+zh duplicate: keepOriginalKey line 755
+zh duplicate: leaveEmptyNoChange line 756
+zh duplicate: mmFormApiKey line 757
+zh duplicate: mmFormApiKeyPlaceholder line 758
+zh duplicate: reconfigureFile line 761
+zh duplicate: modifySettings line 762
+zh duplicate: filesCount line 763
+zh duplicate: allFilesIndexed line 764
+zh duplicate: noEmbeddingModels line 765
+zh duplicate: reconfigure line 766
+zh duplicate: refresh line 767
+zh duplicate: settings line 768
+zh duplicate: needLogin line 769
+zh duplicate: citationSources line 770
+zh duplicate: chunkNumber line 771
+zh duplicate: getUserListFailed line 772
+zh duplicate: usernamePasswordRequired line 773
+zh duplicate: passwordMinLength line 774
+zh duplicate: userCreatedSuccess line 775
+zh duplicate: createUserFailed line 776
+zh duplicate: userPromotedToAdmin line 777
+zh duplicate: userDemotedFromAdmin line 778
+zh duplicate: updateUserFailed line 779
+zh duplicate: confirmDeleteUser line 780
+zh duplicate: deleteUser line 781
+zh duplicate: deleteUserFailed line 782
+zh duplicate: userDeletedSuccessfully line 783
+zh duplicate: makeUserAdmin line 784
+zh duplicate: makeUserRegular line 785
+zh duplicate: loading line 786
+zh duplicate: noUsers line 787
+zh duplicate: aiAssistant line 790
+zh duplicate: polishContent line 791
+zh duplicate: expandContent line 792
+zh duplicate: summarizeContent line 793
+zh duplicate: translateToEnglish line 794
+zh duplicate: fixGrammar line 795
+zh duplicate: aiCommandInstructPolish line 796
+zh duplicate: aiCommandInstructExpand line 797
+zh duplicate: aiCommandInstructSummarize line 798
+zh duplicate: aiCommandInstructTranslateToEn line 799
+zh duplicate: aiCommandInstructFixGrammar line 800
+zh duplicate: aiCommandsPreset line 801
+zh duplicate: aiCommandsCustom line 802
+zh duplicate: aiCommandsCustomPlaceholder line 803
+zh duplicate: aiCommandsReferenceContext line 804
+zh duplicate: aiCommandsStartGeneration line 805
+zh duplicate: aiCommandsResult line 806
+zh duplicate: aiCommandsGenerating line 807
+zh duplicate: aiCommandsApplyResult line 808
+zh duplicate: aiCommandsGoBack line 809
+zh duplicate: aiCommandsReset line 810
+zh duplicate: aiCommandsModalPreset line 811
+zh duplicate: aiCommandsModalCustom line 812
+zh duplicate: aiCommandsModalCustomPlaceholder line 813
+zh duplicate: aiCommandsModalBasedOnSelection line 814
+zh duplicate: aiCommandsModalResult line 815
+zh duplicate: aiCommandsModalApply line 816
+zh duplicate: fillAllFields line 819
+zh duplicate: passwordMismatch line 820
+zh duplicate: newPasswordMinLength line 821
+zh duplicate: changePasswordFailed line 822
+zh duplicate: changePasswordTitle line 823
+zh duplicate: changing line 824
+zh duplicate: searchResults line 825
+zh duplicate: visionModelSettings line 828
+zh duplicate: defaultVisionModel line 829
+zh duplicate: loadVisionModelFailed line 830
+zh duplicate: loadFailed line 831
+zh duplicate: saveVisionModelFailed line 832
+zh duplicate: noVisionModels line 833
+zh duplicate: selectVisionModel line 834
+zh duplicate: visionModelHelp line 835
+zh duplicate: mmErrorNameRequired line 836
+zh duplicate: mmErrorModelIdRequired line 837
+zh duplicate: mmErrorBaseUrlRequired line 838
+zh duplicate: mmRequiredAsterisk line 839
+zh duplicate: typeLLM line 841
+zh duplicate: typeEmbedding line 842
+zh duplicate: typeRerank line 843
+zh duplicate: typeVision line 844
+zh duplicate: welcome line 846
+zh duplicate: placeholderWithFiles line 847
+zh duplicate: placeholderEmpty line 848
+zh duplicate: analyzing line 849
+zh duplicate: errorGeneric line 850
+zh duplicate: errorLabel line 851
+zh duplicate: errorNoModel line 852
+zh duplicate: aiDisclaimer line 853
+zh duplicate: confirmClear line 854
+zh duplicate: removeFile line 855
+zh duplicate: apiError line 856
+zh duplicate: geminiError line 857
+zh duplicate: processedButNoText line 858
+zh duplicate: unitByte line 859
+zh duplicate: readingFailed line 860
+zh duplicate: copy line 862
+zh duplicate: copied line 863
+zh duplicate: logout line 866
+zh duplicate: changePassword line 867
+zh duplicate: userManagement line 868
+zh duplicate: userList line 869
+zh duplicate: addUser line 870
+zh duplicate: username line 871
+zh duplicate: password line 872
+zh duplicate: confirmPassword line 873
+zh duplicate: currentPassword line 874
+zh duplicate: newPassword line 875
+zh duplicate: createUser line 876
+zh duplicate: admin line 877
+zh duplicate: user line 878
+zh duplicate: adminUser line 879
+zh duplicate: confirmChange line 880
+zh duplicate: changeUserPassword line 881
+zh duplicate: enterNewPassword line 882
+zh duplicate: createdAt line 883
+zh duplicate: newChat line 884
+zh duplicate: selectKnowledgeGroups line 887
+zh duplicate: searchGroupsPlaceholder line 888
+zh duplicate: done line 889
+zh duplicate: all line 890
+zh duplicate: noGroupsFound line 891
+zh duplicate: noGroups line 892
+zh duplicate: autoRefresh line 895
+zh duplicate: refreshInterval line 896
+zh duplicate: kbManagement line 899
+zh duplicate: kbManagementDesc line 900
+zh duplicate: searchPlaceholder line 901
+zh duplicate: allGroups line 902
+zh duplicate: allStatus line 903
+zh duplicate: statusReadyFragment line 904
+zh duplicate: statusFailedFragment line 905
+zh duplicate: statusIndexingFragment line 906
+zh duplicate: uploadFile line 907
+zh duplicate: fileName line 908
+zh duplicate: size line 909
+zh duplicate: status line 910
+zh duplicate: groups line 911
+zh duplicate: actions line 912
+zh duplicate: groupsActions line 913
+zh duplicate: noFilesFound line 914
+zh duplicate: showingRange line 915
+zh duplicate: confirmDeleteFile line 916
+zh duplicate: fileDeleted line 917
+zh duplicate: deleteFailed line 918
+zh duplicate: confirmClearKB line 919
+zh duplicate: kbCleared line 920
+zh duplicate: clearFailed line 921
+zh duplicate: loginRequired line 922
+zh duplicate: uploadErrors line 923
+zh duplicate: uploadWarning line 924
+zh duplicate: uploadFailed line 925
+zh duplicate: preview line 926
+zh duplicate: addGroup line 927
+zh duplicate: delete line 928
+zh duplicate: retry line 929
+zh duplicate: retrying line 930
+zh duplicate: retrySuccess line 931
+zh duplicate: retryFailed line 932
+zh duplicate: chunkInfo line 933
+zh duplicate: totalChunks line 934
+zh duplicate: chunkIndex line 935
+zh duplicate: contentLength line 936
+zh duplicate: position line 937
+zh duplicate: reconfigureTitle line 940
+zh duplicate: reconfigureDesc line 941
+zh duplicate: indexingConfigTitle line 942
+zh duplicate: indexingConfigDesc line 943
+zh duplicate: pendingFiles line 944
+zh duplicate: processingMode line 945
+zh duplicate: analyzingFile line 946
+zh duplicate: recommendationReason line 947
+zh duplicate: fastMode line 948
+zh duplicate: fastModeDesc line 949
+zh duplicate: preciseMode line 950
+zh duplicate: preciseModeDesc line 951
+zh duplicate: fastModeFeatures line 952
+zh duplicate: fastFeature1 line 953
+zh duplicate: fastFeature2 line 954
+zh duplicate: fastFeature3 line 955
+zh duplicate: fastFeature4 line 956
+zh duplicate: fastFeature5 line 957
+zh duplicate: preciseModeFeatures line 958
+zh duplicate: preciseFeature1 line 959
+zh duplicate: preciseFeature2 line 960
+zh duplicate: preciseFeature3 line 961
+zh duplicate: preciseFeature4 line 962
+zh duplicate: preciseFeature5 line 963
+zh duplicate: preciseFeature6 line 964
+zh duplicate: embeddingModel line 965
+zh duplicate: pleaseSelect line 966
+zh duplicate: pleaseSelectKnowledgeGroupFirst line 967
+zh duplicate: selectUnassignGroupWarning line 968
+zh duplicate: chunkConfig line 969
+zh duplicate: chunkSize line 970
+zh duplicate: min line 971
+zh duplicate: max line 972
+zh duplicate: chunkOverlap line 973
+zh duplicate: modelLimitsInfo line 974
+zh duplicate: model line 975
+zh duplicate: maxChunkSize line 976
+zh duplicate: maxOverlapSize line 977
+zh duplicate: maxBatchSize line 978
+zh duplicate: envLimitWeaker line 979
+zh duplicate: optimizationTips line 980
+zh duplicate: tipChunkTooLarge line 981
+zh duplicate: tipOverlapSmall line 982
+zh duplicate: tipMaxValues line 983
+zh duplicate: tipPreciseCost line 984
+zh duplicate: selectEmbeddingFirst line 985
+zh duplicate: confirmPreciseCost line 986
+zh duplicate: startProcessing line 987
+zh duplicate: notebooks line 990
+zh duplicate: notebooksDesc line 991
+zh duplicate: createNotebook line 992
+zh duplicate: chatWithNotebook line 993
+zh duplicate: editNotebook line 994
+zh duplicate: deleteNotebook line 995
+zh duplicate: noDescription line 996
+zh duplicate: noNotebooks line 999
+zh duplicate: createFailed line 1000
+zh duplicate: confirmDeleteNotebook line 1001
+zh duplicate: createNotebookTitle line 1008
+zh duplicate: createFailedRetry line 1009
+zh duplicate: updateFailedRetry line 1010
+zh duplicate: name line 1011
+zh duplicate: nameHelp line 1012
+zh duplicate: namePlaceholder line 1013
+zh duplicate: shortDescription line 1014
+zh duplicate: descPlaceholder line 1015
+zh duplicate: creating line 1019
+zh duplicate: createNow line 1020
+zh duplicate: saving line 1021
+zh duplicate: save line 1022
+zh duplicate: chatTitle line 1025
+zh duplicate: chatDesc line 1026
+zh duplicate: viewHistory line 1027
+zh duplicate: saveSettingsFailed line 1028
+zh duplicate: loginToUpload line 1029
+zh duplicate: fileSizeLimitExceeded line 1030
+zh duplicate: unsupportedFileType line 1031
+zh duplicate: readFailed line 1032
+zh duplicate: loadHistoryFailed line 1033
+zh duplicate: loadingUserData line 1034
+zh duplicate: errorMessage line 1035
+zh duplicate: welcomeMessage line 1036
+zh duplicate: selectKnowledgeGroup line 1037
+zh duplicate: allKnowledgeGroups line 1038
+zh duplicate: unknownGroup line 1039
+zh duplicate: selectedGroupsCount line 1040
+zh duplicate: generalSettings line 1043
+zh duplicate: modelManagement line 1044
+zh duplicate: languageSettings line 1045
+zh duplicate: passwordChangeSuccess line 1046
+zh duplicate: passwordChangeFailed line 1047
+zh duplicate: create line 1048
+zh duplicate: validationFailedMsg line 1049
+zh duplicate: navChat line 1053
+zh duplicate: navCoach line 1054
+zh duplicate: navKnowledge line 1055
+zh duplicate: navKnowledgeGroups line 1056
+zh duplicate: navNotebook line 1057
+zh duplicate: notebookDesc line 1058
+zh duplicate: newNote line 1059
+zh duplicate: editNote line 1060
+zh duplicate: noNotesFound line 1061
+zh duplicate: startByCreatingNote line 1062
+zh duplicate: navCrawler line 1063
+zh duplicate: expandMenu line 1064
+zh duplicate: switchLanguage line 1065
+zh duplicate: importFolder line 1067
+zh duplicate: createPDFNote line 1070
+zh duplicate: screenshotPreview line 1071
+zh duplicate: associateKnowledgeGroup line 1072
+zh duplicate: globalNoSpecificGroup line 1073
+zh duplicate: title line 1074
+zh duplicate: enterNoteTitle line 1075
+zh duplicate: contentOCR line 1076
+zh duplicate: extractingText line 1077
+zh duplicate: analyzingImage line 1078
+zh duplicate: noTextExtracted line 1079
+zh duplicate: saveNote line 1080
+zh duplicate: page line 1083
+zh duplicate: placeholderText line 1084
+zh duplicate: createNewNotebook line 1087
+zh duplicate: nameField line 1088
+zh duplicate: required line 1089
+zh duplicate: exampleResearch line 1090
+zh duplicate: shortDescriptionField line 1091
+zh duplicate: describePurpose line 1092
+zh duplicate: creationFailed line 1095
+zh duplicate: preparingPDFConversion line 1098
+zh duplicate: pleaseWait line 1099
+zh duplicate: convertingPDF line 1100
+zh duplicate: pdfConversionFailed line 1101
+zh duplicate: pdfConversionError line 1102
+zh duplicate: pdfLoadFailed line 1103
+zh duplicate: pdfLoadError line 1104
+zh duplicate: downloadingPDF line 1105
+zh duplicate: loadingPDF line 1106
+zh duplicate: zoomOut line 1107
+zh duplicate: zoomIn line 1108
+zh duplicate: resetZoom line 1109
+zh duplicate: selectPageNumber line 1110
+zh duplicate: enterPageNumber line 1111
+zh duplicate: exitSelectionMode line 1112
+zh duplicate: clickToSelectAndNote line 1113
+zh duplicate: regeneratePDF line 1114
+zh duplicate: downloadPDF line 1115
+zh duplicate: openInNewWindow line 1116
+zh duplicate: exitFullscreen line 1117
+zh duplicate: fullscreenDisplay line 1118
+zh duplicate: pdfPreview line 1119
+zh duplicate: converting line 1120
+zh duplicate: generatePDFPreview line 1121
+zh duplicate: previewNotSupported line 1122
+zh duplicate: confirmRegeneratePDF line 1125
+zh duplicate: pdfPreviewReady line 1128
+zh duplicate: convertingInProgress line 1129
+zh duplicate: conversionFailed line 1130
+zh duplicate: generatePDFPreviewButton line 1131
+zh duplicate: checkPDFStatusFailed line 1134
+zh duplicate: requestRegenerationFailed line 1135
+zh duplicate: downloadPDFFailed line 1136
+zh duplicate: openPDFInNewTabFailed line 1137
+zh duplicate: invalidFile line 1140
+zh duplicate: incompleteFileInfo line 1141
+zh duplicate: unsupportedFileFormat line 1142
+zh duplicate: willUseFastMode line 1143
+zh duplicate: formatNoPrecise line 1144
+zh duplicate: smallFileFastOk line 1145
+zh duplicate: mixedContentPreciseRecommended line 1146
+zh duplicate: willIncurApiCost line 1147
+zh duplicate: largeFilePreciseRecommended line 1148
+zh duplicate: longProcessingTime line 1149
+zh duplicate: highApiCost line 1150
+zh duplicate: considerFileSplitting line 1151
+zh duplicate: dragDropUploadTitle line 1154
+zh duplicate: dragDropUploadDesc line 1155
+zh duplicate: supportedFormats line 1156
+zh duplicate: browseFiles line 1157
+zh duplicate: recommendationMsg line 1160
+zh duplicate: autoAdjustChunk line 1161
+zh duplicate: autoAdjustOverlap line 1162
+zh duplicate: autoAdjustOverlapMin line 1163
+zh duplicate: loadLimitsFailed line 1164
+zh duplicate: maxValueMsg line 1165
+zh duplicate: overlapRatioLimit line 1166
+zh duplicate: onlyAdminCanModify line 1167
+zh duplicate: dragToSelect line 1168
+zh duplicate: fillTargetName line 1171
+zh duplicate: submitFailed line 1172
+zh duplicate: importFolderTitle line 1173
+zh duplicate: importFolderTip line 1174
+zh duplicate: lblTargetGroup line 1175
+zh duplicate: placeholderNewGroup line 1176
+zh duplicate: importToCurrentGroup line 1177
+zh duplicate: nextStep line 1178
+zh duplicate: selectedFilesCount line 1182
+zh duplicate: clickToSelectFolder line 1183
+zh duplicate: selectFolderTip line 1184
+zh duplicate: importComplete line 1185
+zh duplicate: importedFromLocalFolder line 1186
+zh duplicate: historyTitle line 1189
+zh duplicate: confirmDeleteHistory line 1190
+zh duplicate: deleteHistorySuccess line 1191
+zh duplicate: deleteHistoryFailed line 1192
+zh duplicate: yesterday line 1193
+zh duplicate: daysAgo line 1194
+zh duplicate: historyMessages line 1195
+zh duplicate: noHistory line 1196
+zh duplicate: noHistoryDesc line 1197
+zh duplicate: loadMore line 1198
+zh duplicate: loadingHistoriesFailed line 1199
+zh duplicate: supportedFormatsInfo line 1200
+zh duplicate: aiCommandsError line 1203
+zh duplicate: appTitle line 1204
+zh duplicate: loginTitle line 1205
+zh duplicate: loginDesc line 1206
+zh duplicate: loginButton line 1207
+zh duplicate: loginError line 1208
+zh duplicate: unknownError line 1209
+zh duplicate: usernamePlaceholder line 1210
+zh duplicate: passwordPlaceholder line 1211
+zh duplicate: registerButton line 1212
+zh duplicate: langZh line 1213
+zh duplicate: langEn line 1214
+zh duplicate: langJa line 1215
+zh duplicate: confirm line 1216
+zh duplicate: cancel line 1217
+zh duplicate: confirmTitle line 1218
+zh duplicate: confirmDeleteGroup line 1219
+zh duplicate: sidebarTitle line 1221
+zh duplicate: backToWorkspace line 1222
+zh duplicate: goToAdmin line 1223
+zh duplicate: sidebarDesc line 1224
+zh duplicate: tabFiles line 1225
+zh duplicate: files line 1226
+zh duplicate: notes line 1227
+zh duplicate: tabSettings line 1228
+zh duplicate: systemConfiguration line 1229
+zh duplicate: noFiles line 1230
+zh duplicate: noFilesDesc line 1231
+zh duplicate: addFile line 1232
+zh duplicate: clearAll line 1233
+zh duplicate: uploading line 1234
+zh duplicate: statusIndexing line 1235
+zh duplicate: statusReady line 1236
+zh duplicate: ragSettings line 1239
+zh duplicate: enableRerank line 1240
+zh duplicate: enableRerankDesc line 1241
+zh duplicate: selectRerankModel line 1242
+zh duplicate: selectModelPlaceholder line 1243
+zh duplicate: headerModelSelection line 1245
+zh duplicate: headerHyperparams line 1246
+zh duplicate: headerIndexing line 1247
+zh duplicate: headerRetrieval line 1248
+zh duplicate: btnManageModels line 1249
+zh duplicate: lblLLM line 1251
+zh duplicate: lblEmbedding line 1252
+zh duplicate: lblRerankRef line 1253
+zh duplicate: lblTemperature line 1254
+zh duplicate: lblMaxTokens line 1255
+zh duplicate: lblChunkSize line 1256
+zh duplicate: lblChunkOverlap line 1257
+zh duplicate: lblTopK line 1258
+zh duplicate: lblRerank line 1259
+zh duplicate: idxModalTitle line 1261
+zh duplicate: idxDesc line 1262
+zh duplicate: idxFiles line 1263
+zh duplicate: idxMethod line 1264
+zh duplicate: idxEmbeddingModel line 1265
+zh duplicate: idxStart line 1266
+zh duplicate: idxCancel line 1267
+zh duplicate: idxAuto line 1268
+zh duplicate: idxCustom line 1269
+zh duplicate: mmTitle line 1271
+zh duplicate: mmAddBtn line 1272
+zh duplicate: mmEdit line 1273
+zh duplicate: mmDelete line 1274
+zh duplicate: mmEmpty line 1275
+zh duplicate: mmFormName line 1276
+zh duplicate: mmFormProvider line 1277
+zh duplicate: mmFormModelId line 1278
+zh duplicate: mmFormBaseUrl line 1279
+zh duplicate: mmFormType line 1280
+zh duplicate: mmFormVision line 1281
+zh duplicate: mmFormDimensions line 1282
+zh duplicate: mmFormDimensionsHelp line 1283
+zh duplicate: mmSave line 1284
+zh duplicate: mmCancel line 1285
+zh duplicate: mmErrorNotAuthenticated line 1286
+zh duplicate: mmErrorTitle line 1287
+zh duplicate: modelEnabled line 1288
+zh duplicate: modelDisabled line 1289
+zh duplicate: confirmChangeEmbeddingModel line 1290
+zh duplicate: embeddingModelWarning line 1291
+zh duplicate: sourcePreview line 1292
+zh duplicate: matchScore line 1293
+zh duplicate: copyContent line 1294
+zh duplicate: copySuccess line 1295
+zh duplicate: selectLLMModel line 1298
+zh duplicate: selectEmbeddingModel line 1299
+zh duplicate: defaultForUploads line 1300
+zh duplicate: noRerankModel line 1301
+zh duplicate: vectorSimilarityThreshold line 1302
+zh duplicate: rerankSimilarityThreshold line 1303
+zh duplicate: filterLowResults line 1304
+zh duplicate: noteCreatedSuccess line 1305
+zh duplicate: noteCreatedFailed line 1306
+zh duplicate: fullTextSearch line 1307
+zh duplicate: hybridVectorWeight line 1308
+zh duplicate: hybridVectorWeightDesc line 1309
+zh duplicate: lblQueryExpansion line 1310
+zh duplicate: lblHyDE line 1311
+zh duplicate: lblQueryExpansionDesc line 1312
+zh duplicate: lblHyDEDesc line 1313
+zh duplicate: apiKeyValidationFailed line 1315
+zh duplicate: keepOriginalKey line 1316
+zh duplicate: leaveEmptyNoChange line 1317
+zh duplicate: mmFormApiKey line 1318
+zh duplicate: mmFormApiKeyPlaceholder line 1319
+zh duplicate: reconfigureFile line 1322
+zh duplicate: modifySettings line 1323
+zh duplicate: filesCount line 1324
+zh duplicate: allFilesIndexed line 1325
+zh duplicate: noEmbeddingModels line 1326
+zh duplicate: reconfigure line 1327
+zh duplicate: refresh line 1328
+zh duplicate: settings line 1329
+zh duplicate: needLogin line 1330
+zh duplicate: citationSources line 1331
+zh duplicate: chunkNumber line 1332
+zh duplicate: getUserListFailed line 1333
+zh duplicate: usernamePasswordRequired line 1334
+zh duplicate: passwordMinLength line 1335
+zh duplicate: userCreatedSuccess line 1336
+zh duplicate: createUserFailed line 1337
+zh duplicate: userPromotedToAdmin line 1338
+zh duplicate: userDemotedFromAdmin line 1339
+zh duplicate: updateUserFailed line 1340
+zh duplicate: confirmDeleteUser line 1341
+zh duplicate: deleteUser line 1342
+zh duplicate: deleteUserFailed line 1343
+zh duplicate: userDeletedSuccessfully line 1344
+zh duplicate: makeUserAdmin line 1345
+zh duplicate: makeUserRegular line 1346
+zh duplicate: loading line 1347
+zh duplicate: noUsers line 1348
+zh duplicate: aiAssistant line 1351
+zh duplicate: polishContent line 1352
+zh duplicate: expandContent line 1353
+zh duplicate: summarizeContent line 1354
+zh duplicate: translateToEnglish line 1355
+zh duplicate: fixGrammar line 1356
+zh duplicate: aiCommandInstructPolish line 1357
+zh duplicate: aiCommandInstructExpand line 1358
+zh duplicate: aiCommandInstructSummarize line 1359
+zh duplicate: aiCommandInstructTranslateToEn line 1360
+zh duplicate: aiCommandInstructFixGrammar line 1361
+zh duplicate: aiCommandsPreset line 1362
+zh duplicate: aiCommandsCustom line 1363
+zh duplicate: aiCommandsCustomPlaceholder line 1364
+zh duplicate: aiCommandsReferenceContext line 1365
+zh duplicate: aiCommandsStartGeneration line 1366
+zh duplicate: aiCommandsResult line 1367
+zh duplicate: aiCommandsGenerating line 1368
+zh duplicate: aiCommandsApplyResult line 1369
+zh duplicate: aiCommandsGoBack line 1370
+zh duplicate: aiCommandsReset line 1371
+zh duplicate: aiCommandsModalPreset line 1372
+zh duplicate: aiCommandsModalCustom line 1373
+zh duplicate: aiCommandsModalCustomPlaceholder line 1374
+zh duplicate: aiCommandsModalBasedOnSelection line 1375
+zh duplicate: aiCommandsModalResult line 1376
+zh duplicate: aiCommandsModalApply line 1377
+zh duplicate: fillAllFields line 1380
+zh duplicate: passwordMismatch line 1381
+zh duplicate: newPasswordMinLength line 1382
+zh duplicate: changePasswordFailed line 1383
+zh duplicate: changePasswordTitle line 1384
+zh duplicate: changing line 1385
+zh duplicate: searchResults line 1386
+zh duplicate: visionModelSettings line 1389
+zh duplicate: defaultVisionModel line 1390
+zh duplicate: loadVisionModelFailed line 1391
+zh duplicate: loadFailed line 1392
+zh duplicate: saveVisionModelFailed line 1393
+zh duplicate: noVisionModels line 1394
+zh duplicate: selectVisionModel line 1395
+zh duplicate: visionModelHelp line 1396
+zh duplicate: mmErrorNameRequired line 1397
+zh duplicate: mmErrorModelIdRequired line 1398
+zh duplicate: mmErrorBaseUrlRequired line 1399
+zh duplicate: mmRequiredAsterisk line 1400
+zh duplicate: typeLLM line 1402
+zh duplicate: typeEmbedding line 1403
+zh duplicate: typeRerank line 1404
+zh duplicate: typeVision line 1405
+zh duplicate: welcome line 1407
+zh duplicate: placeholderWithFiles line 1408
+zh duplicate: placeholderEmpty line 1409
+zh duplicate: analyzing line 1410
+zh duplicate: errorGeneric line 1411
+zh duplicate: errorLabel line 1412
+zh duplicate: errorNoModel line 1413
+zh duplicate: aiDisclaimer line 1414
+zh duplicate: confirmClear line 1415
+zh duplicate: removeFile line 1416
+zh duplicate: apiError line 1417
+zh duplicate: geminiError line 1418
+zh duplicate: processedButNoText line 1419
+zh duplicate: unitByte line 1420
+zh duplicate: readingFailed line 1421
+zh duplicate: copy line 1423
+zh duplicate: copied line 1424
+zh duplicate: logout line 1427
+zh duplicate: changePassword line 1428
+zh duplicate: userManagement line 1429
+zh duplicate: userList line 1430
+zh duplicate: addUser line 1431
+zh duplicate: username line 1432
+zh duplicate: password line 1433
+zh duplicate: confirmPassword line 1434
+zh duplicate: currentPassword line 1435
+zh duplicate: newPassword line 1436
+zh duplicate: createUser line 1437
+zh duplicate: admin line 1438
+zh duplicate: user line 1439
+zh duplicate: adminUser line 1440
+zh duplicate: confirmChange line 1441
+zh duplicate: changeUserPassword line 1442
+zh duplicate: enterNewPassword line 1443
+zh duplicate: createdAt line 1444
+zh duplicate: newChat line 1445
+zh duplicate: kbManagement line 1448
+zh duplicate: kbManagementDesc line 1449
+zh duplicate: searchPlaceholder line 1450
+zh duplicate: allGroups line 1451
+zh duplicate: allStatus line 1452
+zh duplicate: statusReadyFragment line 1453
+zh duplicate: statusFailedFragment line 1454
+zh duplicate: statusIndexingFragment line 1455
+zh duplicate: uploadFile line 1456
+zh duplicate: fileName line 1457
+zh duplicate: size line 1458
+zh duplicate: status line 1459
+zh duplicate: groups line 1460
+zh duplicate: actions line 1461
+zh duplicate: groupsActions line 1462
+zh duplicate: noFilesFound line 1463
+zh duplicate: showingRange line 1464
+zh duplicate: confirmDeleteFile line 1465
+zh duplicate: fileDeleted line 1466
+zh duplicate: deleteFailed line 1467
+zh duplicate: fileAddedToGroup line 1468
+zh duplicate: failedToAddToGroup line 1469
+zh duplicate: fileRemovedFromGroup line 1470
+zh duplicate: failedToRemoveFromGroup line 1471
+zh duplicate: confirmClearKB line 1472
+zh duplicate: kbCleared line 1473
+zh duplicate: clearFailed line 1474
+zh duplicate: loginRequired line 1475
+zh duplicate: uploadErrors line 1476
+zh duplicate: uploadWarning line 1477
+zh duplicate: uploadFailed line 1478
+zh duplicate: preview line 1479
+zh duplicate: addGroup line 1480
+zh duplicate: delete line 1481
+zh duplicate: retry line 1482
+zh duplicate: retrying line 1483
+zh duplicate: retrySuccess line 1484
+zh duplicate: retryFailed line 1485
+zh duplicate: chunkInfo line 1486
+zh duplicate: totalChunks line 1487
+zh duplicate: chunkIndex line 1488
+zh duplicate: contentLength line 1489
+zh duplicate: position line 1490
+zh duplicate: reconfigureTitle line 1493
+zh duplicate: reconfigureDesc line 1494
+zh duplicate: indexingConfigTitle line 1495
+zh duplicate: indexingConfigDesc line 1496
+zh duplicate: pendingFiles line 1497
+zh duplicate: processingMode line 1498
+zh duplicate: analyzingFile line 1499
+zh duplicate: recommendationReason line 1500
+zh duplicate: fastMode line 1501
+zh duplicate: fastModeDesc line 1502
+zh duplicate: preciseMode line 1503
+zh duplicate: preciseModeDesc line 1504
+zh duplicate: fastModeFeatures line 1505
+zh duplicate: fastFeature1 line 1506
+zh duplicate: fastFeature2 line 1507
+zh duplicate: fastFeature3 line 1508
+zh duplicate: fastFeature4 line 1509
+zh duplicate: fastFeature5 line 1510
+zh duplicate: preciseModeFeatures line 1511
+zh duplicate: preciseFeature1 line 1512
+zh duplicate: preciseFeature2 line 1513
+zh duplicate: preciseFeature3 line 1514
+zh duplicate: preciseFeature4 line 1515
+zh duplicate: preciseFeature5 line 1516
+zh duplicate: preciseFeature6 line 1517
+zh duplicate: embeddingModel line 1518
+zh duplicate: pleaseSelect line 1519
+zh duplicate: pleaseSelectKnowledgeGroupFirst line 1520
+zh duplicate: selectUnassignGroupWarning line 1521
+zh duplicate: chunkConfig line 1522
+zh duplicate: chunkSize line 1523
+zh duplicate: min line 1524
+zh duplicate: max line 1525
+zh duplicate: chunkOverlap line 1526
+zh duplicate: modelLimitsInfo line 1527
+zh duplicate: model line 1528
+zh duplicate: maxChunkSize line 1529
+zh duplicate: maxOverlapSize line 1530
+zh duplicate: maxBatchSize line 1531
+zh duplicate: envLimitWeaker line 1532
+zh duplicate: optimizationTips line 1533
+zh duplicate: tipChunkTooLarge line 1534
+zh duplicate: tipOverlapSmall line 1535
+zh duplicate: tipMaxValues line 1536
+zh duplicate: tipPreciseCost line 1537
+zh duplicate: selectEmbeddingFirst line 1538
+zh duplicate: confirmPreciseCost line 1539
+zh duplicate: startProcessing line 1540
+zh duplicate: notebooks line 1543
+zh duplicate: notebooksDesc line 1544
+zh duplicate: createNotebook line 1545
+zh duplicate: chatWithNotebook line 1546
+zh duplicate: editNotebook line 1547
+zh duplicate: deleteNotebook line 1548
+zh duplicate: noDescription line 1549
+zh duplicate: hasIntro line 1550
+zh duplicate: noIntro line 1551
+zh duplicate: noNotebooks line 1552
+zh duplicate: createFailed line 1553
+zh duplicate: confirmDeleteNotebook line 1554
+zh duplicate: errorFileTooLarge line 1557
+zh duplicate: noFilesYet line 1558
+zh duplicate: createNotebookTitle line 1561
+zh duplicate: editNotebookTitle line 1562
+zh duplicate: createFailedRetry line 1563
+zh duplicate: updateFailedRetry line 1564
+zh duplicate: name line 1565
+zh duplicate: nameHelp line 1566
+zh duplicate: namePlaceholder line 1567
+zh duplicate: shortDescription line 1568
+zh duplicate: descPlaceholder line 1569
+zh duplicate: detailedIntro line 1570
+zh duplicate: introPlaceholder line 1571
+zh duplicate: introHelp line 1572
+zh duplicate: creating line 1573
+zh duplicate: createNow line 1574
+zh duplicate: saving line 1575
+zh duplicate: save line 1576
+zh duplicate: chatTitle line 1579
+zh duplicate: chatDesc line 1580
+zh duplicate: viewHistory line 1581
+zh duplicate: saveSettingsFailed line 1582
+zh duplicate: loginToUpload line 1583
+zh duplicate: fileSizeLimitExceeded line 1584
+zh duplicate: unsupportedFileType line 1585
+zh duplicate: readFailed line 1586
+zh duplicate: loadHistoryFailed line 1587
+zh duplicate: loadingUserData line 1588
+zh duplicate: errorMessage line 1589
+zh duplicate: welcomeMessage line 1590
+zh duplicate: selectKnowledgeGroup line 1591
+zh duplicate: allKnowledgeGroups line 1592
+zh duplicate: unknownGroup line 1593
+zh duplicate: selectedGroupsCount line 1594
+zh duplicate: generalSettings line 1597
+zh duplicate: modelManagement line 1598
+zh duplicate: languageSettings line 1599
+zh duplicate: passwordChangeSuccess line 1600
+zh duplicate: passwordChangeFailed line 1601
+zh duplicate: create line 1602
+zh duplicate: validationFailedMsg line 1603
+zh duplicate: navChat line 1607
+zh duplicate: navCoach line 1608
+zh duplicate: navKnowledge line 1609
+zh duplicate: navKnowledgeGroups line 1610
+zh duplicate: navCrawler line 1611
+zh duplicate: expandMenu line 1612
+zh duplicate: switchLanguage line 1613
+zh duplicate: selectKnowledgeGroups line 1616
+zh duplicate: searchGroupsPlaceholder line 1617
+zh duplicate: done line 1618
+zh duplicate: all line 1619
+zh duplicate: noGroupsFound line 1620
+zh duplicate: noGroups line 1621
+zh duplicate: autoRefresh line 1624
+zh duplicate: refreshInterval line 1625
+zh duplicate: errorRenderFlowchart line 1628
+zh duplicate: errorLoadData line 1629
+zh duplicate: confirmUnsupportedFile line 1630
+zh duplicate: errorReadFile line 1631
+zh duplicate: successUploadFile line 1632
+zh duplicate: errorUploadFile line 1633
+zh duplicate: errorProcessFile line 1634
+zh duplicate: errorTitleContentRequired line 1635
+zh duplicate: successNoteUpdated line 1636
+zh duplicate: successNoteCreated line 1637
+zh duplicate: errorSaveFailed line 1638
+zh duplicate: confirmDeleteNote line 1639
+zh duplicate: successNoteDeleted line 1640
+zh duplicate: confirmRemoveFileFromGroup line 1641
+zh duplicate: editNote line 1642
+zh duplicate: newNote line 1643
+zh duplicate: togglePreviewOpen line 1644
+zh duplicate: togglePreviewClose line 1645
+zh duplicate: noteTitlePlaceholder line 1646
+zh duplicate: noteContentPlaceholder line 1647
+zh duplicate: markdownPreviewArea line 1648
+zh duplicate: back line 1649
+zh duplicate: chatWithGroup line 1650
+zh duplicate: chatWithFile line 1651
+zh duplicate: filesCountLabel line 1652
+zh duplicate: notesCountLabel line 1653
+zh duplicate: indexIntoKB line 1654
+zh duplicate: noFilesOrNotes line 1655
+zh duplicate: importFolder line 1656
+zh duplicate: createPDFNote line 1659
+zh duplicate: screenshotPreview line 1660
+zh duplicate: associateKnowledgeGroup line 1661
+zh duplicate: globalNoSpecificGroup line 1662
+zh duplicate: title line 1663
+zh duplicate: enterNoteTitle line 1664
+zh duplicate: contentOCR line 1665
+zh duplicate: extractingText line 1666
+zh duplicate: analyzingImage line 1667
+zh duplicate: noTextExtracted line 1668
+zh duplicate: saveNote line 1669
+zh duplicate: page line 1672
+zh duplicate: placeholderText line 1673
+zh duplicate: createNewNotebook line 1676
+zh duplicate: nameField line 1677
+zh duplicate: required line 1678
+zh duplicate: exampleResearch line 1679
+zh duplicate: shortDescriptionField line 1680
+zh duplicate: describePurpose line 1681
+zh duplicate: detailedIntroField line 1682
+zh duplicate: provideBackgroundInfo line 1683
+zh duplicate: creationFailed line 1684
+zh duplicate: preparingPDFConversion line 1687
+zh duplicate: pleaseWait line 1688
+zh duplicate: convertingPDF line 1689
+zh duplicate: pdfConversionFailed line 1690
+zh duplicate: pdfConversionError line 1691
+zh duplicate: pdfLoadFailed line 1692
+zh duplicate: pdfLoadError line 1693
+zh duplicate: downloadingPDF line 1694
+zh duplicate: loadingPDF line 1695
+zh duplicate: zoomOut line 1696
+zh duplicate: zoomIn line 1697
+zh duplicate: resetZoom line 1698
+zh duplicate: selectPageNumber line 1699
+zh duplicate: enterPageNumber line 1700
+zh duplicate: exitSelectionMode line 1701
+zh duplicate: clickToSelectAndNote line 1702
+zh duplicate: regeneratePDF line 1703
+zh duplicate: downloadPDF line 1704
+zh duplicate: openInNewWindow line 1705
+zh duplicate: exitFullscreen line 1706
+zh duplicate: fullscreenDisplay line 1707
+zh duplicate: pdfPreview line 1708
+zh duplicate: converting line 1709
+zh duplicate: generatePDFPreview line 1710
+zh duplicate: previewNotSupported line 1711
+zh duplicate: confirmRegeneratePDF line 1714
+zh duplicate: pdfPreviewReady line 1717
+zh duplicate: convertingInProgress line 1718
+zh duplicate: conversionFailed line 1719
+zh duplicate: generatePDFPreviewButton line 1720
+zh duplicate: checkPDFStatusFailed line 1723
+zh duplicate: requestRegenerationFailed line 1724
+zh duplicate: downloadPDFFailed line 1725
+zh duplicate: openPDFInNewTabFailed line 1726
+zh duplicate: invalidFile line 1729
+zh duplicate: incompleteFileInfo line 1730
+zh duplicate: unsupportedFileFormat line 1731
+zh duplicate: willUseFastMode line 1732
+zh duplicate: formatNoPrecise line 1733
+zh duplicate: smallFileFastOk line 1734
+zh duplicate: mixedContentPreciseRecommended line 1735
+zh duplicate: willIncurApiCost line 1736
+zh duplicate: largeFilePreciseRecommended line 1737
+zh duplicate: longProcessingTime line 1738
+zh duplicate: highApiCost line 1739
+zh duplicate: considerFileSplitting line 1740
+zh duplicate: dragDropUploadTitle line 1743
+zh duplicate: dragDropUploadDesc line 1744
+zh duplicate: supportedFormats line 1745
+zh duplicate: browseFiles line 1746
+zh duplicate: recommendationMsg line 1749
+zh duplicate: autoAdjustChunk line 1750
+zh duplicate: autoAdjustOverlap line 1751
+zh duplicate: autoAdjustOverlapMin line 1752
+zh duplicate: loadLimitsFailed line 1753
+zh duplicate: maxValueMsg line 1754
+zh duplicate: overlapRatioLimit line 1755
+zh duplicate: onlyAdminCanModify line 1756
+zh duplicate: dragToSelect line 1757
+zh duplicate: fillTargetName line 1760
+zh duplicate: submitFailed line 1761
+zh duplicate: importFolderTitle line 1762
+zh duplicate: importFolderTip line 1763
+zh duplicate: lblTargetGroup line 1764
+zh duplicate: placeholderNewGroup line 1765
+zh duplicate: importToCurrentGroup line 1766
+zh duplicate: nextStep line 1767
+zh duplicate: lblImportSource line 1768
+zh duplicate: serverPath line 1769
+zh duplicate: localFolder line 1770
+zh duplicate: selectedFilesCount line 1771
+zh duplicate: clickToSelectFolder line 1772
+zh duplicate: selectFolderTip line 1773
+zh duplicate: importComplete line 1774
+zh duplicate: importedFromLocalFolder line 1775
+zh duplicate: historyTitle line 1778
+zh duplicate: confirmDeleteHistory line 1779
+zh duplicate: deleteHistorySuccess line 1780
+zh duplicate: deleteHistoryFailed line 1781
+zh duplicate: yesterday line 1782
+zh duplicate: daysAgo line 1783
+zh duplicate: historyMessages line 1784
+zh duplicate: noHistory line 1785
+zh duplicate: noHistoryDesc line 1786
+zh duplicate: loadMore line 1787
+zh duplicate: loadingHistoriesFailed line 1788
+zh duplicate: supportedFormatsInfo line 1789
+en duplicate: aiCommandsError line 1203
+en duplicate: appTitle line 1204
+en duplicate: loginTitle line 1205
+en duplicate: loginDesc line 1206
+en duplicate: loginButton line 1207
+en duplicate: loginError line 1208
+en duplicate: unknownError line 1209
+en duplicate: usernamePlaceholder line 1210
+en duplicate: passwordPlaceholder line 1211
+en duplicate: registerButton line 1212
+en duplicate: langZh line 1213
+en duplicate: langEn line 1214
+en duplicate: langJa line 1215
+en duplicate: confirm line 1216
+en duplicate: cancel line 1217
+en duplicate: confirmTitle line 1218
+en duplicate: confirmDeleteGroup line 1219
+en duplicate: sidebarTitle line 1221
+en duplicate: backToWorkspace line 1222
+en duplicate: goToAdmin line 1223
+en duplicate: sidebarDesc line 1224
+en duplicate: tabFiles line 1225
+en duplicate: files line 1226
+en duplicate: notes line 1227
+en duplicate: tabSettings line 1228
+en duplicate: systemConfiguration line 1229
+en duplicate: noFiles line 1230
+en duplicate: noFilesDesc line 1231
+en duplicate: addFile line 1232
+en duplicate: clearAll line 1233
+en duplicate: uploading line 1234
+en duplicate: statusIndexing line 1235
+en duplicate: statusReady line 1236
+en duplicate: ragSettings line 1239
+en duplicate: enableRerank line 1240
+en duplicate: enableRerankDesc line 1241
+en duplicate: selectRerankModel line 1242
+en duplicate: selectModelPlaceholder line 1243
+en duplicate: headerModelSelection line 1245
+en duplicate: headerHyperparams line 1246
+en duplicate: headerIndexing line 1247
+en duplicate: headerRetrieval line 1248
+en duplicate: btnManageModels line 1249
+en duplicate: lblLLM line 1251
+en duplicate: lblEmbedding line 1252
+en duplicate: lblRerankRef line 1253
+en duplicate: lblTemperature line 1254
+en duplicate: lblMaxTokens line 1255
+en duplicate: lblChunkSize line 1256
+en duplicate: lblChunkOverlap line 1257
+en duplicate: lblTopK line 1258
+en duplicate: lblRerank line 1259
+en duplicate: idxModalTitle line 1261
+en duplicate: idxDesc line 1262
+en duplicate: idxFiles line 1263
+en duplicate: idxMethod line 1264
+en duplicate: idxEmbeddingModel line 1265
+en duplicate: idxStart line 1266
+en duplicate: idxCancel line 1267
+en duplicate: idxAuto line 1268
+en duplicate: idxCustom line 1269
+en duplicate: mmTitle line 1271
+en duplicate: mmAddBtn line 1272
+en duplicate: mmEdit line 1273
+en duplicate: mmDelete line 1274
+en duplicate: mmEmpty line 1275
+en duplicate: mmFormName line 1276
+en duplicate: mmFormProvider line 1277
+en duplicate: mmFormModelId line 1278
+en duplicate: mmFormBaseUrl line 1279
+en duplicate: mmFormType line 1280
+en duplicate: mmFormVision line 1281
+en duplicate: mmFormDimensions line 1282
+en duplicate: mmFormDimensionsHelp line 1283
+en duplicate: mmSave line 1284
+en duplicate: mmCancel line 1285
+en duplicate: mmErrorNotAuthenticated line 1286
+en duplicate: mmErrorTitle line 1287
+en duplicate: modelEnabled line 1288
+en duplicate: modelDisabled line 1289
+en duplicate: confirmChangeEmbeddingModel line 1290
+en duplicate: embeddingModelWarning line 1291
+en duplicate: sourcePreview line 1292
+en duplicate: matchScore line 1293
+en duplicate: copyContent line 1294
+en duplicate: copySuccess line 1295
+en duplicate: selectLLMModel line 1298
+en duplicate: selectEmbeddingModel line 1299
+en duplicate: defaultForUploads line 1300
+en duplicate: noRerankModel line 1301
+en duplicate: vectorSimilarityThreshold line 1302
+en duplicate: rerankSimilarityThreshold line 1303
+en duplicate: filterLowResults line 1304
+en duplicate: noteCreatedSuccess line 1305
+en duplicate: noteCreatedFailed line 1306
+en duplicate: fullTextSearch line 1307
+en duplicate: hybridVectorWeight line 1308
+en duplicate: hybridVectorWeightDesc line 1309
+en duplicate: lblQueryExpansion line 1310
+en duplicate: lblHyDE line 1311
+en duplicate: lblQueryExpansionDesc line 1312
+en duplicate: lblHyDEDesc line 1313
+en duplicate: apiKeyValidationFailed line 1315
+en duplicate: keepOriginalKey line 1316
+en duplicate: leaveEmptyNoChange line 1317
+en duplicate: mmFormApiKey line 1318
+en duplicate: mmFormApiKeyPlaceholder line 1319
+en duplicate: reconfigureFile line 1322
+en duplicate: modifySettings line 1323
+en duplicate: filesCount line 1324
+en duplicate: allFilesIndexed line 1325
+en duplicate: noEmbeddingModels line 1326
+en duplicate: reconfigure line 1327
+en duplicate: refresh line 1328
+en duplicate: settings line 1329
+en duplicate: needLogin line 1330
+en duplicate: citationSources line 1331
+en duplicate: chunkNumber line 1332
+en duplicate: getUserListFailed line 1333
+en duplicate: usernamePasswordRequired line 1334
+en duplicate: passwordMinLength line 1335
+en duplicate: userCreatedSuccess line 1336
+en duplicate: createUserFailed line 1337
+en duplicate: userPromotedToAdmin line 1338
+en duplicate: userDemotedFromAdmin line 1339
+en duplicate: updateUserFailed line 1340
+en duplicate: confirmDeleteUser line 1341
+en duplicate: deleteUser line 1342
+en duplicate: deleteUserFailed line 1343
+en duplicate: userDeletedSuccessfully line 1344
+en duplicate: makeUserAdmin line 1345
+en duplicate: makeUserRegular line 1346
+en duplicate: loading line 1347
+en duplicate: noUsers line 1348
+en duplicate: aiAssistant line 1351
+en duplicate: polishContent line 1352
+en duplicate: expandContent line 1353
+en duplicate: summarizeContent line 1354
+en duplicate: translateToEnglish line 1355
+en duplicate: fixGrammar line 1356
+en duplicate: aiCommandInstructPolish line 1357
+en duplicate: aiCommandInstructExpand line 1358
+en duplicate: aiCommandInstructSummarize line 1359
+en duplicate: aiCommandInstructTranslateToEn line 1360
+en duplicate: aiCommandInstructFixGrammar line 1361
+en duplicate: aiCommandsPreset line 1362
+en duplicate: aiCommandsCustom line 1363
+en duplicate: aiCommandsCustomPlaceholder line 1364
+en duplicate: aiCommandsReferenceContext line 1365
+en duplicate: aiCommandsStartGeneration line 1366
+en duplicate: aiCommandsResult line 1367
+en duplicate: aiCommandsGenerating line 1368
+en duplicate: aiCommandsApplyResult line 1369
+en duplicate: aiCommandsGoBack line 1370
+en duplicate: aiCommandsReset line 1371
+en duplicate: aiCommandsModalPreset line 1372
+en duplicate: aiCommandsModalCustom line 1373
+en duplicate: aiCommandsModalCustomPlaceholder line 1374
+en duplicate: aiCommandsModalBasedOnSelection line 1375
+en duplicate: aiCommandsModalResult line 1376
+en duplicate: aiCommandsModalApply line 1377
+en duplicate: fillAllFields line 1380
+en duplicate: passwordMismatch line 1381
+en duplicate: newPasswordMinLength line 1382
+en duplicate: changePasswordFailed line 1383
+en duplicate: changePasswordTitle line 1384
+en duplicate: changing line 1385
+en duplicate: searchResults line 1386
+en duplicate: visionModelSettings line 1389
+en duplicate: defaultVisionModel line 1390
+en duplicate: loadVisionModelFailed line 1391
+en duplicate: loadFailed line 1392
+en duplicate: saveVisionModelFailed line 1393
+en duplicate: noVisionModels line 1394
+en duplicate: selectVisionModel line 1395
+en duplicate: visionModelHelp line 1396
+en duplicate: mmErrorNameRequired line 1397
+en duplicate: mmErrorModelIdRequired line 1398
+en duplicate: mmErrorBaseUrlRequired line 1399
+en duplicate: mmRequiredAsterisk line 1400
+en duplicate: typeLLM line 1402
+en duplicate: typeEmbedding line 1403
+en duplicate: typeRerank line 1404
+en duplicate: typeVision line 1405
+en duplicate: welcome line 1407
+en duplicate: placeholderWithFiles line 1408
+en duplicate: placeholderEmpty line 1409
+en duplicate: analyzing line 1410
+en duplicate: errorGeneric line 1411
+en duplicate: errorLabel line 1412
+en duplicate: errorNoModel line 1413
+en duplicate: aiDisclaimer line 1414
+en duplicate: confirmClear line 1415
+en duplicate: removeFile line 1416
+en duplicate: apiError line 1417
+en duplicate: geminiError line 1418
+en duplicate: processedButNoText line 1419
+en duplicate: unitByte line 1420
+en duplicate: readingFailed line 1421
+en duplicate: copy line 1423
+en duplicate: copied line 1424
+en duplicate: logout line 1427
+en duplicate: changePassword line 1428
+en duplicate: userManagement line 1429
+en duplicate: userList line 1430
+en duplicate: addUser line 1431
+en duplicate: username line 1432
+en duplicate: password line 1433
+en duplicate: confirmPassword line 1434
+en duplicate: currentPassword line 1435
+en duplicate: newPassword line 1436
+en duplicate: createUser line 1437
+en duplicate: admin line 1438
+en duplicate: user line 1439
+en duplicate: adminUser line 1440
+en duplicate: confirmChange line 1441
+en duplicate: changeUserPassword line 1442
+en duplicate: enterNewPassword line 1443
+en duplicate: createdAt line 1444
+en duplicate: newChat line 1445
+en duplicate: kbManagement line 1448
+en duplicate: kbManagementDesc line 1449
+en duplicate: searchPlaceholder line 1450
+en duplicate: allGroups line 1451
+en duplicate: allStatus line 1452
+en duplicate: statusReadyFragment line 1453
+en duplicate: statusFailedFragment line 1454
+en duplicate: statusIndexingFragment line 1455
+en duplicate: uploadFile line 1456
+en duplicate: fileName line 1457
+en duplicate: size line 1458
+en duplicate: status line 1459
+en duplicate: groups line 1460
+en duplicate: actions line 1461
+en duplicate: groupsActions line 1462
+en duplicate: noFilesFound line 1463
+en duplicate: showingRange line 1464
+en duplicate: confirmDeleteFile line 1465
+en duplicate: fileDeleted line 1466
+en duplicate: deleteFailed line 1467
+en duplicate: fileAddedToGroup line 1468
+en duplicate: failedToAddToGroup line 1469
+en duplicate: fileRemovedFromGroup line 1470
+en duplicate: failedToRemoveFromGroup line 1471
+en duplicate: confirmClearKB line 1472
+en duplicate: kbCleared line 1473
+en duplicate: clearFailed line 1474
+en duplicate: loginRequired line 1475
+en duplicate: uploadErrors line 1476
+en duplicate: uploadWarning line 1477
+en duplicate: uploadFailed line 1478
+en duplicate: preview line 1479
+en duplicate: addGroup line 1480
+en duplicate: delete line 1481
+en duplicate: retry line 1482
+en duplicate: retrying line 1483
+en duplicate: retrySuccess line 1484
+en duplicate: retryFailed line 1485
+en duplicate: chunkInfo line 1486
+en duplicate: totalChunks line 1487
+en duplicate: chunkIndex line 1488
+en duplicate: contentLength line 1489
+en duplicate: position line 1490
+en duplicate: reconfigureTitle line 1493
+en duplicate: reconfigureDesc line 1494
+en duplicate: indexingConfigTitle line 1495
+en duplicate: indexingConfigDesc line 1496
+en duplicate: pendingFiles line 1497
+en duplicate: processingMode line 1498
+en duplicate: analyzingFile line 1499
+en duplicate: recommendationReason line 1500
+en duplicate: fastMode line 1501
+en duplicate: fastModeDesc line 1502
+en duplicate: preciseMode line 1503
+en duplicate: preciseModeDesc line 1504
+en duplicate: fastModeFeatures line 1505
+en duplicate: fastFeature1 line 1506
+en duplicate: fastFeature2 line 1507
+en duplicate: fastFeature3 line 1508
+en duplicate: fastFeature4 line 1509
+en duplicate: fastFeature5 line 1510
+en duplicate: preciseModeFeatures line 1511
+en duplicate: preciseFeature1 line 1512
+en duplicate: preciseFeature2 line 1513
+en duplicate: preciseFeature3 line 1514
+en duplicate: preciseFeature4 line 1515
+en duplicate: preciseFeature5 line 1516
+en duplicate: preciseFeature6 line 1517
+en duplicate: embeddingModel line 1518
+en duplicate: pleaseSelect line 1519
+en duplicate: pleaseSelectKnowledgeGroupFirst line 1520
+en duplicate: selectUnassignGroupWarning line 1521
+en duplicate: chunkConfig line 1522
+en duplicate: chunkSize line 1523
+en duplicate: min line 1524
+en duplicate: max line 1525
+en duplicate: chunkOverlap line 1526
+en duplicate: modelLimitsInfo line 1527
+en duplicate: model line 1528
+en duplicate: maxChunkSize line 1529
+en duplicate: maxOverlapSize line 1530
+en duplicate: maxBatchSize line 1531
+en duplicate: envLimitWeaker line 1532
+en duplicate: optimizationTips line 1533
+en duplicate: tipChunkTooLarge line 1534
+en duplicate: tipOverlapSmall line 1535
+en duplicate: tipMaxValues line 1536
+en duplicate: tipPreciseCost line 1537
+en duplicate: selectEmbeddingFirst line 1538
+en duplicate: confirmPreciseCost line 1539
+en duplicate: startProcessing line 1540
+en duplicate: notebooks line 1543
+en duplicate: notebooksDesc line 1544
+en duplicate: createNotebook line 1545
+en duplicate: chatWithNotebook line 1546
+en duplicate: editNotebook line 1547
+en duplicate: deleteNotebook line 1548
+en duplicate: noDescription line 1549
+en duplicate: hasIntro line 1550
+en duplicate: noIntro line 1551
+en duplicate: noNotebooks line 1552
+en duplicate: createFailed line 1553
+en duplicate: confirmDeleteNotebook line 1554
+en duplicate: errorFileTooLarge line 1557
+en duplicate: noFilesYet line 1558
+en duplicate: createNotebookTitle line 1561
+en duplicate: editNotebookTitle line 1562
+en duplicate: createFailedRetry line 1563
+en duplicate: updateFailedRetry line 1564
+en duplicate: name line 1565
+en duplicate: nameHelp line 1566
+en duplicate: namePlaceholder line 1567
+en duplicate: shortDescription line 1568
+en duplicate: descPlaceholder line 1569
+en duplicate: detailedIntro line 1570
+en duplicate: introPlaceholder line 1571
+en duplicate: introHelp line 1572
+en duplicate: creating line 1573
+en duplicate: createNow line 1574
+en duplicate: saving line 1575
+en duplicate: save line 1576
+en duplicate: chatTitle line 1579
+en duplicate: chatDesc line 1580
+en duplicate: viewHistory line 1581
+en duplicate: saveSettingsFailed line 1582
+en duplicate: loginToUpload line 1583
+en duplicate: fileSizeLimitExceeded line 1584
+en duplicate: unsupportedFileType line 1585
+en duplicate: readFailed line 1586
+en duplicate: loadHistoryFailed line 1587
+en duplicate: loadingUserData line 1588
+en duplicate: errorMessage line 1589
+en duplicate: welcomeMessage line 1590
+en duplicate: selectKnowledgeGroup line 1591
+en duplicate: allKnowledgeGroups line 1592
+en duplicate: unknownGroup line 1593
+en duplicate: selectedGroupsCount line 1594
+en duplicate: generalSettings line 1597
+en duplicate: modelManagement line 1598
+en duplicate: languageSettings line 1599
+en duplicate: passwordChangeSuccess line 1600
+en duplicate: passwordChangeFailed line 1601
+en duplicate: create line 1602
+en duplicate: validationFailedMsg line 1603
+en duplicate: navChat line 1607
+en duplicate: navCoach line 1608
+en duplicate: navKnowledge line 1609
+en duplicate: navKnowledgeGroups line 1610
+en duplicate: navCrawler line 1611
+en duplicate: expandMenu line 1612
+en duplicate: switchLanguage line 1613
+en duplicate: selectKnowledgeGroups line 1616
+en duplicate: searchGroupsPlaceholder line 1617
+en duplicate: done line 1618
+en duplicate: all line 1619
+en duplicate: noGroupsFound line 1620
+en duplicate: noGroups line 1621
+en duplicate: autoRefresh line 1624
+en duplicate: refreshInterval line 1625
+en duplicate: errorRenderFlowchart line 1628
+en duplicate: errorLoadData line 1629
+en duplicate: confirmUnsupportedFile line 1630
+en duplicate: errorReadFile line 1631
+en duplicate: successUploadFile line 1632
+en duplicate: errorUploadFile line 1633
+en duplicate: errorProcessFile line 1634
+en duplicate: errorTitleContentRequired line 1635
+en duplicate: successNoteUpdated line 1636
+en duplicate: successNoteCreated line 1637
+en duplicate: errorSaveFailed line 1638
+en duplicate: confirmDeleteNote line 1639
+en duplicate: successNoteDeleted line 1640
+en duplicate: confirmRemoveFileFromGroup line 1641
+en duplicate: editNote line 1642
+en duplicate: newNote line 1643
+en duplicate: togglePreviewOpen line 1644
+en duplicate: togglePreviewClose line 1645
+en duplicate: noteTitlePlaceholder line 1646
+en duplicate: noteContentPlaceholder line 1647
+en duplicate: markdownPreviewArea line 1648
+en duplicate: back line 1649
+en duplicate: chatWithGroup line 1650
+en duplicate: chatWithFile line 1651
+en duplicate: filesCountLabel line 1652
+en duplicate: notesCountLabel line 1653
+en duplicate: indexIntoKB line 1654
+en duplicate: noFilesOrNotes line 1655
+en duplicate: importFolder line 1656
+en duplicate: createPDFNote line 1659
+en duplicate: screenshotPreview line 1660
+en duplicate: associateKnowledgeGroup line 1661
+en duplicate: globalNoSpecificGroup line 1662
+en duplicate: title line 1663
+en duplicate: enterNoteTitle line 1664
+en duplicate: contentOCR line 1665
+en duplicate: extractingText line 1666
+en duplicate: analyzingImage line 1667
+en duplicate: noTextExtracted line 1668
+en duplicate: saveNote line 1669
+en duplicate: page line 1672
+en duplicate: placeholderText line 1673
+en duplicate: createNewNotebook line 1676
+en duplicate: nameField line 1677
+en duplicate: required line 1678
+en duplicate: exampleResearch line 1679
+en duplicate: shortDescriptionField line 1680
+en duplicate: describePurpose line 1681
+en duplicate: detailedIntroField line 1682
+en duplicate: provideBackgroundInfo line 1683
+en duplicate: creationFailed line 1684
+en duplicate: preparingPDFConversion line 1687
+en duplicate: pleaseWait line 1688
+en duplicate: convertingPDF line 1689
+en duplicate: pdfConversionFailed line 1690
+en duplicate: pdfConversionError line 1691
+en duplicate: pdfLoadFailed line 1692
+en duplicate: pdfLoadError line 1693
+en duplicate: downloadingPDF line 1694
+en duplicate: loadingPDF line 1695
+en duplicate: zoomOut line 1696
+en duplicate: zoomIn line 1697
+en duplicate: resetZoom line 1698
+en duplicate: selectPageNumber line 1699
+en duplicate: enterPageNumber line 1700
+en duplicate: exitSelectionMode line 1701
+en duplicate: clickToSelectAndNote line 1702
+en duplicate: regeneratePDF line 1703
+en duplicate: downloadPDF line 1704
+en duplicate: openInNewWindow line 1705
+en duplicate: exitFullscreen line 1706
+en duplicate: fullscreenDisplay line 1707
+en duplicate: pdfPreview line 1708
+en duplicate: converting line 1709
+en duplicate: generatePDFPreview line 1710
+en duplicate: previewNotSupported line 1711
+en duplicate: confirmRegeneratePDF line 1714
+en duplicate: pdfPreviewReady line 1717
+en duplicate: convertingInProgress line 1718
+en duplicate: conversionFailed line 1719
+en duplicate: generatePDFPreviewButton line 1720
+en duplicate: checkPDFStatusFailed line 1723
+en duplicate: requestRegenerationFailed line 1724
+en duplicate: downloadPDFFailed line 1725
+en duplicate: openPDFInNewTabFailed line 1726
+en duplicate: invalidFile line 1729
+en duplicate: incompleteFileInfo line 1730
+en duplicate: unsupportedFileFormat line 1731
+en duplicate: willUseFastMode line 1732
+en duplicate: formatNoPrecise line 1733
+en duplicate: smallFileFastOk line 1734
+en duplicate: mixedContentPreciseRecommended line 1735
+en duplicate: willIncurApiCost line 1736
+en duplicate: largeFilePreciseRecommended line 1737
+en duplicate: longProcessingTime line 1738
+en duplicate: highApiCost line 1739
+en duplicate: considerFileSplitting line 1740
+en duplicate: dragDropUploadTitle line 1743
+en duplicate: dragDropUploadDesc line 1744
+en duplicate: supportedFormats line 1745
+en duplicate: browseFiles line 1746
+en duplicate: recommendationMsg line 1749
+en duplicate: autoAdjustChunk line 1750
+en duplicate: autoAdjustOverlap line 1751
+en duplicate: autoAdjustOverlapMin line 1752
+en duplicate: loadLimitsFailed line 1753
+en duplicate: maxValueMsg line 1754
+en duplicate: overlapRatioLimit line 1755
+en duplicate: onlyAdminCanModify line 1756
+en duplicate: dragToSelect line 1757
+en duplicate: fillTargetName line 1760
+en duplicate: submitFailed line 1761
+en duplicate: importFolderTitle line 1762
+en duplicate: importFolderTip line 1763
+en duplicate: lblTargetGroup line 1764
+en duplicate: placeholderNewGroup line 1765
+en duplicate: importToCurrentGroup line 1766
+en duplicate: nextStep line 1767
+en duplicate: lblImportSource line 1768
+en duplicate: serverPath line 1769
+en duplicate: localFolder line 1770
+en duplicate: selectedFilesCount line 1771
+en duplicate: clickToSelectFolder line 1772
+en duplicate: selectFolderTip line 1773
+en duplicate: importComplete line 1774
+en duplicate: importedFromLocalFolder line 1775
+en duplicate: historyTitle line 1778
+en duplicate: confirmDeleteHistory line 1779
+en duplicate: deleteHistorySuccess line 1780
+en duplicate: deleteHistoryFailed line 1781
+en duplicate: yesterday line 1782
+en duplicate: daysAgo line 1783
+en duplicate: historyMessages line 1784
+en duplicate: noHistory line 1785
+en duplicate: noHistoryDesc line 1786
+en duplicate: loadMore line 1787
+en duplicate: loadingHistoriesFailed line 1788
+en duplicate: supportedFormatsInfo line 1789

File diff suppressed because it is too large
+ 313 - 149
package-lock.json


+ 12 - 0
server/check_schema.js

@@ -0,0 +1,12 @@
+const sqlite3 = require('better-sqlite3');
+const db = new sqlite3('./data/metadata.db');
+
+const tableInfo = db.prepare("PRAGMA table_info(model_configs)").all();
+console.log("Table info for model_configs:");
+console.log(JSON.stringify(tableInfo, null, 2));
+
+const sample = db.prepare("SELECT * FROM model_configs LIMIT 5").all();
+console.log("Sample data:");
+console.log(JSON.stringify(sample, null, 2));
+
+db.close();

+ 47 - 0
server/debug_es.js

@@ -0,0 +1,47 @@
+const { Client } = require('@elastic/elasticsearch');
+
+async function run() {
+    const client = new Client({
+        node: 'http://127.0.0.1:9200',
+    });
+
+    try {
+        const indexName = 'knowledge_base';
+
+        console.log(`\n--- Total Documents ---`);
+        const count = await client.count({ index: indexName });
+        console.log(count);
+
+        console.log(`\n--- Document Distribution by tenantId ---`);
+        const distribution = await client.search({
+            index: indexName,
+            size: 0,
+            aggs: {
+                by_tenant: {
+                    terms: { field: 'tenantId', size: 100, missing: 'N/A' }
+                }
+            }
+        });
+        console.log(JSON.stringify(distribution.aggregations.by_tenant.buckets, null, 2));
+
+        console.log(`\n--- Sample Documents (last 5) ---`);
+        const samples = await client.search({
+            index: indexName,
+            size: 5,
+            sort: [{ createdAt: 'desc' }],
+        });
+        console.log(JSON.stringify(samples.hits.hits.map(h => ({
+            id: h._id,
+            tenantId: h._source.tenantId,
+            fileName: h._source.fileName,
+            vectorLength: h._source.vector?.length,
+            vectorPreview: h._source.vector?.slice(0, 5),
+            contentPreview: h._source.content?.substring(0, 50)
+        })), null, 2));
+
+    } catch (error) {
+        console.error('Error:', error.meta?.body || error.message);
+    }
+}
+
+run();

+ 2 - 1
server/package.json

@@ -55,7 +55,8 @@
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.1",
     "tesseract.js": "^7.0.0",
-    "typeorm": "0.3.26"
+    "typeorm": "0.3.26",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@eslint/eslintrc": "^3.2.0",

+ 38 - 4
server/src/admin/admin.controller.ts

@@ -1,4 +1,6 @@
-import { Controller, Get, Post, Put, Body, UseGuards, Request } from '@nestjs/common';
+import { Controller, Get, Post, Put, Body, UseGuards, Request, Query, UseInterceptors, UploadedFile, Res } from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { Response } from 'express';
 import { AdminService } from './admin.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
@@ -7,13 +9,43 @@ import { UserRole } from '../user/user-role.enum';
 
 @Controller('v1/admin')
 @UseGuards(CombinedAuthGuard, RolesGuard)
-@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
 export class AdminController {
     constructor(private readonly adminService: AdminService) { }
 
     @Get('users')
-    async getUsers(@Request() req: any) {
-        return this.adminService.getTenantUsers(req.user.tenantId);
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+    async getUsers(
+        @Request() req: any,
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
+        return this.adminService.getTenantUsers(
+            isSuperAdmin ? undefined : req.user.tenantId,
+            page ? parseInt(page) : undefined,
+            limit ? parseInt(limit) : undefined
+        );
+    }
+
+    @Get('users/export')
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+    async getUsersExport(@Request() req: any, @Res() res: Response) {
+        const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
+        const buffer = await this.adminService.exportUsers(isSuperAdmin ? undefined : req.user.tenantId);
+        res.set({
+            'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'Content-Disposition': 'attachment; filename="users_export.xlsx"',
+            'Content-Length': buffer.length,
+        });
+        res.end(buffer);
+    }
+
+    @Post('users/import')
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+    @UseInterceptors(FileInterceptor('file'))
+    async importUsers(@Request() req: any, @UploadedFile() file: any) {
+        const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
+        return this.adminService.importUsers(isSuperAdmin ? undefined : req.user.tenantId, file);
     }
 
     @Get('settings')
@@ -22,11 +54,13 @@ export class AdminController {
     }
 
     @Put('settings')
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
     async updateSettings(@Request() req: any, @Body() body: any) {
         return this.adminService.updateTenantSettings(req.user.tenantId, body);
     }
 
     @Get('pending-shares')
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
     async getPendingShares(@Request() req: any) {
         return this.adminService.getPendingShares(req.user.tenantId);
     }

+ 84 - 3
server/src/admin/admin.service.ts

@@ -1,16 +1,97 @@
-import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
+import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
+import * as XLSX from 'xlsx';
 import { UserService } from '../user/user.service';
 import { TenantService } from '../tenant/tenant.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class AdminService {
     constructor(
         private readonly userService: UserService,
         private readonly tenantService: TenantService,
+        private readonly i18nService: I18nService,
     ) { }
 
-    async getTenantUsers(tenantId: string) {
-        return this.userService.findByTenantId(tenantId);
+    async getTenantUsers(tenantId?: string, page?: number, limit?: number) {
+        if (!tenantId) {
+            return this.userService.findAll(page, limit);
+        }
+        return this.userService.findByTenantId(tenantId, page, limit);
+    }
+
+    async exportUsers(tenantId?: string): Promise<Buffer> {
+        const { data: users } = tenantId 
+            ? await this.userService.findByTenantId(tenantId)
+            : await this.userService.findAll();
+        
+        const worksheet = XLSX.utils.json_to_sheet(users.map(u => ({
+            Username: u.username,
+            DisplayName: u.displayName || '',
+            IsAdmin: u.isAdmin ? 'Yes' : 'No',
+            CreatedAt: u.createdAt,
+            Password: '', // Placeholder for new users
+        })));
+
+        const workbook = XLSX.utils.book_new();
+        XLSX.utils.book_append_sheet(workbook, worksheet, 'Users');
+
+        return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
+    }
+
+    async importUsers(tenantId?: string, file?: any) {
+        if (!file) throw new BadRequestException(this.i18nService.getMessage('uploadNoFile'));
+
+        const workbook = XLSX.read(file.buffer, { type: 'buffer' });
+        const sheetName = workbook.SheetNames[0];
+        const worksheet = workbook.Sheets[sheetName];
+        const data = XLSX.utils.sheet_to_json(worksheet) as any[];
+
+        const results = {
+            success: 0,
+            failed: 0,
+            errors: [] as string[],
+        };
+
+        for (const row of data) {
+            try {
+                const username = (row.Username || row.username)?.toString();
+                const displayName = (row.DisplayName || row.displayName || row.Name || row.name)?.toString();
+                const password = (row.Password || row.password)?.toString();
+                const isAdminStr = (row.IsAdmin || row.isAdmin || 'No').toString();
+                const isAdmin = isAdminStr.toLowerCase() === 'yes' || isAdminStr === 'true' || isAdminStr === '1';
+
+                if (!username) {
+                    throw new Error(this.i18nService.getMessage('usernameRequired'));
+                }
+
+                const existingUser = await this.userService.findOneByUsername(username);
+
+                if (existingUser) {
+                    await this.userService.updateUser(existingUser.id, {
+                        displayName: displayName || existingUser.displayName,
+                        password: password || undefined,
+                        // We avoid changing isAdmin status via import for security unless explicitly required
+                    });
+                } else {
+                    if (!password) {
+                        throw new Error(this.i18nService.formatMessage('passwordRequiredForNewUser', { username }));
+                    }
+                    await this.userService.createUser(
+                        username,
+                        password,
+                        isAdmin,
+                        tenantId,
+                        displayName
+                    );
+                }
+                results.success++;
+            } catch (e: any) {
+                results.failed++;
+                results.errors.push(`${row.Username || 'Unknown'}: ${e.message}`);
+            }
+        }
+
+        return results;
     }
 
     async getTenantSettings(tenantId: string) {

+ 36 - 31
server/src/api/api-v1.controller.ts

@@ -18,7 +18,9 @@ import { RagService } from '../rag/rag.service';
 import { ChatService } from '../chat/chat.service';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
 import { ModelConfigService } from '../model-config/model-config.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
+import { UserSettingService } from '../user/user-setting.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Controller('v1')
 @UseGuards(ApiKeyGuard)
@@ -28,7 +30,9 @@ export class ApiV1Controller {
         private readonly chatService: ChatService,
         private readonly knowledgeBaseService: KnowledgeBaseService,
         private readonly modelConfigService: ModelConfigService,
+        private readonly tenantService: TenantService,
         private readonly userSettingService: UserSettingService,
+        private readonly i18nService: I18nService,
     ) { }
 
     // ========== Chat / RAG ==========
@@ -56,10 +60,11 @@ export class ApiV1Controller {
             return res.status(400).json({ error: 'message is required' });
         }
 
-        // Get user settings and model configuration
-        const userSetting = await this.userSettingService.findOrCreate(user.id);
+        // Get organization settings and model configuration
+        const tenantSettings = await this.tenantService.getSettings(user.tenantId);
+        const userSetting = await this.userSettingService.getByUser(user.id);
         const models = await this.modelConfigService.findAll(user.id, user.tenantId);
-        const llmModel = models.find((m) => m.id === userSetting?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isEnabled);
+        const llmModel = models.find((m) => m.id === tenantSettings?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
 
         if (!llmModel) {
             return res.status(400).json({ error: 'No LLM model configured for this user' });
@@ -79,19 +84,19 @@ export class ApiV1Controller {
                     user.id,
                     modelConfig,
                     userSetting?.language ?? 'zh',            // userLanguage
-                    userSetting?.selectedEmbeddingId,          // selectedEmbeddingId
+                    tenantSettings?.selectedEmbeddingId,      // selectedEmbeddingId
                     selectedGroups,                           // selectedGroups
                     selectedFiles,                            // selectedFiles
                     undefined,                                // historyId
-                    userSetting?.enableRerank ?? false,        // enableRerank
-                    userSetting?.selectedRerankId,             // selectedRerankId
-                    userSetting?.temperature,                  // temperature
-                    userSetting?.maxTokens,                    // maxTokens
-                    userSetting?.topK ?? 5,                    // topK
-                    userSetting?.similarityThreshold ?? 0.3,   // similarityThreshold
-                    userSetting?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
-                    userSetting?.enableQueryExpansion ?? false, // enableQueryExpansion
-                    userSetting?.enableHyDE ?? false,           // enableHyDE
+                    tenantSettings?.enableRerank ?? false,    // enableRerank
+                    tenantSettings?.selectedRerankId,         // selectedRerankId
+                    tenantSettings?.temperature,              // temperature
+                    tenantSettings?.maxTokens,                // maxTokens
+                    tenantSettings?.topK ?? 5,                // topK
+                    tenantSettings?.similarityThreshold ?? 0.3,   // similarityThreshold
+                    tenantSettings?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
+                    tenantSettings?.enableQueryExpansion ?? false, // enableQueryExpansion
+                    tenantSettings?.enableHyDE ?? false,           // enableHyDE
                     user.tenantId,                             // Passing tenantId correctly
                 );
 
@@ -117,19 +122,19 @@ export class ApiV1Controller {
                     user.id,
                     modelConfig,
                     userSetting?.language ?? 'zh',
-                    userSetting?.selectedEmbeddingId,
+                    tenantSettings?.selectedEmbeddingId,
                     selectedGroups,
                     selectedFiles,
                     undefined,                                // historyId
-                    userSetting?.enableRerank ?? false,
-                    userSetting?.selectedRerankId,
-                    userSetting?.temperature,
-                    userSetting?.maxTokens,
-                    userSetting?.topK ?? 5,
-                    userSetting?.similarityThreshold ?? 0.3,
-                    userSetting?.rerankSimilarityThreshold ?? 0.5,
-                    userSetting?.enableQueryExpansion ?? false,
-                    userSetting?.enableHyDE ?? false,
+                    tenantSettings?.enableRerank ?? false,
+                    tenantSettings?.selectedRerankId,
+                    tenantSettings?.temperature,
+                    tenantSettings?.maxTokens,
+                    tenantSettings?.topK ?? 5,
+                    tenantSettings?.similarityThreshold ?? 0.3,
+                    tenantSettings?.rerankSimilarityThreshold ?? 0.5,
+                    tenantSettings?.enableQueryExpansion ?? false,
+                    tenantSettings?.enableHyDE ?? false,
                     user.tenantId,                            // Passing tenantId correctly
                 );
 
@@ -169,7 +174,7 @@ export class ApiV1Controller {
 
         if (!query) return { error: 'query is required' };
 
-        const userSetting = await this.userSettingService.findOrCreate(user.id);
+        const userSetting = await this.tenantService.getSettings(user.tenantId);
 
         const results = await this.ragService.searchKnowledge(
             query,
@@ -199,9 +204,9 @@ export class ApiV1Controller {
     @Get('knowledge-bases')
     async listFiles(@Request() req) {
         const user = req.user;
-        const result = await this.knowledgeBaseService.findAll(user.id, user.tenantId, { limit: 1000 });
+        const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
         return {
-            files: result.items.map((f) => ({
+            files: files.map((f) => ({
                 id: f.id,
                 name: f.originalName,
                 title: f.title,
@@ -210,7 +215,7 @@ export class ApiV1Controller {
                 mimetype: f.mimetype,
                 createdAt: f.createdAt,
             })),
-            total: result.total,
+            total: files.length,
         };
     }
 
@@ -255,14 +260,14 @@ export class ApiV1Controller {
     async deleteFile(@Request() req, @Param('id') id: string) {
         const user = req.user;
         await this.knowledgeBaseService.deleteFile(id, user.id, user.tenantId);
-        return { message: 'File deleted successfully' };
+        return { message: this.i18nService.getMessage('fileDeleted') };
     }
 
     @Get('knowledge-bases/:id')
     async getFile(@Request() req, @Param('id') id: string) {
         const user = req.user;
-        const result = await this.knowledgeBaseService.findAll(user.id, user.tenantId, { limit: 1000 });
-        const file = result.items.find((f) => f.id === id);
+        const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
+        const file = files.find((f) => f.id === id);
         if (!file) return { error: 'File not found' };
         return file;
     }

+ 2 - 2
server/src/api/api.controller.ts

@@ -40,14 +40,14 @@ export class ApiController {
     }
 
     try {
-      
+      // ユーザーの LLM モデル設定を取得
       const models = await this.modelConfigService.findAll(req.user.id, req.user.tenantId);
       const llmModel = models.find((m) => m.type === 'llm');
       if (!llmModel) {
         throw new Error(this.i18nService.getMessage('addLLMConfig'));
       }
 
-      // API key is optional - allow local models
+      // API key is optional - allows local models
 
       
       const modelConfigForService = {

+ 2 - 2
server/src/api/api.module.ts

@@ -4,8 +4,8 @@ import { ApiService } from './api.service';
 import { ApiV1Controller } from './api-v1.controller';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
 import { AuthModule } from '../auth/auth.module';
+import { TenantModule } from '../tenant/tenant.module';
 import { ModelConfigModule } from '../model-config/model-config.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
 import { RagModule } from '../rag/rag.module';
 import { ChatModule } from '../chat/chat.module';
 import { UserModule } from '../user/user.module';
@@ -17,10 +17,10 @@ import { memoryStorage } from 'multer';
     KnowledgeBaseModule,
     AuthModule,
     ModelConfigModule,
-    UserSettingModule,
     RagModule,
     ChatModule,
     UserModule,
+    TenantModule,
     MulterModule.register({ storage: memoryStorage() }),
   ],
   controllers: [ApiController, ApiV1Controller],

+ 6 - 5
server/src/api/api.service.ts

@@ -1,12 +1,13 @@
 import { Injectable } from '@nestjs/common';
 import { ChatOpenAI } from '@langchain/openai';
 import { ModelConfig } from '../types';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class ApiService {
-  constructor() { }
+  constructor(private i18nService: I18nService) { }
 
-  
+  // Simple health check method
   healthCheck() {
     return { status: 'ok', message: 'API is healthy' };
   }
@@ -15,7 +16,7 @@ export class ApiService {
     prompt: string,
     modelConfig: ModelConfig,
   ): Promise<string> {
-    // API key is optional - allow local models
+    // API key is optional - allows local models
 
     try {
       const llm = this.createLLM(modelConfig);
@@ -24,9 +25,9 @@ export class ApiService {
     } catch (error) {
       console.error('LangChain call failed:', error);
       if (error.message?.includes('401')) {
-        throw new Error('Invalid API key');
+        throw new Error(this.i18nService.getMessage('invalidApiKey'));
       }
-      throw new Error('Failed to get response: ' + error.message);
+      throw new Error(this.i18nService.formatMessage('apiCallFailed', { message: error.message }));
     }
   }
 

+ 2 - 11
server/src/app.module.ts

@@ -18,7 +18,6 @@ import { CombinedAuthGuard } from './auth/combined-auth.guard';
 import { KnowledgeBaseModule } from './knowledge-base/knowledge-base.module';
 import { ModelConfigModule } from './model-config/model-config.module';
 import { UserModule } from './user/user.module';
-import { UserSettingModule } from './user-setting/user-setting.module';
 import { TikaModule } from './tika/tika.module';
 import { VisionModule } from './vision/vision.module';
 import { LibreOfficeModule } from './libreoffice/libreoffice.module';
@@ -32,7 +31,7 @@ import { ImportTaskModule } from './import-task/import-task.module';
 import { I18nMiddleware } from './i18n/i18n.middleware';
 import { TenantMiddleware } from './tenant/tenant.middleware';
 import { User } from './user/user.entity';
-import { UserSetting } from './user-setting/user-setting.entity';
+import { UserSetting } from './user/user-setting.entity';
 import { ModelConfig } from './model-config/model-config.entity';
 import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
 import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
@@ -49,10 +48,6 @@ import { TenantMember } from './tenant/tenant-member.entity';
 import { TenantModule } from './tenant/tenant.module';
 import { SuperAdminModule } from './super-admin/super-admin.module';
 import { AdminModule } from './admin/admin.module';
-import { AssessmentModule } from './assessment/assessment.module';
-import { AssessmentSession } from './assessment/entities/assessment-session.entity';
-import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
-import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
 
 @Module({
   imports: [
@@ -87,9 +82,6 @@ import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity
           TenantSetting,
           TenantMember,
           ApiKey,
-          AssessmentSession,
-          AssessmentQuestion,
-          AssessmentAnswer,
         ],
         synchronize: true, // Auto-create database schema. Disable in production.
       }),
@@ -98,7 +90,6 @@ import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity
     I18nModule,
     UserModule,
     TenantModule,
-    UserSettingModule,
     ModelConfigModule,
     KnowledgeBaseModule,
     KnowledgeGroupModule,
@@ -117,7 +108,6 @@ import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity
     ImportTaskModule,
     SuperAdminModule,
     AdminModule,
-    AssessmentModule,
   ],
   controllers: [AppController],
   providers: [
@@ -135,3 +125,4 @@ export class AppModule implements NestModule {
       .forRoutes('*');
   }
 }
+// Trigger restart correct

+ 2 - 1
server/src/auth/auth.service.ts

@@ -32,7 +32,8 @@ export class AuthService {
         id: user.id,
         username: user.username,
         role: user.role,
-        tenantId: user.tenantId
+        tenantId: user.tenantId,
+        displayName: user.displayName
       }
     };
   }

+ 7 - 28
server/src/auth/combined-auth.guard.ts

@@ -6,6 +6,7 @@ import { Request } from 'express';
 import { lastValueFrom, Observable } from 'rxjs';
 import { IS_PUBLIC_KEY } from './public.decorator';
 import { tenantStore } from '../tenant/tenant.store';
+import { UserRole } from '../user/user-role.enum';
 
 /**
  * A combined authentication guard that accepts either:
@@ -38,17 +39,11 @@ export class CombinedAuthGuard implements CanActivate {
 
         const request = context.switchToHttp().getRequest<Request & { user?: any; tenantId?: string }>();
 
-        // DEBUG
-        const rawApiKey = request.headers['x-api-key'] as string;
-        // console.log(`[CombinedAuthGuard] Request: ${request.method} ${request.url}`);
-        // console.log(`[CombinedAuthGuard] Headers: x-api-key: ${rawApiKey ? rawApiKey.substring(0, 10) + '...' : 'false'}, x-tenant-id: ${request.headers['x-tenant-id']}`);
-
         // --- Try API Key first ---
         const apiKey = this.extractApiKey(request);
         if (apiKey) {
             const user = await this.userService.findByApiKey(apiKey);
             if (user) {
-                // --- Try API Key first ---
                 // If x-tenant-id is provided, verify membership
                 const requestedTenantId = request.headers['x-tenant-id'] as string;
                 let activeTenantId = user.tenantId;
@@ -57,7 +52,7 @@ export class CombinedAuthGuard implements CanActivate {
                     const memberships = await this.userService.getUserTenants(user.id);
                     const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
 
-                    if (hasAccess || user.role === 'SUPER_ADMIN') {
+                    if (hasAccess || user.isAdmin) {
                         activeTenantId = requestedTenantId;
                     } else {
                         throw new UnauthorizedException('User does not belong to the requested tenant');
@@ -67,7 +62,7 @@ export class CombinedAuthGuard implements CanActivate {
                 request.user = {
                     id: user.id,
                     username: user.username,
-                    role: user.role,
+                    role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
                     tenantId: activeTenantId,
                 };
                 request.tenantId = activeTenantId;
@@ -81,19 +76,11 @@ export class CombinedAuthGuard implements CanActivate {
 
                 return true;
             }
-
-            // Only throw if it definitely looks like an API key. 
-            // If it doesn't have the 'kb_' prefix, it's likely a mis-stored JWT from the frontend,
-            // so we let it fall through to the JWT check.
-            if (apiKey.startsWith('kb_')) {
-                throw new UnauthorizedException('Invalid API key');
-            }
+            throw new UnauthorizedException('Invalid API key');
         }
 
         // --- Fall back to JWT ---
         try {
-            // console.log(`[CombinedAuthGuard] Attempting JWT Auth for ${request.method} ${request.url}`);
-            // console.log(`[CombinedAuthGuard] Authorization: ${request.headers.authorization?.substring(0, 20)}...`);
             const result = await (this.jwtGuard as any).canActivate(context);
             let hasJwtSession = false;
 
@@ -103,26 +90,19 @@ export class CombinedAuthGuard implements CanActivate {
                 hasJwtSession = result;
             }
 
-            // console.log(`[CombinedAuthGuard] JWT Auth result: ${hasJwtSession}`);
-
             if (hasJwtSession) {
                 const user = request.user;
-                if (!user) {
-                    // console.log(`[CombinedAuthGuard] JWT Auth passed but request.user is missing!`);
-                    return false;
-                }
+                if (!user) return false;
 
                 const requestedTenantId = request.headers['x-tenant-id'] as string;
-                // console.log(`[CombinedAuthGuard] User: ${user.username}, Tenant: ${user.tenantId}, Requested Tenant: ${requestedTenantId}`);
 
                 if (requestedTenantId && user.tenantId !== requestedTenantId) {
                     const memberships = await this.userService.getUserTenants(user.id);
                     const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
 
-                    if (hasAccess || user.role === 'SUPER_ADMIN') {
+                    if (hasAccess || user.isAdmin) {
                         user.tenantId = requestedTenantId;
                     } else {
-                        // console.log(`[CombinedAuthGuard] Access denied to tenant ${requestedTenantId}`);
                         throw new UnauthorizedException('User does not belong to the requested tenant');
                     }
                 }
@@ -138,10 +118,9 @@ export class CombinedAuthGuard implements CanActivate {
 
                 return true;
             }
-            // console.log(`[CombinedAuthGuard] JWT Auth failed (no session)`);
             return false;
         } catch (e) {
-            // console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
+            console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
             throw e instanceof UnauthorizedException ? e : new UnauthorizedException('Authentication required');
         }
     }

+ 12 - 9
server/src/auth/jwt.strategy.ts

@@ -3,7 +3,8 @@ import { PassportStrategy } from '@nestjs/passport';
 import { ExtractJwt, Strategy } from 'passport-jwt';
 import { ConfigService } from '@nestjs/config';
 import { UserService } from '../user/user.service';
-import { SafeUser } from '../user/dto/user-safe.dto'; // Import SafeUser
+import { SafeUser } from '../user/dto/user-safe.dto';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -29,15 +30,17 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
     const user = await this.userService.findOneById(payload.sub);
 
     if (user) {
-      // 2. ALWAYS prioritize database values for role/tenant to prevent stale token access
+      const { password, ...result } = user;
+
+      // In a multi-tenant setup, the tenantId in the payload is the "default" or "last active" one.
+      // But it can be overridden by the x-tenant-id header in the guard.
+      // Map the backend isAdmin flag to the global UserRole
+      const computedRole = result.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER;
+
       return {
-        id: user.id,
-        username: user.username,
-        role: user.role, // Use DB role
-        tenantId: user.tenantId, // Use DB tenantId
-        isAdmin: user.isAdmin,
-        createdAt: user.createdAt,
-        updatedAt: user.updatedAt,
+        ...result,
+        role: payload.role || computedRole,
+        tenantId: payload.tenantId || result.tenantId
       } as SafeUser;
     }
     return null;

+ 5 - 1
server/src/auth/local.strategy.ts

@@ -4,6 +4,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
 import { AuthService } from './auth.service';
 import { SafeUser } from '../user/dto/user-safe.dto'; // Import SafeUser
 import { I18nService } from '../i18n/i18n.service';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class LocalStrategy extends PassportStrategy(Strategy) {
@@ -20,6 +21,9 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
       throw new UnauthorizedException(this.i18nService.getMessage('incorrectCredentials'));
     }
     const { password: userPassword, ...result } = user; // Destructure to remove password
-    return result as SafeUser;
+    return {
+      ...result,
+      role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER
+    } as SafeUser;
   }
 }

+ 12 - 12
server/src/chat/chat.controller.ts

@@ -73,12 +73,12 @@ export class ChatController {
       const role = req.user.role;
       const tenantId = req.user.tenantId;
 
-
+      // 获取用户的LLM模型配置
       let models = await this.modelConfigService.findAll(userId, tenantId);
 
       if (role !== 'SUPER_ADMIN') {
         const tenantSettings = await this.tenantService.getSettings(tenantId);
-        const enabledIds = tenantSettings.enabledModelIds || [];
+        const enabledIds = tenantSettings?.enabledModelIds || [];
         // Only allow models that are enabled by the tenant admin
         models = models.filter(m => enabledIds.includes(m.id));
       }
@@ -87,14 +87,14 @@ export class ChatController {
       if (selectedLLMId) {
         // Find specifically selected model
         llmModel = await this.modelConfigService.findOne(selectedLLMId, userId, tenantId);
-        console.log('Using selected LLM model:', llmModel.name);
+        console.log('使用选中的LLM模型:', llmModel.name);
       } else {
         // Use organization's default LLM from Index Chat Config (strict)
         llmModel = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
-        console.log('Final LLM model used (default):', llmModel ? llmModel.name : 'None');
+        console.log('最终使用的LLM模型 (默认):', llmModel ? llmModel.name : '无');
       }
 
-
+      // 设置 SSE 响应头
       res.setHeader('Content-Type', 'text/event-stream');
       res.setHeader('Cache-Control', 'no-cache');
       res.setHeader('Connection', 'keep-alive');
@@ -121,13 +121,13 @@ export class ChatController {
         historyId,
         enableRerank,
         selectedRerankId,
-        temperature,
-        maxTokens,
-        topK,
-        similarityThreshold,
-        rerankSimilarityThreshold,
-        enableQueryExpansion,
-        enableHyDE,
+        temperature, // 传递 temperature 参数
+        maxTokens, // 传递 maxTokens 参数
+        topK, // 传递 topK 参数
+        similarityThreshold, // 传递 similarityThreshold 参数
+        rerankSimilarityThreshold, // 传递 rerankSimilarityThreshold 参数
+        enableQueryExpansion, // 传递 enableQueryExpansion
+        enableHyDE, // 传递 enableHyDE
         req.user.tenantId // Pass tenant ID
       );
 

+ 2 - 2
server/src/chat/chat.module.ts

@@ -4,22 +4,22 @@ import { ChatService } from './chat.service';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
 import { ModelConfigModule } from '../model-config/model-config.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
 import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
 import { SearchHistoryModule } from '../search-history/search-history.module';
 import { RagModule } from '../rag/rag.module';
 import { TenantModule } from '../tenant/tenant.module';
+import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
     forwardRef(() => ElasticsearchModule),
     forwardRef(() => KnowledgeBaseModule),
     ModelConfigModule,
-    UserSettingModule,
     forwardRef(() => KnowledgeGroupModule),
     SearchHistoryModule,
     RagModule,
     TenantModule,
+    UserModule,
   ],
   controllers: [ChatController],
   providers: [ChatService],

+ 60 - 59
server/src/chat/chat.service.ts

@@ -12,7 +12,8 @@ import { RagService } from '../rag/rag.service';
 
 import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
 import { I18nService } from '../i18n/i18n.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
+import { UserSettingService } from '../user/user-setting.service';
 
 export interface ChatMessage {
   role: 'user' | 'assistant';
@@ -35,6 +36,7 @@ export class ChatService {
     private configService: ConfigService,
     private ragService: RagService,
     private i18nService: I18nService,
+    private tenantService: TenantService,
     private userSettingService: UserSettingService,
   ) {
     this.defaultDimensions = parseInt(
@@ -49,19 +51,19 @@ export class ChatService {
     modelConfig: ModelConfig,
     userLanguage: string = DEFAULT_LANGUAGE,
     selectedEmbeddingId?: string,
-    selectedGroups?: string[], 
-    selectedFiles?: string[], 
-    historyId?: string, 
+    selectedGroups?: string[], // New: Selected groups
+    selectedFiles?: string[], // New: Selected files
+    historyId?: string, // New: Chat history ID
     enableRerank: boolean = false,
     selectedRerankId?: string,
-    temperature?: number, 
-    maxTokens?: number, 
-    topK?: number, 
-    similarityThreshold?: number, 
-    rerankSimilarityThreshold?: number, 
-    enableQueryExpansion?: boolean, 
-    enableHyDE?: boolean, 
-    tenantId?: string 
+    temperature?: number, // New: temperature parameter
+    maxTokens?: number, // New: maxTokens parameter
+    topK?: number, // New: topK parameter
+    similarityThreshold?: number, // New: similarityThreshold parameter
+    rerankSimilarityThreshold?: number, // New: rerankSimilarityThreshold parameter
+    enableQueryExpansion?: boolean, // New
+    enableHyDE?: boolean, // New
+    tenantId?: string // New: tenant isolation
   ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
     console.log('=== ChatService.streamChat ===');
     console.log('User ID:', userId);
@@ -85,19 +87,19 @@ export class ChatService {
     console.log('API Key prefix:', modelConfig.apiKey?.substring(0, 10) + '...');
     console.log('API Key length:', modelConfig.apiKey?.length);
 
-    
-    
+    // Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
+    // Use actual language based on user settings
     const effectiveUserLanguage = userLanguage || DEFAULT_LANGUAGE;
 
     let currentHistoryId = historyId;
     let fullResponse = '';
 
     try {
-      
+      // Create new chat history if no historyId
       if (!currentHistoryId) {
         const searchHistory = await this.searchHistoryService.create(
           userId,
-          tenantId || 'default', 
+          tenantId || 'default', // New
           message,
           selectedGroups,
         );
@@ -106,9 +108,9 @@ export class ChatService {
         yield { type: 'historyId', data: currentHistoryId };
       }
 
-      
+      // Save user message
       await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
-      
+      // 1. Get user's embedding model settings
       let embeddingModel: any;
 
       if (selectedEmbeddingId) {
@@ -121,7 +123,7 @@ export class ChatService {
 
       console.log(this.i18nService.getMessage('usingEmbeddingModel', effectiveUserLanguage) + embeddingModel.name + ' ' + embeddingModel.modelId + ' ID:' + embeddingModel.id);
 
-      
+      // 2. Search using user's query directly
       console.log(this.i18nService.getMessage('startingSearch', effectiveUserLanguage));
       yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n' };
 
@@ -129,14 +131,14 @@ export class ChatService {
       let context = '';
 
       try {
-        
-        let effectiveFileIds = selectedFiles; 
+        // 3. If knowledge groups are selected, get file IDs from those groups first
+        let effectiveFileIds = selectedFiles; // Prioritize explicitly specified files
         if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
-          
+          // Get file IDs from knowledge groups
           effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId, tenantId as string);
         }
 
-        
+        // 3. Use RagService for search (supports hybrid search + Rerank)
         const ragResults = await this.ragService.searchKnowledge(
           message,
           userId,
@@ -154,18 +156,18 @@ export class ChatService {
           enableHyDE
         );
 
-        
-        
-        
+        // Convert RagSearchResult to format needed by ChatService (any[])
+        // HybridSearch returns ES hit structure, but RagSearchResult is normalized
+        // BuildContext expects {fileName, content}. RagSearchResult has these
         searchResults = ragResults;
         console.log(this.i18nService.getMessage('searchResultsCount', effectiveUserLanguage) + searchResults.length);
 
-        
+        // 4. Build context
         context = this.buildContext(searchResults, effectiveUserLanguage);
 
         if (searchResults.length === 0) {
           if (selectedGroups && selectedGroups.length > 0) {
-            
+            // User selected knowledge groups but no matches found
             const noMatchMsg = this.i18nService.getMessage('noMatchInKnowledgeGroup', effectiveUserLanguage);
             yield { type: 'content', data: `⚠️ ${noMatchMsg}\n\n` };
           } else {
@@ -178,7 +180,7 @@ export class ChatService {
             type: 'content',
             data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`,
           };
-          
+          // Debug info
           const scores = searchResults.map(r => {
             if (r.originalScore !== undefined && r.originalScore !== r.score) {
               return `${r.originalScore.toFixed(2)} → ${r.score.toFixed(2)}`;
@@ -195,7 +197,7 @@ export class ChatService {
         yield { type: 'content', data: this.i18nService.getMessage('searchFailed', effectiveUserLanguage) + '\n\n' };
       }
 
-      
+      // 5. Stream response generation
       this.logger.log(this.i18nService.formatMessage('modelCall', {
         type: 'LLM',
         model: `${modelConfig.name} (${modelConfig.modelId})`,
@@ -238,7 +240,7 @@ export class ChatService {
         }
       }
 
-      
+      // Save AI response
       await this.searchHistoryService.addMessage(
         currentHistoryId,
         'assistant',
@@ -253,15 +255,15 @@ export class ChatService {
         })),
       );
 
-      
+      // 7. Auto-generate chat title (executed after first exchange)
       const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId, tenantId);
       if (messagesInHistory.messages.length === 2) {
-        this.generateChatTitle(currentHistoryId, userId, tenantId, effectiveUserLanguage).catch((err) => {
+        this.generateChatTitle(currentHistoryId, userId, tenantId).catch((err) => {
           this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err);
         });
       }
 
-      
+      // 6. Return sources
       yield {
         type: 'sources',
         data: searchResults.map((result) => ({
@@ -300,8 +302,8 @@ export class ChatService {
       });
 
       const systemPrompt = `${this.i18nService.getMessage('intelligentAssistant', 'ja')}
-Correct or improve the provided text content based on your instructions.
-Please do not include any greetings or closing words (such as "Okay, this is...") and directly output only the revised content.
+提供されたテキスト内容を、ユーザーの指示に基づいて修正または改善please。
+挨拶や結びの言葉(「わかりました、こちらが...」etc.)は含めず、修正後の内容のみを直接出力please。
 
 Context (current contents):
 ${context}
@@ -326,22 +328,22 @@ ${instruction}`;
     keywords: string[],
     userId: string,
     embeddingModelId?: string,
-    selectedGroups?: string[], 
-    explicitFileIds?: string[], 
+    selectedGroups?: string[], // New parameter
+    explicitFileIds?: string[], // New parameter
     tenantId?: string, // Added
   ): Promise<any[]> {
     try {
-      
+      // Join keywords into search string
       const combinedQuery = keywords.join(' ');
       console.log(this.i18nService.getMessage('searchString', 'ja') + combinedQuery);
 
-      
+      // Check if embedding model ID is provided
       if (!embeddingModelId) {
         console.log(this.i18nService.getMessage('embeddingModelIdNotProvided', 'ja'));
         return [];
       }
 
-      
+      // Use actual embedding vector
       console.log(this.i18nService.getMessage('generatingEmbeddings', 'ja'));
       const queryEmbedding = await this.embeddingService.getEmbeddings(
         [combinedQuery],
@@ -351,7 +353,7 @@ ${instruction}`;
       const queryVector = queryEmbedding[0];
       console.log(this.i18nService.getMessage('embeddingsGenerated', 'ja') + this.i18nService.getMessage('dimensions', 'ja') + ':', queryVector.length);
 
-      
+      // Hybrid search
       console.log(this.i18nService.getMessage('performingHybridSearch', 'ja'));
       const results = await this.elasticsearchService.hybridSearch(
         queryVector,
@@ -359,8 +361,8 @@ ${instruction}`;
         userId,
         10,
         0.6,
-        selectedGroups, 
-        explicitFileIds, 
+        selectedGroups, // Pass selected groups
+        explicitFileIds, // Pass explicit file IDs
         tenantId, // Added: tenantId
       );
       console.log(this.i18nService.getMessage('esSearchCompleted', 'ja') + this.i18nService.getMessage('resultsCount', 'ja') + ':', results.length);
@@ -436,10 +438,10 @@ ${instruction}`;
         model: `${config.name} (${config.modelId})`,
         user: userId
       }, 'ja'));
-      const settings = await this.userSettingService.findOrCreate(userId);
+      const settings = await this.tenantService.getSettings(tenantId || 'default');
       const llm = new ChatOpenAI({
         apiKey: config.apiKey || 'ollama',
-        temperature: settings.temperature ?? 0.7, 
+        temperature: settings?.temperature ?? 0.7,
         modelName: config.modelId,
         configuration: {
           baseURL: config.baseUrl || 'http://localhost:11434/v1',
@@ -457,9 +459,11 @@ ${instruction}`;
     }
   }
 
-  
-  async generateChatTitle(historyId: string, userId: string, tenantId?: string, language?: string): Promise<string | null> {
-    this.logger.log(`Generating automatic title for chat session ${historyId} in language: ${language || 'default'}`);
+  /**
+   * 対話内容に基づいてチャットのタイトルを自動生成する
+   */
+  async generateChatTitle(historyId: string, userId: string, tenantId?: string): Promise<string | null> {
+    this.logger.log(`Generating automatic title for chat session ${historyId}`);
 
     try {
       const history = await this.searchHistoryService.findOne(historyId, userId, tenantId || 'default');
@@ -474,17 +478,14 @@ ${instruction}`;
         return null;
       }
 
-      
-      let targetLanguage = language;
-      if (!targetLanguage) {
-        const settings = await this.userSettingService.findOrCreate(userId);
-        targetLanguage = settings.language || 'ja';
-      }
+      // Get language from user settings
+      const userSettings = await this.userSettingService.getByUser(userId);
+      const language = userSettings?.language || 'ja';
 
-      
-      const prompt = this.i18nService.getChatTitlePrompt(targetLanguage, userMessage, aiResponse);
+      // Build prompt
+      const prompt = this.i18nService.getChatTitlePrompt(language, userMessage, aiResponse);
 
-      
+      // Call LLM to generate title
       const generatedTitle = await this.generateSimpleChat(
         [{ role: 'user', content: prompt }],
         userId,
@@ -492,7 +493,7 @@ ${instruction}`;
       );
 
       if (generatedTitle && generatedTitle.trim().length > 0) {
-        
+        // Remove extra quotes
         const cleanedTitle = generatedTitle.trim().replace(/^["']|["']$/g, '').substring(0, 50);
         await this.searchHistoryService.updateTitle(historyId, cleanedTitle);
         this.logger.log(`Successfully generated title for chat ${historyId}: ${cleanedTitle}`);

+ 2 - 2
server/src/common/constants.ts

@@ -11,7 +11,7 @@ export const DEFAULT_MAX_OVERLAP_RATIO = 0.5;
 
 export const DEFAULT_VECTOR_DIMENSIONS = 1536;
 
-
+// File size limit (バイト)
 export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
 
 
@@ -20,5 +20,5 @@ export const DEFAULT_MAX_BATCH_SIZE = 2048;
 
 export const DEFAULT_LANGUAGE = 'ja';
 
-
+// システム全体の共通テナントID(シードデータetc.で使用)
 export const GLOBAL_TENANT_ID = '00000000-0000-0000-0000-000000000000';

+ 2 - 14
server/src/data-source.ts

@@ -1,22 +1,16 @@
 import { DataSource } from 'typeorm';
 import { User } from './user/user.entity';
-import { UserSetting } from './user-setting/user-setting.entity';
+// import { UserSetting } from './user-setting/user-setting.entity';
 import { ModelConfig } from './model-config/model-config.entity';
 import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
 import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
 import { SearchHistory } from './search-history/search-history.entity';
 import { ChatMessage } from './search-history/chat-message.entity';
 import { Note } from './note/note.entity';
-import { NoteCategory } from './note/note-category.entity';
 import { PodcastEpisode } from './podcasts/entities/podcast-episode.entity';
 import { ImportTask } from './import-task/import-task.entity';
 import { Tenant } from './tenant/tenant.entity';
 import { TenantSetting } from './tenant/tenant-setting.entity';
-import { TenantMember } from './tenant/tenant-member.entity';
-import { ApiKey } from './auth/entities/api-key.entity';
-import { AssessmentSession } from './assessment/entities/assessment-session.entity';
-import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
-import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
 
 export const AppDataSource = new DataSource({
     type: 'better-sqlite3',
@@ -25,23 +19,17 @@ export const AppDataSource = new DataSource({
     logging: true,
     entities: [
         User,
-        UserSetting,
+        // UserSetting,
         ModelConfig,
         KnowledgeBase,
         KnowledgeGroup,
         SearchHistory,
         ChatMessage,
         Note,
-        NoteCategory,
         PodcastEpisode,
         ImportTask,
         Tenant,
         TenantSetting,
-        TenantMember,
-        ApiKey,
-        AssessmentSession,
-        AssessmentQuestion,
-        AssessmentAnswer,
     ],
     migrations: ['src/migrations/**/*.ts'],
 });

+ 23 - 19
server/src/elasticsearch/elasticsearch.service.ts

@@ -2,6 +2,7 @@
 import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Client } from '@elastic/elasticsearch';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class ElasticsearchService implements OnModuleInit {
@@ -11,6 +12,7 @@ export class ElasticsearchService implements OnModuleInit {
 
   constructor(
     private configService: ConfigService,
+    private i18nService: I18nService,
   ) {
     const node = this.configService.get<string>('ELASTICSEARCH_HOST'); // Changed from NODE to HOST
     this.indexName = this.configService.get<string>(
@@ -19,7 +21,7 @@ export class ElasticsearchService implements OnModuleInit {
     );
 
     if (!node) {
-      throw new Error('ELASTICSEARCH_HOST environment variable not set.');
+      throw new Error(this.i18nService.getMessage('elasticsearchHostRequired'));
     }
 
     this.client = new Client({
@@ -31,7 +33,7 @@ export class ElasticsearchService implements OnModuleInit {
     try {
       const health = await this.client.cluster.health();
       this.logger.log(`Elasticsearch cluster health is: ${health.status}`);
-      
+      // Index is created dynamically on first use based on the model
     } catch (error) {
       this.logger.error('Failed to connect to Elasticsearch', error);
     }
@@ -48,7 +50,7 @@ export class ElasticsearchService implements OnModuleInit {
       );
       await this.createIndex(vectorDimensions);
     } else {
-      
+      // Check existing index vector dimensions
       const mapping = await this.client.indices.getMapping({
         index: this.indexName,
       });
@@ -63,7 +65,7 @@ export class ElasticsearchService implements OnModuleInit {
         );
         this.logger.warn(`Reason: The embedding model might have been changed to one with different dimensions. The system will automatically recreate the index.`);
 
-        
+        // Delete existing index and recreate
         await this.client.indices.delete({ index: this.indexName });
         this.logger.log(`Successfully deleted old index: ${this.indexName}`);
 
@@ -89,7 +91,7 @@ export class ElasticsearchService implements OnModuleInit {
 
     if (!vector || vector.length === 0) {
       this.logger.error(`Invalid vector for document ${documentId}`);
-      throw new Error('Vector is required for indexing');
+      throw new Error(this.i18nService.getMessage('vectorRequired'));
     }
 
     const document = {
@@ -150,7 +152,7 @@ export class ElasticsearchService implements OnModuleInit {
         source: 'ctx._source.title = params.title',
         params: { title },
       },
-      refresh: true, 
+      refresh: true, // Reflect in search immediately
     });
   }
 
@@ -200,7 +202,7 @@ export class ElasticsearchService implements OnModuleInit {
 
       const results = response.hits.hits.map((hit: any) => ({
         id: hit._id,
-        score: this.normalizeScore(hit._score), 
+        score: this.normalizeScore(hit._score), // Normalize score
         content: hit._source?.content,
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
@@ -261,7 +263,7 @@ export class ElasticsearchService implements OnModuleInit {
 
       const results = response.hits.hits.map((hit: any) => ({
         id: hit._id,
-        score: this.normalizeScore(hit._score), 
+        score: this.normalizeScore(hit._score), // Normalize score
         content: hit._source?.content,
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
@@ -287,15 +289,15 @@ export class ElasticsearchService implements OnModuleInit {
     userId: string,
     topK: number = 5,
     vectorWeight: number = 0.7,
-    selectedGroups?: string[], 
-    explicitFileIds?: string[], 
+    selectedGroups?: string[], // Keep for backward compatibility(未使用)
+    explicitFileIds?: string[], // Explicitly specified file ID list
     tenantId?: string,
   ) {
-    
+    // selectedGroups is deprecated。呼び出し側で fileIds に変換して explicitFileIds を使用please
     const fileIds = explicitFileIds;
 
     if (fileIds && fileIds.length === 0) {
-      this.logger.log('Skipping search because there are 0 target files');
+      this.logger.log('検索対象ファイルが0件for、検索をスキップします');
       return [];
     }
 
@@ -309,10 +311,10 @@ export class ElasticsearchService implements OnModuleInit {
       this.searchFullTextWithFileFilter(query, userId, topK, fileIds, tenantId),
     ]);
 
-    
+    // Merge results and remove duplicates
     const combinedResults = new Map();
 
-    
+    // Add vector search results
     vectorResults.forEach((result) => {
       combinedResults.set(result.id, {
         ...result,
@@ -322,7 +324,7 @@ export class ElasticsearchService implements OnModuleInit {
       });
     });
 
-    
+    // Add full-text search results
     textResults.forEach((result) => {
       if (combinedResults.has(result.id)) {
         const existing = combinedResults.get(result.id);
@@ -340,7 +342,7 @@ export class ElasticsearchService implements OnModuleInit {
       }
     });
 
-    
+    // 正規化forにすべての組み合わせスコアを取得
     const allScores = Array.from(combinedResults.values()).map(r => r.combinedScore);
     const maxScore = Math.max(...allScores, 1); 
     const minScore = Math.min(...allScores);
@@ -400,10 +402,10 @@ export class ElasticsearchService implements OnModuleInit {
         
         userId: { type: 'keyword' },
 
-        
+        // テナント情報(マルチテナント分離用)
         tenantId: { type: 'keyword' },
 
-        
+        // タイムスタンプ
         createdAt: { type: 'date' },
       },
     };
@@ -590,7 +592,9 @@ export class ElasticsearchService implements OnModuleInit {
     }
   }
 
-  
+  /**
+   * 指定されたファイルのすべてのチャンクを取得
+   */
   async getFileChunks(fileId: string, userId: string, tenantId?: string) {
     try {
       this.logger.log(`Getting chunks for file ${fileId}`);

+ 13 - 13
server/src/i18n/i18n.service.ts

@@ -178,9 +178,9 @@ Please answer in English.
 `;
     } else { // 默认为日语,符合项目要求
       return type === 'withContext' ? `
-以下のナレッジベースの内容に基づいてユーザーの質問に答えてください
+以下のナレッジベースの内容に基づいてユーザーの質問に答えてくplease
 ${hasKnowledgeGroup ? `
-**重要**: ユーザーが特定の知識グループを選択しました。以下のナレッジベースの内容に厳密に基づいて回答してください。ナレッジベースに関連情報がない場合は、「${noMatchMsg}」とユーザーに明示的に伝えてから、回答を提供してください
+**重要**: ユーザーが特定の知識グループを選択しました。以下のナレッジベースの内容に厳密に基づいて回答please。ナレッジベースに関連情報がない場合は、「${noMatchMsg}」とユーザーに明示的に伝えてfrom、回答を提供please
 ` : ''}
 ナレッジベースの内容:
 {context}
@@ -190,7 +190,7 @@ ${hasKnowledgeGroup ? `
 
 ユーザーの質問:{question}
 
-Japaneseで回答してください。以下の Markdown 書式要件に厳密に従ってください
+日本語で回答please。以下の Markdown 書式要件に厳密に従ってくplease
 
 1. **段落と構造**:
    - 明確な段落分けを使用し、要点間に空行を入れる
@@ -207,10 +207,10 @@ Japaneseで回答してください。以下の Markdown 書式要件に厳密
      def example():
          return "例"
      \`\`\`
-   - 対応言語:python, javascript, typescript, java, bash, sql など
+   - 対応言語:python, javascript, typescript, java, bash, sql etc.
 
 4. **図表とチャート**:
-   - フローチャート、シーケンス図などに Mermaid 構文を使用:
+   - フローチャート、シーケンス図etc.に Mermaid 構文を使用:
      \`\`\`mermaid
      graph LR
          A[開始] --> B[処理]
@@ -223,13 +223,13 @@ Japaneseで回答してください。以下の Markdown 書式要件に厳密
    - 複数のステップがある場合は番号付きリストを使用
    - 比較情報には表を使用(該当する場合)
 ` : `
-インテリジェントアシスタントとして、ユーザーの質問に答えてください
+インテリジェントアシスタントとして、ユーザーの質問に答えてくplease
 
 会話履歴:
 {history}
 
 ユーザーの質問:{question}
-      Japaneseで回答してください
+      日本語で回答please
 `;
     }
   }
@@ -250,9 +250,9 @@ Language: English
 Text:
 ${contentSample}`;
     } else {
-      return `あなたはドキュメントアナライザーです。以下のテキスト(ドキュメントの冒頭部分)を読み、簡潔でプロフェッショナルなタイトル(最大50文字)を生成してください
-タイトルテキストのみを返してください。説明文や前置き(例:「タイトルは:」)は含めないでください
-言語:Japanese
+      return `あなたはドキュメントアナライザー.以下のテキスト(ドキュメントの冒頭部分)を読み、簡潔でプロフェッショナルなタイトル(最大50文字)を生成please
+タイトルテキストのみを返please。説明文や前置き(例:「タイトルは:」)は含めないでくplease
+言語:日本語
 テキスト:
 ${contentSample}`;
     }
@@ -275,9 +275,9 @@ Snippet:
 User: ${userMessage}
 Assistant: ${aiResponse}`;
     } else {
-      return `以下の会話スニペットに基づいて、トピックを要約する短く説明的なタイトル(最大50文字)を生成してください
-タイトルのみを返してください。前置きは不要です。
-言語:Japanese
+      return `以下の会話スニペットに基づいて、トピックを要約する短く説明的なタイトル(最大50文字)を生成please
+タイトルのみを返please。前置きは不要.
+言語:日本語
 スニペット:
 ユーザー: ${userMessage}
 アシスタント: ${aiResponse}`;

+ 89 - 365
server/src/i18n/messages.ts

@@ -35,6 +35,7 @@ export const errorMessages = {
     adminOnlyDeleteUser: '只有管理员可以删除用户',
     cannotDeleteSelf: '不能删除自己的账户',
     cannotDeleteBuiltinAdmin: '无法删除内置管理员账户',
+    invalidMemberRole: '无效的角色。只允许 USER 和 TENANT_ADMIN',
     incorrectCredentials: '用户名或密码不正确',
     incorrectCurrentPassword: '当前密码错误',
     usernameExists: '用户名已存在',
@@ -57,11 +58,28 @@ export const errorMessages = {
     retryMechanismError: '重试机制异常',
     imageLoadError: '无法读取图像: {message}',
     groupNotFound: '分组不存在',
+    fileDeleted: '文件删除成功',
+    fileDeletedFromGroup: '文件从分组中移除成功',
+    kbCleared: '知识库清除成功',
+    groupSyncSuccess: '分组同步成功',
+    groupDeleted: '分组删除成功',
+    searchHistoryDeleted: '搜索历史删除成功',
+    jwtSecretRequired: 'JWT_SECRET 环境变量未设置',
+    tenantNotFound: '租户不存在',
+    usernameRequired: '用户名是必填项',
+    passwordRequiredForNewUser: '新用户 {username} 需要密码',
+    importTaskNotFound: '导入任务不存在',
+    sourcePathNotFound: '源路径不存在: {path}',
+    targetGroupRequired: '未指定目标分组',
+    modelConfigNotFound: '找不到模型配置: {id}',
+    cannotUpdateOtherTenantModel: '无法更新其他租户的模型',
+    cannotDeleteOtherTenantModel: '无法删除其他租户的模型',
+    elasticsearchHostRequired: 'ELASTICSEARCH_HOST 环境变量未设置',
   },
   ja: {
-    noEmbeddingModel: '先にシステム設定で埋め込みモデルを設定してください',
+    noEmbeddingModel: '先にシステム設定で埋め込みモデルを設定please',
     searchFailed: 'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
-    invalidApiKey: 'APIキーが無効です',
+    invalidApiKey: 'APIキーが無効is',
     fileNotFound: 'ファイルが見つかりません',
     insufficientQuota: '利用枠が不足しています',
     modelNotConfigured: 'モデルが設定されていません',
@@ -70,23 +88,23 @@ export const errorMessages = {
     uploadNoFile: 'ファイルがアップロードされていません',
     uploadSizeExceeded: 'ファイルサイズが制限: {size}, 最大許容: {max}',
     uploadModelRequired: '埋め込みモデルを選択する必要があります',
-    uploadTypeUnsupported: 'サポートされていないファイル形式です: {type}',
-    chunkOverflow: 'Chunk size {size}  exceeds limit  {max} ({reason}) 。自動調整されました',
-    chunkUnderflow: 'Chunk size {size}  is below minimum  {min} 。自動調整されました',
-    overlapOverflow: '重なりサイズ {size}  exceeds limit  {max} 。自動調整されました',
-    overlapUnderflow: '重なりサイズ {size}  is below minimum  {min} 。自動調整されました',
-    overlapRatioExceeded: '重なりサイズ {size} がChunk sizeの50% ({max}) 。自動調整されました',
-    batchOverflowWarning: 'バッチ処理のオーバーフローを避けるため、Chunk sizeを {safeSize} 以下にすることをお勧めします (現在: {size}, モデル制限の {percent}%)',
+    uploadTypeUnsupported: 'サポートされていないファイル形式is: {type}',
+    chunkOverflow: 'チャンクサイズ {size} が上限 {max} ({reason}) を超えています。自動調整されました',
+    chunkUnderflow: 'チャンクサイズ {size} が最小値 {min} 未満.自動調整されました',
+    overlapOverflow: '重なりサイズ {size} が上限 {max} を超えています。自動調整されました',
+    overlapUnderflow: '重なりサイズ {size} が最小値 {min} 未満.自動調整されました',
+    overlapRatioExceeded: '重なりサイズ {size} がチャンクサイズの50% ({max}) を超えています。自動調整されました',
+    batchOverflowWarning: 'バッチ処理のオーバーフローを避けるため、チャンクサイズを {safeSize} 以下にすることをお勧めします (現在: {size}, モデル制限の {percent}%)',
     estimatedChunkCountExcessive: '推定チャンク数が多すぎます ({count})。処理に時間がかかる可能性があります',
-    contentAndTitleRequired: '内容とタイトルは必須です',
+    contentAndTitleRequired: '内容とタイトルは必須is',
     embeddingModelNotFound: '埋め込みモデル {id} が見つかりません、またはタイプが embedding ではありません',
     ocrFailed: 'テキストの抽出に失敗しました: {message}',
     noImageUploaded: '画像がアップロードされていません',
     adminOnlyViewList: '管理者のみがユーザーリストを表示できます',
-    passwordsRequired: '現在のパスワードと新しいパスワードは必須です',
+    passwordsRequired: '現在のパスワードと新しいパスワードは必須is',
     newPasswordMinLength: '新しいパスワードは少なくとも6文字以上である必要があります',
     adminOnlyCreateUser: '管理者のみがユーザーを作成できます',
-    usernamePasswordRequired: 'ユーザー名とパスワードは必須です',
+    usernamePasswordRequired: 'ユーザー名とパスワードは必須is',
     passwordMinLength: 'パスワードは少なくとも6文字以上である必要があります',
     adminOnlyUpdateUser: '管理者のみがユーザー情報を更新できます',
     userNotFound: 'ユーザーが見つかりません',
@@ -94,28 +112,46 @@ export const errorMessages = {
     adminOnlyDeleteUser: '管理者のみがユーザーを削除できます',
     cannotDeleteSelf: '自分自身のアカウントを削除できません',
     cannotDeleteBuiltinAdmin: 'ビルトイン管理者アカウントを削除できません',
+    invalidMemberRole: '無効な役割です。USER と TENANT_ADMIN のみ許可されています',
     incorrectCredentials: 'ユーザー名またはパスワードが間違っています',
     incorrectCurrentPassword: '現在のパスワードが間違っています',
     usernameExists: 'ユーザー名が既に存在します',
     noteNotFound: 'ノートが見つかりません: {id}',
     knowledgeGroupNotFound: 'ナレッジグループが見つかりません: {id}',
     accessDeniedNoToken: 'アクセス不許可:トークンがありません',
-    invalidToken: '無効なトークンです',
+    invalidToken: '無効なトークンis',
     pdfFileNotFound: 'PDF ファイルが見つかりません',
-    pdfFileEmpty: 'PDF ファイルが空です。変換に失敗した可能性があります',
+    pdfFileEmpty: 'PDF ファイルが空.変換に失敗した可能性があります',
     pdfConversionFailed: 'PDF ファイルが存在しないか、変換に失敗しました',
-    pdfConversionFailedDetail: 'PDF 変換に失敗しました(ファイル ID: {id})。後でもう一度お試しください',
+    pdfConversionFailedDetail: 'PDF 変換に失敗しました(ファイル ID: {id})。後でもう一度お試しくplease',
     pdfPreviewNotSupported: 'このファイル形式はプレビューをサポートしていません',
     pdfServiceUnavailable: 'PDF サービスを利用できません: {message}',
     pageImageNotFound: 'ページ画像が見つかりません',
     pdfPageImageFailed: 'PDF ページの画像を取得できませんでした',
     someGroupsNotFound: '一部のグループが存在しません',
-    promptRequired: 'プロンプトは必須です',
-    addLLMConfig: 'システム設定で LLM モデルをAddedしてください',
+    promptRequired: 'プロンプトは必須is',
+    addLLMConfig: 'システム設定で LLM モデルを追加please',
     visionAnalysisFailed: 'ビジョン分析に失敗しました: {message}',
     retryMechanismError: '再試行メカニズムの異常',
     imageLoadError: '画像を読み込めません: {message}',
     groupNotFound: 'グループが存在しません',
+    fileDeleted: 'ファイルが削除されました',
+    fileDeletedFromGroup: 'ファイルがグループから削除されました',
+    kbCleared: 'ナレッジベースがクリアされました',
+    groupSyncSuccess: 'グループ同期が完了しました',
+    groupDeleted: 'グループが削除されました',
+    searchHistoryDeleted: '検索履歴が削除されました',
+    jwtSecretRequired: 'JWT_SECRET 環境変数が設定されていません',
+    tenantNotFound: 'テナントが見つかりません',
+    usernameRequired: 'ユーザー名は必須is',
+    passwordRequiredForNewUser: '新しいユーザー {username} のパスワードが必要is',
+    importTaskNotFound: 'インポートタスクが見つかりません',
+    sourcePathNotFound: 'ソースパスが見つかりません: {path}',
+    targetGroupRequired: 'ターゲットグループが指定されていません',
+    modelConfigNotFound: 'モデル設定が見つかりません: {id}',
+    cannotUpdateOtherTenantModel: '他のテナントのモデルは更新できません',
+    cannotDeleteOtherTenantModel: '他のテナントのモデルは削除できません',
+    elasticsearchHostRequired: 'ELASTICSEARCH_HOST 環境変数が設定されていません',
   },
   en: {
     noEmbeddingModel: 'Please configure embedding model in system settings first',
@@ -146,6 +182,7 @@ export const errorMessages = {
     newPasswordMinLength: 'New password must be at least 6 characters',
     adminOnlyCreateUser: 'Only admins can create users',
     usernamePasswordRequired: 'Username and password are required',
+    usernameRequired: 'Username is required',
     passwordMinLength: 'Password must be at least 6 characters',
     adminOnlyUpdateUser: 'Only admins can update user info',
     userNotFound: 'User not found',
@@ -153,7 +190,7 @@ export const errorMessages = {
     adminOnlyDeleteUser: 'Only admins can delete users',
     cannotDeleteSelf: 'Cannot delete your own account',
     cannotDeleteBuiltinAdmin: 'Cannot delete built-in admin account',
-    onlyBuiltinAdminCanChangeRole: 'Only built-in admin can change user roles',
+    invalidMemberRole: 'Invalid role. Only USER and TENANT_ADMIN are allowed',
     incorrectCredentials: 'Incorrect username or password',
     incorrectCurrentPassword: 'Incorrect current password',
     usernameExists: 'Username already exists',
@@ -176,363 +213,50 @@ export const errorMessages = {
     retryMechanismError: 'Retry mechanism error',
     imageLoadError: 'Cannot load image: {message}',
     groupNotFound: 'Group not found',
+    fileDeleted: 'File deleted successfully',
+    fileDeletedFromGroup: 'File removed from group successfully',
+    kbCleared: 'Knowledge base cleared successfully',
+    groupSyncSuccess: 'Group sync completed successfully',
+    groupDeleted: 'Group deleted successfully',
+    searchHistoryDeleted: 'Search history deleted successfully',
+    jwtSecretRequired: 'JWT_SECRET environment variable is required but not set',
+    tenantNotFound: 'Tenant not found',
+    importTaskNotFound: 'Import task not found',
+    sourcePathNotFound: 'Source path not found: {path}',
+    targetGroupRequired: 'Target group not specified',
+    modelConfigNotFound: 'Model config not found: {id}',
+    cannotUpdateOtherTenantModel: 'Cannot update models from another tenant',
+    cannotDeleteOtherTenantModel: 'Cannot delete models from another tenant',
+    elasticsearchHostRequired: 'ELASTICSEARCH_HOST environment variable is not set',
+    libreofficeUrlRequired: 'LIBREOFFICE_URL environment variable is required but not set',
+    pdfToImageConversionFailed: 'PDF to image conversion failed. No images were generated.',
+    pdfPageCountError: 'Could not get PDF page count',
+    parentCategoryNotFound: 'Parent category not found',
+    categoryNotFound: 'Category not found',
+    maxCategoryDepthExceeded: 'Maximum category depth (3 levels) exceeded',
+    userIdRequired: 'User ID is required',
+    podcastNotFound: 'Podcast not found: {id}',
+    scriptGenerationFailed: 'Script generation failed to produce valid JSON',
+    vectorRequired: 'Vector is required for indexing',
+    apiCallFailed: 'API call failed: {message}',
+    tikaHostRequired: 'TIKA_HOST environment variable is required but not set',
   }
 };
 
 export const logMessages = {
-  zh: {
-    processingFile: '处理文件: {name} ({size})',
-    indexingComplete: '索引完成: {id}',
-    vectorizingFile: '向量化文件: ',
-    searchQuery: '搜索查询: ',
-    modelCall: '[模型调用] 类型: {type}, 模型: {model}, 用户: {user}',
-    memoryStatus: '内存状态: ',
-    uploadSuccess: '文件上传成功。正在后台索引',
-    overlapAdjusted: '重叠大小超过切片大小的50%。已自动调整为 {newSize}',
-    environmentLimit: '环境变量限制',
-    modelLimit: '模型限制',
-    configLoaded: '数据库模型配置加载: {name} ({id})',
-    batchSizeAdjusted: '批量大小从 {old} 调整为 {new} (模型限制: {limit})',
-    dimensionMismatch: '模型 {id} 维度不匹配: 预期 {expected}, 实际 {actual}',
-    searchMetadataFailed: '为用户 {userId} 搜索知识库失败',
-    extractedTextTooLarge: '抽出されたテキストが大きいです: {size}MB',
-    preciseModeUnsupported: '格式 {ext} 不支持精密模式,回退到快速模式',
-    visionModelNotConfiguredFallback: '未配置视觉模型,回退到快速模式',
-    visionModelInvalidFallback: '视觉模型配置无效,回退到快速模式',
-    visionPipelineFailed: '视觉流水线失败,回退到快速模式',
-    preciseModeComplete: '精密模式提取完成: {pages}页, 费用: ${cost}',
-    skippingEmptyVectorPage: '跳过第 {page} 页(空向量)',
-    pdfPageImageError: '获取 PDF 页面图像失败: {message}',
-    internalServerError: '服务器内部错误',
-  },
-  ja: {
-    processingFile: 'ファイル処理中: {name} ({size})',
-    indexingComplete: 'インデックス完了: {id}',
-    vectorizingFile: 'ファイルベクトル化中: ',
-    searchQuery: '検索クエリ: ',
-    modelCall: '[モデル呼び出し] タイプ: {type}, Model: {model}, ユーザー: {user}',
-    memoryStatus: 'メモリ状態: ',
-    uploadSuccess: 'ファイルが正常にアップロードされました。バックグラウンドでインデックス処理を実行中です',
-    overlapAdjusted: 'オーバーラップサイズがChunk sizeの50%。自動的に {newSize} に調整されました',
-    environmentLimit: '環境変数の制限',
-    modelLimit: 'モデルの制限',
-    configLoaded: 'データベースからモデル設定を読み込みました: {name} ({id})',
-    batchSizeAdjusted: 'バッチサイズを {old} から {new} に調整しました (モデル制限: {limit})',
-    dimensionMismatch: 'モデル {id} の次元が一致しません: 期待値 {expected}, 実際 {actual}',
-    searchMetadataFailed: 'ユーザー {userId} のナレッジベース検索に失敗しました',
-    extractedTextTooLarge: '抽出されたテキストが大きいです: {size}MB',
-    preciseModeUnsupported: 'ファイル形式 {ext} はPrecise Modeをサポートしていません。Fast Modeにフォールバックします',
-    visionModelNotConfiguredFallback: 'ビジョンモデルが設定されていません。Fast Modeにフォールバックします',
-    visionModelInvalidFallback: 'ビジョンモデルの設定が無効です。Fast Modeにフォールバックします',
-    visionPipelineFailed: 'ビジョンパイプラインが失敗しました。Fast Modeにフォールバックします',
-    preciseModeComplete: 'Precise Mode内容抽出完了: {pages}ページ, コスト: ${cost}',
-    skippingEmptyVectorPage: '第 {page} ページの空ベクトルをスキップします',
-    pdfPageImageError: 'PDF ページの画像取得に失敗しました: {message}',
-    internalServerError: 'サーバー内部エラー',
-  },
-  en: {
-    processingFile: 'Processing file: {name} ({size})',
-    indexingComplete: 'Indexing complete: {id}',
-    vectorizingFile: 'Vectorizing file: ',
-    searchQuery: 'Search query: ',
-    modelCall: '[Model call] Type: {type}, Model: {model}, User: {user}',
-    memoryStatus: 'Memory status: ',
-    uploadSuccess: 'File uploaded successfully. Indexing in background',
-    overlapAdjusted: 'Overlap size exceeds 50% of chunk size. Auto-adjusted to {newSize}',
-    environmentLimit: 'Environment variable limit',
-    modelLimit: 'Model limit',
-    configLoaded: 'Model config loaded from DB: {name} ({id})',
-    batchSizeAdjusted: 'Batch size adjusted from {old} to {new} (Model limit: {limit})',
-    dimensionMismatch: 'Model {id} dimension mismatch: Expected {expected}, Actual {actual}',
-    searchMetadataFailed: 'Failed to search knowledge base for user {userId}',
-    extractedTextTooLarge: 'Extracted text is too large: {size}MB',
-    preciseModeUnsupported: 'Format {ext} not supported for precise mode. Falling back to fast mode',
-    visionModelNotConfiguredFallback: 'Vision model not configured. Falling back to fast mode',
-    visionModelInvalidFallback: 'Vision model config invalid. Falling back to fast mode',
-    visionPipelineFailed: 'Vision pipeline failed. Falling back to fast mode',
-    preciseModeComplete: 'Precise mode extraction complete: {pages} pages, cost: ${cost}',
-    skippingEmptyVectorPage: 'Skipping page {page} due to empty vector',
-    pdfPageImageError: 'Failed to retrieve PDF page image: {message}',
-    internalServerError: 'Internal server error',
-  }
+  zh: {},
+  ja: {},
+  en: {},
 };
 
 export const statusMessages = {
   zh: {
-    searching: '正在搜索知识库...',
-    noResults: '未找到相关知识,将基于一般知识回答...',
-    searchFailed: '知识库搜索失败,将基于一般知识回答...',
-    generatingResponse: '正在生成回答',
-    files: ' files',
-    notebooks: '个笔记本',
-    all: '全部',
-    items: '个',
-    searchResults: '搜索结果',
-    relevantInfoFound: '条相关信息找到',
-    searchHits: '搜索命中',
-    relevance: '相关度',
-    sourceFiles: '源文件',
-    searchScope: '搜索范围',
-    error: '错误',
-    creatingHistory: '创建新对话历史: ',
-    searchingModelById: '根据ID搜索模型: ',
-    searchModelFallback: '未找到指定的嵌入模型。使用第一个可用模型。',
-    noEmbeddingModelFound: '找不到嵌入模型设置',
-    usingEmbeddingModel: '使用的嵌入模型: ',
-    startingSearch: '开始搜索知识库...',
-    searchResultsCount: '搜索结果数: ',
-    searchFailedLog: '搜索失败',
-    modelCall: '[模型调用]',
-    chatStreamError: '聊天流错误',
-    assistStreamError: '辅助流错误',
-    file: '文件',
-    content: '内容',
-    userLabel: '用户',
-    assistantLabel: '助手',
-    intelligentAssistant: '您是智能写作助手。',
-    searchString: '搜索字符串: ',
-    embeddingModelIdNotProvided: '未提供嵌入模型ID',
-    generatingEmbeddings: '生成嵌入向量...',
-    embeddingsGenerated: '嵌入向量生成完成',
-    dimensions: '维度',
-    performingHybridSearch: '执行混合搜索...',
-    esSearchCompleted: 'ES搜索完成',
-    resultsCount: '结果数',
-    hybridSearchFailed: '混合搜索失败',
-    getContextForTopicFailed: '获取主题上下文失败',
-    noLLMConfigured: '用户未配置LLM模型',
-    simpleChatGenerationError: '简单聊天生成错误',
-    noMatchInKnowledgeGroup: '所选知识组中未找到相关内容,以下是基于模型的一般性回答:',
-    uploadTextSuccess: '笔记内容已接收。正在后台索引',
-    passwordChanged: '密码已成功修改',
-    userCreated: '用户已成功创建',
-    userInfoUpdated: '用户信息已更新',
-    userDeleted: '用户已删除',
-    pdfNoteTitle: 'PDF 笔记 - {date}',
-    noTextExtracted: '未提取到文本',
-    kbCleared: '知识库已清空',
-    fileDeleted: '文件已删除',
-    pageImageNotFoundDetail: '无法获取 PDF 第 {page} 页’的图像',
-    groupSyncSuccess: '文件分组已更新',
-    fileDeletedFromGroup: '文件已从分组中删除',
-    chunkConfigCorrection: '切片配置已修正: {warnings}',
-    noChunksGenerated: '文件 {id} 未生成任何切片',
-    chunkCountAnomaly: '实际切片数 {actual} 大幅超过预计值 {estimated},可能存在异常',
-    batchSizeExceeded: '批次 {index} 的大小 {actual} 超过推荐值 {limit},将拆分处理',
-    skippingEmptyVectorChunk: '跳过文本块 {index} (空向量)',
-    contextLengthErrorFallback: '批次处理发生上下文长度错误,降级到逐条处理模式',
-    chunkLimitExceededForceBatch: '切片数 {actual} 超过模型批次限制 {limit},强制进行批次处理',
-    noteContentRequired: '笔记内容是必填项',
-    imageAnalysisStarted: '正在使用模型 {id} 分析图像...',
-    batchAnalysisStarted: '正在分析 {count} 张图像...',
-    pageAnalysisFailed: '第 {page} 页分析失败',
-    visionSystemPrompt: '您是专业的文档分析助手。请分析此文档图像,并按以下要求以 JSON 格式返回:\n\n1. 提取所有可读文本(按阅读顺序,保持段落和格式)\n2. 识别图像/图表/表格(描述内容、含义和作用)\n3. 分析页面布局(仅文本/文本和图像混合/表格/图表等)\n4. 评估分析质量 (0-1)\n\n响应格式:\n{\n  "text": "完整的文本内容",\n  "images": [\n    {"type": "图表类型", "description": "详细描述", "position": 1}\n  ],\n  "layout": "布局说明",\n  "confidence": 0.95\n}',
-    visionModelCall: '[模型调用] 类型: Vision, 模型: {model}, 页面: {page}',
-    visionAnalysisSuccess: '✅ 视觉分析完成: {path}{page}, 文本长度: {textLen}, 图像数: {imgCount}, 布局: {layout}, 置信度: {confidence}%',
-    conversationHistoryNotFound: '对话历史不存在',
-    batchContextLengthErrorFallback: '小文件批次处理发生上下文长度错误,降级到逐条处理模式',
-    chunkProcessingFailed: '处理文本块 {index} 失败,已跳过: {message}',
-    singleTextProcessingComplete: '逐条文本处理完成: {count} 个切片',
-    fileVectorizationComplete: '文件 {id} 向量化完成。共处理 {count} 个文本块。最终内存: {memory}MB',
-    fileVectorizationFailed: '文件 {id} 向量化失败',
-    batchProcessingStarted: '开始批次处理: {count} 个项目',
-    batchProcessingProgress: '正在处理批次 {index}/{total}: {count} 个项目',
-    batchProcessingComplete: '批次处理完成: {count} 个项目,耗时 {duration}s',
-    onlyFailedFilesRetryable: '仅允许重试失败的文件 (当前状态: {status})',
-    emptyFileRetryFailed: '文件内容为空,无法重试。请重新上传文件。',
-    ragSystemPrompt: '您是专业的知识库助手。请根据以下提供的文档内容回答用户的问题。',
-    ragRules: '## 规则:\n1. 仅根据提供的文档内容进行回答,请勿编造信息。\n2. 如果文档中没有相关信息,请告知用户。\n3. 请在回答中注明信息来源。格式:[文件名.扩展子]\n4. 如果多个文档中的信息存在矛盾,请进行综合分析或解释不同的观点。\n5. 请使用{lang}进行回答。',
-    ragDocumentContent: '## 文档内容:',
-    ragUserQuestion: '## 用户问题:',
-    ragAnswer: '## 回答:',
-    ragSource: '### 来源:{fileName}',
-    ragSegment: '片段 {index} (相似度: {score}):',
-    ragNoDocumentFound: '未找到相关文档。',
-    queryExpansionPrompt: '您是一个搜索助手。请为以下用户查询生成3个不同的演变版本,以帮助在向量搜索中获得更好的结果。每个版本应包含不同的关键词或表达方式,但保持原始意思。直接输出3行查询,不要有数字或编号:\n\n查询:{query}',
-    hydePrompt: '请为以下用户问题写一段简短、事实性的假设回答(约100字)。不要包含任何引导性文字(如“基于我的分析...”),直接输出答案内容。\n\n问题:{query}',
+    noMatchInKnowledgeGroup: '所选知识组中未找到相关内容',
   },
   ja: {
-    searching: 'ナレッジベースを検索中...',
-    noResults: '関連する知識が見つかりませんでした。一般的な知識に基づいて回答します...',
-    searchFailed: 'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
-    generatingResponse: '回答を生成中',
-    files: '個のファイル',
-    notebooks: '個のノートブック',
-    all: 'すべて',
-    items: '件',
-    searchResults: 'Search results',
-    relevantInfoFound: '件の関連情報が見つかりました',
-    searchHits: '検索ヒット',
-    relevance: '関連度',
-    sourceFiles: '元ファイル',
-    searchScope: '検索範囲',
-    error: 'エラー',
-    creatingHistory: '新規対話履歴を作成: ',
-    searchingModelById: 'selectedEmbeddingId に基づいてモデルを検索: ',
-    searchModelFallback: '指定された埋め込みモデルが見つかりません。最初に使用可能なモデルを使用します。',
-    noEmbeddingModelFound: '埋め込みモデルの設定が見つかりません',
-    usingEmbeddingModel: '使用する埋め込みModel: ',
-    startingSearch: 'ナレッジベースの検索を開始...',
-    searchResultsCount: 'Search results数: ',
-    searchFailedLog: '検索失敗',
-    chatStreamError: 'チャットストリームエラー',
-    assistStreamError: 'アシストストリームエラー',
-    file: 'ファイル',
-    content: '内容',
-    userLabel: 'ユーザー',
-    assistantLabel: 'アシスタント',
-    intelligentAssistant: 'あなたはインテリジェントな執筆アシスタントです。',
-    searchString: '検索文字列: ',
-    embeddingModelIdNotProvided: 'Embedding model IDが提供されていません',
-    generatingEmbeddings: '埋め込みベクトルを生成中...',
-    embeddingsGenerated: '埋め込みベクトルの生成が完了しました',
-    dimensions: '次元数',
-    performingHybridSearch: 'ES 混合検索を実行中...',
-    esSearchCompleted: 'ES 検索が完了しました',
-    resultsCount: '結果数',
-    hybridSearchFailed: '混合検索に失敗しました',
-    getContextForTopicFailed: 'トピックのコンテキスト取得に失敗しました',
-    noLLMConfigured: 'ユーザーにLLMモデルが設定されていません',
-    simpleChatGenerationError: '簡易チャット生成エラー',
-    noMatchInKnowledgeGroup: '選択された知識グループに関連する内容が見つかりませんでした。以下はモデルに基づく一般的な回答です:',
-    uploadTextSuccess: 'ノート内容を受け取りました。バックグラウンドでインデックス処理を実行中です',
-    passwordChanged: 'パスワードが正常に変更されました',
-    userCreated: 'ユーザーが正常に作成されました',
-    userInfoUpdated: 'ユーザー情報が更新されました',
-    userDeleted: 'ユーザーが削除されました',
-    pdfNoteTitle: 'PDF ノート - {date}',
-    noTextExtracted: 'テキストが抽出されませんでした',
-    kbCleared: 'ナレッジベースが空になりました',
-    fileDeleted: 'ファイルが削除されました',
-    pageImageNotFoundDetail: 'PDF の第 {page} ページの画像を取得できません',
-    groupSyncSuccess: 'ファイルグループが更新されました',
-    fileDeletedFromGroup: 'ファイルがグループから削除されました',
-    chunkConfigCorrection: 'Chunk configurationの修正: {warnings}',
-    noChunksGenerated: 'ファイル {id} からテキストチャンクが生成されませんでした',
-    chunkCountAnomaly: '実際のチャンク数 {actual} が推定値 {estimated} を大幅に超えています。異常がある可能性があります',
-    batchSizeExceeded: 'バッチ {index} のサイズ {actual} が推奨値 {limit} 。分割して処理します',
-    skippingEmptyVectorChunk: '空ベクトルのテキストブロック {index} をスキップします',
-    contextLengthErrorFallback: 'バッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします',
-    chunkLimitExceededForceBatch: 'チャンク数 {actual} がモデルのバッチ制限 {limit} 。強制的にバッチ処理を行います',
-    noteContentRequired: 'ノート内容は必須です',
-    imageAnalysisStarted: 'モデル {id} で画像をAnalyzing...',
-    batchAnalysisStarted: '{count} 枚の画像をAnalyzing...',
-    pageAnalysisFailed: '第 {page} ページの分析に失敗しました',
-    visionSystemPrompt: 'あなたは専門的なドキュメント分析アシスタントです。このドキュメント画像を分析し、以下の要求に従って JSON 形式で返してください:\n\n1. すべての読み取り可能なテキストを抽出(読み取り順序に従い、段落と形式を保持)\n2. 画像/グラフ/表の識別(内容、意味、役割を記述)\n3. ページレイアウトの分析(テキストのみ/テキストと画像の混合/表/グラフなど)\n4. 分析品質の評価(0-1)\n\nレスポンス形式:\n{\n  "text": "完全なテキスト内容",\n  "images": [\n    {"type": "グラフの種類", "description": "詳細な記述", "position": 1}\n  ],\n  "layout": "レイアウトの説明",\n  "confidence": 0.95\n}',
-    visionModelCall: '[モデル呼び出し] タイプ: Vision, Model: {model}, ページ: {page}',
-    visionAnalysisSuccess: '✅ Vision 分析完了: {path}{page}, テキスト長: {textLen}文字, 画像数: {imgCount}, レイアウト: {layout}, 信頼度: {confidence}%',
-    conversationHistoryNotFound: '会話履歴が存在しません',
-    batchContextLengthErrorFallback: '小ファイルバッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします',
-    chunkProcessingFailed: 'テキストブロック {index} の処理に失敗しました。スキップします: {message}',
-    singleTextProcessingComplete: '単一テキスト処理完了: {count} チャンク',
-    fileVectorizationComplete: 'ファイル {id} ベクトル化完了。{count} 個のテキストブロックを処理しました。最終メモリ: {memory}MB',
-    fileVectorizationFailed: 'ファイル {id} ベクトル化失敗',
-    batchProcessingStarted: 'バッチ処理を開始します: {count} アイテム',
-    batchProcessingProgress: 'バッチ {index}/{total} を処理中: {count} 個のアイテム',
-    batchProcessingComplete: 'バッチ処理完了: {count} アイテム, 所要時間 {duration}s',
-    onlyFailedFilesRetryable: '失敗したファイルのみ再試行可能です (現在のステータス: {status})',
-    emptyFileRetryFailed: 'ファイル内容が空です。再試行できません。ファイルを再アップロードしてください。',
-    ragSystemPrompt: 'あなたは専門的なナレッジベースアシスタントです。以下の提供されたドキュメントの内容に基づいて、ユーザーの質問に答えてください。',
-    ragRules: '## ルール:\n1. 提供されたドキュメントの内容のみに基づいて回答し、情報を捏造しないでください。\n2. ドキュメントに関連情報がない場合は、その旨をユーザーに伝えてください。\n3. 回答には情報源を明記してください。形式:[ファイル名.拡張子]\n4. 複数のドキュメントで情報が矛盾している場合は、総合的に分析するか、異なる視点を説明してください。\n5. {lang}で回答してください。',
-    ragDocumentContent: '## ドキュメント内容:',
-    ragUserQuestion: '## ユーザーの質問:',
-    ragAnswer: '## 回答:',
-    ragSource: '### ソース:{fileName}',
-    ragSegment: 'セグメント {index} (類似度: {score}):',
-    ragNoDocumentFound: '関連するドキュメントが見つかりませんでした。',
-    queryExpansionPrompt: 'あなたは検索アシスタントです。以下のユーザーのクエリに対して、ベクトル検索でより良い結果を得るために、3つの異なるバリエーションを生成してください。各バリエーションは異なるキーワードや表現を使用しつつ、元の意味を維持する必要があります。数字やプレフィックスなしで、3行のクエリを直接出力してください:\n\nクエリ:{query}',
-    hydePrompt: '以下のユーザーの質問に対して、簡潔で事実に基づいた仮説的な回答(約200文字)を書いてください。「私の分析によると...」などの導入文は含めず、回答内容のみを直接出力してください。\n\n質問:{query}',
+    noMatchInKnowledgeGroup: '選択された知識グループに関連する内容が見つかりませんでした',
   },
   en: {
-    searching: 'Searching knowledge base...',
-    noResults: 'No relevant knowledge found, will answer based on general knowledge...',
-    searchFailed: 'Knowledge base search failed, will answer based on general knowledge...',
-    generatingResponse: 'Generating response',
-    files: ' files',
-    notebooks: ' notebooks',
-    all: 'all',
-    items: '',
-    searchResults: 'Search results',
-    relevantInfoFound: ' relevant info found',
-    searchHits: 'Search hits',
-    relevance: 'Relevance',
-    sourceFiles: 'Source files',
-    searchScope: 'Search scope',
-    error: 'Error',
-    creatingHistory: 'Creating new chat history: ',
-    searchingModelById: 'Searching model by ID: ',
-    searchModelFallback: 'Specified embedding model not found. Using first available model.',
-    noEmbeddingModelFound: 'No embedding model settings found',
-    usingEmbeddingModel: 'Using embedding model: ',
-    startingSearch: 'Starting knowledge base search...',
-    searchResultsCount: 'Search results count: ',
-    searchFailedLog: 'Search failed',
-    chatStreamError: 'Chat stream error',
-    assistStreamError: 'Assist stream error',
-    file: 'File',
-    content: 'Content',
-    userLabel: 'User',
-    assistantLabel: 'Assistant',
-    intelligentAssistant: 'You are an intelligent writing assistant.',
-    searchString: 'Search string: ',
-    embeddingModelIdNotProvided: 'Embedding model ID not provided',
-    generatingEmbeddings: 'Generating embeddings...',
-    embeddingsGenerated: 'Embeddings generated successfully',
-    dimensions: 'dimensions',
-    performingHybridSearch: 'Performing hybrid search...',
-    esSearchCompleted: 'ES search completed',
-    resultsCount: 'Results count',
-    hybridSearchFailed: 'Hybrid search failed',
-    getContextForTopicFailed: 'getContextForTopic failed',
-    noLLMConfigured: 'No LLM model configured for user',
-    simpleChatGenerationError: 'Simple chat generation error',
-    noMatchInKnowledgeGroup: 'No relevant content found in the selected knowledge group. The following is a general answer based on the model:',
-    uploadTextSuccess: 'Note content received. Indexing in background',
-    passwordChanged: 'Password changed successfully',
-    userCreated: 'User created successfully',
-    userInfoUpdated: 'User information updated',
-    userDeleted: 'User deleted',
-    pdfNoteTitle: 'PDF Note - {date}',
-    noTextExtracted: 'No text extracted',
-    kbCleared: 'Knowledge base cleared',
-    fileDeleted: 'File deleted',
-    pageImageNotFoundDetail: 'Could not retrieve image for PDF page {page}',
-    groupSyncSuccess: 'File groups updated',
-    fileDeletedFromGroup: 'File removed from group',
-    chunkConfigCorrection: 'Chunk config corrected: {warnings}',
-    noChunksGenerated: 'No chunks generated for file {id}',
-    chunkCountAnomaly: 'Actual chunk count {actual} significantly exceeds estimate {estimated}. Possible anomaly.',
-    batchSizeExceeded: 'Batch {index} size {actual} exceeds recommended limit {limit}. Splitting for processing.',
-    skippingEmptyVectorChunk: 'Skipping text block {index} due to empty vector',
-    contextLengthErrorFallback: 'Context length error occurred during batch processing. Downgrading to single processing mode.',
-    chunkLimitExceededForceBatch: 'Chunk count {actual} exceeds model batch limit {limit}. Forcing batch processing.',
-    noteContentRequired: 'Note content is required',
-    imageAnalysisStarted: 'Analyzing image with model {id}...',
-    batchAnalysisStarted: 'Batch analyzing {count} images...',
-    pageAnalysisFailed: 'Failed to analyze page {page}',
-    visionSystemPrompt: 'You are a professional document analysis assistant. Analyze this document image and return in JSON format according to these requirements:\n\n1. Extract all readable text (follow reading order, maintain paragraphs and formatting)\n2. Identify images/graphs/tables (describe content, meaning, and role)\n3. Analyze page layout (text only/mixed/table/graph, etc.)\n4. Evaluate analysis quality (0-1)\n\nResponse format:\n{\n  "text": "full text content",\n  "images": [\n    {"type": "graph type", "description": "detailed description", "position": 1}\n  ],\n  "layout": "layout description",\n  "confidence": 0.95\n}',
-    visionModelCall: '[Model Call] Type: Vision, Model: {model}, Page: {page}',
-    visionAnalysisSuccess: '✅ Vision analysis complete: {path}{page}, Text length: {textLen}, Images: {imgCount}, Layout: {layout}, Confidence: {confidence}%',
-    conversationHistoryNotFound: 'Conversation history not found',
-    batchContextLengthErrorFallback: 'Context length error occurred during small file batch processing. Downgrading to single processing mode.',
-    chunkProcessingFailed: 'Failed to process text block {index}. Skipping: {message}',
-    singleTextProcessingComplete: 'Single text processing complete: {count} chunks',
-    fileVectorizationComplete: 'File {id} vectorization complete. Processed {count} text blocks. Final memory: {memory}MB',
-    fileVectorizationFailed: 'File {id} vectorization failed',
-    batchProcessingStarted: 'Batch processing started: {count} items',
-    batchProcessingProgress: 'Processing batch {index}/{total}: {count} items',
-    batchProcessingComplete: 'Batch processing complete: {count} items in {duration}s',
-    onlyFailedFilesRetryable: 'Only failed files can be retried (current status: {status})',
-    emptyFileRetryFailed: 'File content is empty. Cannot retry. Please re-upload the file.',
-    ragSystemPrompt: 'You are a professional knowledge base assistant. Please answer the user\'s question based on the provided document content below.',
-    ragRules: '## Rules:\n1. Answer based only on the provided document content; do not fabricate information.\n2. If there is no relevant information in the documents, please inform the user.\n3. Clearly state the sources in your answer. Format: [filename.ext]\n4. If information in different documents is contradictory, analyze it comprehensively or explain the different perspectives.\n5. Please answer in {lang}.',
-    ragDocumentContent: '## Document Content:',
-    ragUserQuestion: '## User Question:',
-    ragAnswer: '## Answer:',
-    ragSource: '### Source: {fileName}',
-    ragSegment: 'Segment {index} (Similarity: {score}):',
-    ragNoDocumentFound: 'No relevant documents found.',
-    queryExpansionPrompt: 'You are a search assistant. Please generate 3 different variations of the following user query to help get better results in vector search. Each variation should use different keywords or phrasing while maintaining the original meaning. Output the 3 queries directly as 3 lines, without numbers or prefixes:\n\nQuery: {query}',
-    hydePrompt: 'Please write a brief, factual hypothetical answer (about 100 words) to the following user question. Do not include any introductory text (like "Based on my analysis..."), just output the answer content directly.\n\nQuestion: {query}',
-  }
+    noMatchInKnowledgeGroup: 'No relevant content found in the selected knowledge group',
+  },
 };

+ 6 - 1
server/src/import-task/import-task.controller.ts

@@ -1,4 +1,4 @@
-import { Controller, Post, Get, Delete, Param, Body, Request, UseGuards, Query } from '@nestjs/common';
+import { Controller, Post, Get, Delete, Param, Body, Request, UseGuards } from '@nestjs/common';
 import { ImportTaskService } from './import-task.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
@@ -44,4 +44,9 @@ export class ImportTaskController {
     async delete(@Param('id') id: string, @Request() req) {
         return this.taskService.delete(id, req.user.id);
     }
+
+    @Delete(':id')
+    async delete(@Param('id') id: string, @Request() req) {
+        return this.taskService.delete(id, req.user.id);
+    }
 }

+ 12 - 2
server/src/import-task/import-task.service.ts

@@ -6,6 +6,7 @@ import { ImportTask } from './import-task.entity';
 import { Cron, CronExpression } from '@nestjs/schedule';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
 import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
+import { I18nService } from '../i18n/i18n.service';
 import * as fs from 'fs';
 import * as path from 'path';
 
@@ -26,6 +27,7 @@ export class ImportTaskService {
         private kbService: KnowledgeBaseService,
         private groupService: KnowledgeGroupService,
         private configService: ConfigService,
+        private i18nService: I18nService,
     ) { }
 
     async create(taskData: Partial<ImportTask>): Promise<ImportTask> {
@@ -69,6 +71,14 @@ export class ImportTaskService {
         await this.taskRepository.remove(task);
     }
 
+    async delete(taskId: string, userId: string): Promise<void> {
+        const task = await this.taskRepository.findOne({ where: { id: taskId, userId } });
+        if (!task) {
+            throw new Error(this.i18nService.getMessage('importTaskNotFound'));
+        }
+        await this.taskRepository.remove(task);
+    }
+
     @Cron(CronExpression.EVERY_MINUTE)
     async handleScheduledTasks() {
         this.logger.debug('Checking for scheduled import tasks...');
@@ -106,7 +116,7 @@ export class ImportTaskService {
 
         try {
             if (!fs.existsSync(task.sourcePath)) {
-                throw new Error(`Directory not found: ${task.sourcePath}`);
+                throw new Error(this.i18nService.formatMessage('sourcePathNotFound', { path: task.sourcePath }));
             }
 
             const uploadPath = this.configService.get<string>('UPLOAD_FILE_PATH', './uploads');
@@ -183,7 +193,7 @@ export class ImportTaskService {
                     groupId = group.id;
                     await this.appendLog(taskId, `Created new group: ${task.targetGroupName}`);
                 } else if (!groupId) {
-                    throw new Error('No target group specified');
+                    throw new Error(this.i18nService.getMessage('targetGroupRequired'));
                 }
 
                 await this.appendLog(taskId, `Scanning directory: ${task.sourcePath}`);

+ 66 - 47
server/src/knowledge-base/chunk-config.service.ts

@@ -2,9 +2,17 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { TenantService } from '../tenant/tenant.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
-
-
+// import { UserSettingService } from '../user-setting/user-setting.service';
+
+/**
+ * Chunk config service
+ * Responsible for validating and managing chunk parameters to ensure they conform to model limits and environment variable settings
+ *
+ * Priority of limits:
+ * 1. Environment variables (MAX_CHUNK_SIZE, MAX_OVERLAP_SIZE)
+ * 2. Model settings in database (maxInputTokens, maxBatchSize)
+ * 3. Default values
+ */
 import {
   DEFAULT_CHUNK_SIZE,
   MIN_CHUNK_SIZE,
@@ -20,18 +28,18 @@ import { I18nService } from '../i18n/i18n.service';
 export class ChunkConfigService {
   private readonly logger = new Logger(ChunkConfigService.name);
 
-
+  // Default settings
   private readonly DEFAULTS = {
     chunkSize: DEFAULT_CHUNK_SIZE,
     chunkOverlap: DEFAULT_CHUNK_OVERLAP,
     minChunkSize: MIN_CHUNK_SIZE,
     minChunkOverlap: MIN_CHUNK_OVERLAP,
-    maxOverlapRatio: DEFAULT_MAX_OVERLAP_RATIO,
-    maxBatchSize: DEFAULT_MAX_BATCH_SIZE,
-    expectedDimensions: DEFAULT_VECTOR_DIMENSIONS,
+    maxOverlapRatio: DEFAULT_MAX_OVERLAP_RATIO,  // Overlap up to 50% of chunk size
+    maxBatchSize: DEFAULT_MAX_BATCH_SIZE,    // Default batch limit
+    expectedDimensions: DEFAULT_VECTOR_DIMENSIONS, // Default vector dimensions
   };
 
-
+  // Upper limits set by environment variables (used first)
   private readonly envMaxChunkSize: number;
   private readonly envMaxOverlapSize: number;
 
@@ -40,9 +48,8 @@ export class ChunkConfigService {
     private modelConfigService: ModelConfigService,
     private i18nService: I18nService,
     private tenantService: TenantService,
-    private userSettingService: UserSettingService,
   ) {
-
+    // Load global limit settings from environment variables
     this.envMaxChunkSize = parseInt(
       this.configService.get<string>('MAX_CHUNK_SIZE', '8191')
     );
@@ -55,7 +62,9 @@ export class ChunkConfigService {
     );
   }
 
-
+  /**
+   * Get model limit settings (read from database)
+   */
   async getModelLimits(modelId: string, userId: string, tenantId?: string): Promise<{
     maxInputTokens: number;
     maxBatchSize: number;
@@ -69,20 +78,20 @@ export class ChunkConfigService {
       throw new BadRequestException(this.i18nService.formatMessage('embeddingModelNotFound', { id: modelId }));
     }
 
-
+    // Get limits from database fields and fill with defaults
     const maxInputTokens = modelConfig.maxInputTokens || this.envMaxChunkSize;
     const maxBatchSize = modelConfig.maxBatchSize || this.DEFAULTS.maxBatchSize;
     const expectedDimensions = modelConfig.dimensions || parseInt(this.configService.get('DEFAULT_VECTOR_DIMENSIONS', String(this.DEFAULTS.expectedDimensions)));
-    const providerName = modelConfig.providerName || 'Unknown';
+    const providerName = modelConfig.providerName || 'unknown';
     const isVectorModel = modelConfig.isVectorModel || false;
 
     this.logger.log(
       this.i18nService.formatMessage('configLoaded', { name: modelConfig.name, id: modelConfig.modelId }) + '\n' +
       `  - Provider: ${providerName}\n` +
-      `  - Token Limit: ${maxInputTokens}\n` +
-      `  - Batch Limit: ${maxBatchSize}\n` +
-      `  - Vector Dimensions: ${expectedDimensions}\n` +
-      `  - Is Vector Model: ${isVectorModel}`,
+      `  - Token limit: ${maxInputTokens}\n` +
+      `  - Batch limit: ${maxBatchSize}\n` +
+      `  - Vector dimensions: ${expectedDimensions}\n` +
+      `  - Is vector model: ${isVectorModel}`,
     );
 
     return {
@@ -94,7 +103,10 @@ export class ChunkConfigService {
     };
   }
 
-
+  /**
+   * Validate and fix chunk config
+   * Priority: Environment variable limits > Model limits > User settings
+   */
   async validateChunkConfig(
     chunkSize: number,
     chunkOverlap: number,
@@ -111,7 +123,7 @@ export class ChunkConfigService {
     const warnings: string[] = [];
     const limits = await this.getModelLimits(modelId, userId, tenantId);
 
-
+    // 1. Calculate final limits (choose smaller of env var and model limit)
     const effectiveMaxChunkSize = Math.min(
       this.envMaxChunkSize,
       limits.maxInputTokens,
@@ -122,7 +134,7 @@ export class ChunkConfigService {
       Math.floor(effectiveMaxChunkSize * this.DEFAULTS.maxOverlapRatio),
     );
 
-
+    // 2. Validate chunk size upper limit
     if (chunkSize > effectiveMaxChunkSize) {
       const reason =
         this.envMaxChunkSize < limits.maxInputTokens
@@ -139,7 +151,7 @@ export class ChunkConfigService {
       chunkSize = effectiveMaxChunkSize;
     }
 
-
+    // 3. Validate chunk size lower limit
     if (chunkSize < this.DEFAULTS.minChunkSize) {
       warnings.push(
         this.i18nService.formatMessage('chunkUnderflow', {
@@ -150,7 +162,7 @@ export class ChunkConfigService {
       chunkSize = this.DEFAULTS.minChunkSize;
     }
 
-
+    // 4. Validate overlap size upper limit (env var first)
     if (chunkOverlap > effectiveMaxOverlapSize) {
       warnings.push(
         this.i18nService.formatMessage('overlapOverflow', {
@@ -161,7 +173,7 @@ export class ChunkConfigService {
       chunkOverlap = effectiveMaxOverlapSize;
     }
 
-
+    // 5. Validate overlap doesn't exceed 50% of chunk size
     const maxOverlapByRatio = Math.floor(
       chunkSize * this.DEFAULTS.maxOverlapRatio,
     );
@@ -185,9 +197,9 @@ export class ChunkConfigService {
       chunkOverlap = this.DEFAULTS.minChunkOverlap;
     }
 
-
-
-    const safetyMargin = 0.8;
+    // 6. Add safety check for batch processing
+    // During batch processing, ensure total length of multiple texts doesn't exceed model limits
+    const safetyMargin = 0.8; // 80% safety margin to leave space for batch processing
     const safeChunkSize = Math.floor(effectiveMaxChunkSize * safetyMargin);
 
     if (chunkSize > safeChunkSize) {
@@ -200,9 +212,9 @@ export class ChunkConfigService {
       );
     }
 
-
+    // 7. Check if estimated chunk count is reasonable
     const estimatedChunkCount = this.estimateChunkCount(
-      1000000,
+      1000000, // Assume 1MB text
       chunkSize,
     );
 
@@ -221,7 +233,9 @@ export class ChunkConfigService {
     };
   }
 
-
+  /**
+   * Get recommended batch size
+   */
   async getRecommendedBatchSize(
     modelId: string,
     userId: string,
@@ -230,11 +244,11 @@ export class ChunkConfigService {
   ): Promise<number> {
     const limits = await this.getModelLimits(modelId, userId, tenantId);
 
-
+    // Choose smaller of configured value and model limit
     const recommended = Math.min(
       currentBatchSize,
       limits.maxBatchSize,
-      200,
+      200, // Safety upper limit
     );
 
     if (recommended < currentBatchSize) {
@@ -247,16 +261,20 @@ export class ChunkConfigService {
       );
     }
 
-    return Math.max(10, recommended);
+    return Math.max(10, recommended); // Minimum 10
   }
 
-
+  /**
+   * Estimate chunk count
+   */
   estimateChunkCount(textLength: number, chunkSize: number): number {
     const chunkSizeInChars = chunkSize * 4; // 1 token ≈ 4 chars
     return Math.ceil(textLength / chunkSizeInChars);
   }
 
-
+  /**
+   * Validate vector dimensions
+   */
   async validateDimensions(
     modelId: string,
     userId: string,
@@ -279,7 +297,9 @@ export class ChunkConfigService {
     return true;
   }
 
-
+  /**
+   * Get config summary (for logging)
+   */
   async getConfigSummary(
     chunkSize: number,
     chunkOverlap: number,
@@ -291,14 +311,17 @@ export class ChunkConfigService {
 
     return [
       `Model: ${modelId}`,
-      `Chunk size: ${chunkSize} tokens (Limit: ${limits.maxInputTokens})`,
+      `Chunk size: ${chunkSize} tokens (limit: ${limits.maxInputTokens})`,
       `Overlap size: ${chunkOverlap} tokens`,
       `Batch size: ${limits.maxBatchSize}`,
-      `Vector Dimensions: ${limits.expectedDimensions}`,
+      `Vector dimensions: ${limits.expectedDimensions}`,
     ].join(', ');
   }
 
-
+  /**
+   * Get config limits for frontend
+   * Used for frontend slider max value settings
+   */
   async getFrontendLimits(
     modelId: string,
     userId: string,
@@ -318,29 +341,25 @@ export class ChunkConfigService {
   }> {
     const limits = await this.getModelLimits(modelId, userId, tenantId);
 
-
+    // Calculate final limits (choose smaller of env var and model limit)
     const maxChunkSize = Math.min(this.envMaxChunkSize, limits.maxInputTokens);
     const maxOverlapSize = Math.min(
       this.envMaxOverlapSize,
       Math.floor(maxChunkSize * this.DEFAULTS.maxOverlapRatio),
     );
 
-
+    // Get model config name
     const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || '');
     const modelName = modelConfig?.name || 'Unknown';
 
-
+    // Get defaults from tenant or user settings
     let defaultChunkSize = this.DEFAULTS.chunkSize;
     let defaultOverlapSize = this.DEFAULTS.chunkOverlap;
 
     if (tenantId) {
       const tenantSettings = await this.tenantService.getSettings(tenantId);
-      if (tenantSettings.chunkSize) defaultChunkSize = tenantSettings.chunkSize;
-      if (tenantSettings.chunkOverlap) defaultOverlapSize = tenantSettings.chunkOverlap;
-    } else {
-      const userSettings = await this.userSettingService.findOrCreate(userId);
-      if (userSettings.chunkSize) defaultChunkSize = userSettings.chunkSize;
-      if (userSettings.chunkOverlap) defaultOverlapSize = userSettings.chunkOverlap;
+      if (tenantSettings?.chunkSize) defaultChunkSize = tenantSettings.chunkSize;
+      if (tenantSettings?.chunkOverlap) defaultOverlapSize = tenantSettings.chunkOverlap;
     }
 
     return {

+ 44 - 38
server/src/knowledge-base/embedding.service.ts

@@ -1,6 +1,7 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { ModelConfigService } from '../model-config/model-config.service';
+import { I18nService } from '../i18n/i18n.service';
 
 export interface EmbeddingResponse {
   data: Array<{
@@ -22,6 +23,7 @@ export class EmbeddingService {
   constructor(
     private modelConfigService: ModelConfigService,
     private configService: ConfigService,
+    private i18nService: I18nService,
   ) {
     this.defaultDimensions = parseInt(
       this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', '2560'),
@@ -35,7 +37,7 @@ export class EmbeddingService {
     embeddingModelConfigId: string,
     tenantId?: string,
   ): Promise<number[][]> {
-    this.logger.log(`Generating embedding vectors for ${texts.length} texts`);
+    this.logger.log(`Generating embeddings for ${texts.length} texts`);
 
     const modelConfig = await this.modelConfigService.findOne(
       embeddingModelConfigId,
@@ -43,26 +45,26 @@ export class EmbeddingService {
       tenantId || 'default',
     );
     if (!modelConfig || modelConfig.type !== 'embedding') {
-      throw new Error(`Embedded model configuration ${embeddingModelConfigId} not found`);
+      throw new Error(this.i18nService.formatMessage('embeddingModelNotFound', { id: embeddingModelConfigId }));
     }
 
     if (modelConfig.isEnabled === false) {
-      throw new Error(`Unable to generate embedding vector because model ${modelConfig.name} is disabled`);
+      throw new Error(`Model ${modelConfig.name} is disabled and cannot generate embeddings`);
     }
 
-    // API key is optional - allow local models
+    // API key is optional - allows local models
 
     if (!modelConfig.baseUrl) {
-      throw new Error(`baseUrl not set for model ${modelConfig.name}`);
+      throw new Error(`Model ${modelConfig.name} does not have baseUrl configured`);
     }
 
-    
+    // Determine max batch size based on model name
     const maxBatchSize = this.getMaxBatchSizeForModel(modelConfig.modelId, modelConfig.maxBatchSize);
 
-    
+    // Split processing if batch size exceeds limit
     if (texts.length > maxBatchSize) {
       this.logger.log(
-        `Text count ${texts.length} exceeds model batch limit ${maxBatchSize}, splitting into batches`
+        `Splitting ${texts.length} texts into batches (model batch limit: ${maxBatchSize})`
       );
 
       const allEmbeddings: number[][] = [];
@@ -78,15 +80,15 @@ export class EmbeddingService {
 
         allEmbeddings.push(...batchEmbeddings);
 
-        
+        // Wait briefly to avoid API rate limiting
         if (i + maxBatchSize < texts.length) {
-          await new Promise(resolve => setTimeout(resolve, 100)); 
+          await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms
         }
       }
 
       return allEmbeddings;
     } else {
-      
+      // Normal processing (within batch size)
       return await this.getEmbeddingsForBatch(
         texts,
         userId,
@@ -96,21 +98,25 @@ export class EmbeddingService {
     }
   }
 
-  
+  /**
+   * Determine max batch size based on model ID
+   */
   private getMaxBatchSizeForModel(modelId: string, configuredMaxBatchSize?: number): number {
-    
+    // Model-specific batch size limits
     if (modelId.includes('text-embedding-004') || modelId.includes('text-embedding-v4') ||
       modelId.includes('text-embedding-ada-002')) {
-      return Math.min(10, configuredMaxBatchSize || 100); 
+      return Math.min(10, configuredMaxBatchSize || 100); // Google limit: 10
     } else if (modelId.includes('text-embedding-3') || modelId.includes('text-embedding-003')) {
-      return Math.min(2048, configuredMaxBatchSize || 2048); 
+      return Math.min(2048, configuredMaxBatchSize || 2048); // OpenAI v3 limit: 2048
     } else {
-      
+      // Default: smaller of configured max or 100
       return Math.min(configuredMaxBatchSize || 100, 100);
     }
   }
 
-  
+  /**
+   * Process single batch embedding
+   */
   private async getEmbeddingsForBatch(
     texts: string[],
     userId: string,
@@ -132,8 +138,8 @@ export class EmbeddingService {
           this.logger.error(`Embedding API timeout after 60s: ${apiUrl}`);
         }, 60000); // 60s timeout
 
-        this.logger.log(`[Model Call] Type: Embedding, Model: ${modelConfig.name} (${modelConfig.modelId}), User: ${userId}, Text Count: ${texts.length}`);
-        this.logger.log(`Calling Embedding API (Attempt ${attempt}/${MAX_RETRIES}): ${apiUrl}`);
+        this.logger.log(`[Model call] Type: Embedding, Model: ${modelConfig.name} (${modelConfig.modelId}), User: ${userId}, Text count: ${texts.length}`);
+        this.logger.log(`Calling embedding API (attempt ${attempt}/${MAX_RETRIES}): ${apiUrl}`);
 
         let response;
         try {
@@ -157,14 +163,14 @@ export class EmbeddingService {
         if (!response.ok) {
           const errorText = await response.text();
 
-          
+          // Detect batch size limit error
           if (errorText.includes('batch size is invalid') || errorText.includes('batch_size') ||
             errorText.includes('invalid') || errorText.includes('larger than')) {
             this.logger.warn(
-              `Batch size limit error detected. Halving batch size and retrying: ${maxBatchSize} -> ${Math.floor(maxBatchSize / 2)}`
+              `Batch size limit error detected. Splitting batch in half and retrying: ${maxBatchSize} -> ${Math.floor(maxBatchSize / 2)}`
             );
 
-            
+            // Split batch into smaller units and retry
             if (texts.length > 1) {
               const midPoint = Math.floor(texts.length / 2);
               const firstHalf = texts.slice(0, midPoint);
@@ -177,51 +183,51 @@ export class EmbeddingService {
             }
           }
 
-          
+          // Detect context length excess error
           if (errorText.includes('context length') || errorText.includes('exceeds')) {
             const avgLength = texts.reduce((s, t) => s + t.length, 0) / texts.length;
             const totalLength = texts.reduce((s, t) => s + t.length, 0);
             this.logger.error(
-              `Text length exceeded limit: Input ${texts.length} texts,` +
-              `Total ${totalLength} characters, average ${Math.round(avgLength)} characters, ` +
-              `Model limit: ${modelConfig.maxInputTokens || 8192} tokens`
+              `Text length exceeds limit: ${texts.length} texts, ` +
+              `total ${totalLength} characters, average ${Math.round(avgLength)} characters, ` +
+              `model limit: ${modelConfig.maxInputTokens || 8192} tokens`
             );
             throw new Error(
-              `Text length is a limitation of the model. ` +
-              `Currently: ${texts.length} texts totaling ${totalLength} characters, ` +
-              `Model limit: ${modelConfig.maxInputTokens || 8192} tokens. ` +
+              `Text length exceeds model limit. ` +
+              `Current: ${texts.length} texts with total ${totalLength} characters, ` +
+              `model limit: ${modelConfig.maxInputTokens || 8192} tokens. ` +
               `Advice: Reduce chunk size or batch size`
             );
           }
 
-          
+          // Retry on 429 (Too Many Requests) or 5xx (Server Error)
           if (response.status === 429 || response.status >= 500) {
-            this.logger.warn(`Temporary error occurred in Embedding API (${response.status}): ${errorText}`);
+            this.logger.warn(`Temporary error from embedding API (${response.status}): ${errorText}`);
             throw new Error(`API Error ${response.status}: ${errorText}`);
           }
 
-          this.logger.error(`Embedding API Error Details: ${errorText}`);
+          this.logger.error(`Embedding API error details: ${errorText}`);
           this.logger.error(`Request parameters: model=${modelConfig.modelId}, inputLength=${texts[0]?.length}`);
-          throw new Error(`Embedded API call failed: ${response.statusText} - ${errorText}`);
+          throw new Error(`Embedding API call failed: ${response.statusText} - ${errorText}`);
         }
 
         const data: EmbeddingResponse = await response.json();
         const embeddings = data.data.map((item) => item.embedding);
 
-        
+        // Get dimensions from actual response
         const actualDimensions = embeddings[0]?.length || this.defaultDimensions;
         this.logger.log(
-          `Retrieved ${embeddings.length} embeddings from ${modelConfig.name}. Dimensions: ${actualDimensions}`,
+          `Got ${embeddings.length} embedding vectors from ${modelConfig.name}. Dimensions: ${actualDimensions}`,
         );
 
         return embeddings;
       } catch (error) {
         lastError = error;
 
-        
+        // If not the last attempt and error appears temporary (or for robustness on all), retry after waiting
         if (attempt < MAX_RETRIES) {
           const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
-          this.logger.warn(`Embedding request failed. Retrying in ${delay}ms: ${error.message}`);
+          this.logger.warn(`Embedding request failed. Retrying after ${delay}ms: ${error.message}`);
           await new Promise(resolve => setTimeout(resolve, delay));
           continue;
         }
@@ -232,7 +238,7 @@ export class EmbeddingService {
   }
 
   private getEstimatedDimensions(modelId: string): number {
-    
+    // Use default dimensions from environment variable
     return this.defaultDimensions;
   }
 }

+ 15 - 40
server/src/knowledge-base/knowledge-base.controller.ts

@@ -40,36 +40,8 @@ export class KnowledgeBaseController {
 
   @Get()
   @UseGuards(CombinedAuthGuard)
-  async findAll(
-    @Request() req,
-    @Query('page') page?: number,
-    @Query('limit') limit?: number,
-    @Query('name') name?: string,
-    @Query('status') status?: string,
-    @Query('groupId') groupId?: string,
-  ): Promise<any> {
-    return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId, {
-      page: page ? Number(page) : undefined,
-      limit: limit ? Number(limit) : undefined,
-      name,
-      status,
-      groupId,
-    });
-  }
-
-  @Post('statuses')
-  @UseGuards(CombinedAuthGuard)
-  async getStatuses(
-    @Request() req,
-    @Body() body: { ids: string[] }
-  ): Promise<any> {
-    return this.knowledgeBaseService.getStatuses(body.ids, req.user.id, req.user.tenantId);
-  }
-
-  @Get('stats')
-  @UseGuards(CombinedAuthGuard)
-  async getStats(@Request() req): Promise<any> {
-    return this.knowledgeBaseService.getStats(req.user.id, req.user.tenantId);
+  async findAll(@Request() req): Promise<KnowledgeBase[]> {
+    return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId);
   }
 
   @Delete('clear')
@@ -130,7 +102,10 @@ export class KnowledgeBaseController {
   }
 
 
-  
+  /**
+   * Get chunk config limits (for frontend slider settings)
+   * Query parameter: embeddingModelId - embedding model ID
+   */
   @Get('chunk-config/limits')
   async getChunkConfigLimits(
     @Request() req,
@@ -159,7 +134,7 @@ export class KnowledgeBaseController {
     );
   }
 
-  
+  // File group management - requires admin permission
   @Post(':id/groups')
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async addFileToGroups(
@@ -192,7 +167,7 @@ export class KnowledgeBaseController {
     return { message: this.i18nService.getMessage('fileDeletedFromGroup') };
   }
 
-  
+  // PDF preview - public access
   @Public()
   @Get(':id/pdf')
   async getPDFPreview(
@@ -208,7 +183,7 @@ export class KnowledgeBaseController {
       const jwt = await import('jsonwebtoken');
       const secret = process.env.JWT_SECRET;
       if (!secret) {
-        throw new InternalServerErrorException('JWT_SECRET environment variable is required but not set');
+        throw new InternalServerErrorException(this.i18nService.getMessage('jwtSecretRequired'));
       }
 
       let decoded;
@@ -241,7 +216,7 @@ export class KnowledgeBaseController {
       if (stat.size === 0) {
         this.logger.warn(`PDF file is empty: ${pdfPath}`);
         try {
-          fs.unlinkSync(pdfPath); 
+          fs.unlinkSync(pdfPath); // Delete empty file
         } catch (e) { }
         throw new NotFoundException(this.i18nService.getMessage('pdfFileEmpty'));
       }
@@ -260,7 +235,7 @@ export class KnowledgeBaseController {
     }
   }
 
-  
+  // Get PDF preview URL
   @Get(':id/pdf-url')
   async getPDFUrl(
     @Param('id') fileId: string,
@@ -268,15 +243,15 @@ export class KnowledgeBaseController {
     @Request() req,
   ) {
     try {
-      
+      // Trigger PDF conversion
       await this.knowledgeBaseService.ensurePDFExists(fileId, req.user.id, req.user.tenantId, force === 'true');
 
-      
+      // Generate temporary access token
       const jwt = await import('jsonwebtoken');
 
       const secret = process.env.JWT_SECRET;
       if (!secret) {
-        throw new InternalServerErrorException('JWT_SECRET environment variable is required but not set');
+        throw new InternalServerErrorException(this.i18nService.getMessage('jwtSecretRequired'));
       }
 
       const token = jwt.sign(
@@ -304,7 +279,7 @@ export class KnowledgeBaseController {
     return await this.knowledgeBaseService.getPDFStatus(fileId, req.user.id, req.user.tenantId);
   }
 
-  
+  // Get specific page of PDF as image
   @Get(':id/page/:index')
   async getPageImage(
     @Param('id') fileId: string,

+ 9 - 9
server/src/knowledge-base/knowledge-base.entity.ts

@@ -14,14 +14,14 @@ import { Tenant } from '../tenant/tenant.entity';
 export enum FileStatus {
   PENDING = 'pending',
   INDEXING = 'indexing',
-  EXTRACTED = 'extracted', 
-  VECTORIZED = 'vectorized', 
+  EXTRACTED = 'extracted', // Text extraction completed and saved to database
+  VECTORIZED = 'vectorized', // Vectorization completed and indexed to ES
   FAILED = 'failed',
 }
 
 export enum ProcessingMode {
-  FAST = 'fast',      
-  PRECISE = 'precise', 
+  FAST = 'fast',      // Fast mode - use Tika
+  PRECISE = 'precise', // Precise mode - use Vision Pipeline
 }
 
 @Entity('knowledge_bases')
@@ -51,7 +51,7 @@ export class KnowledgeBase {
   })
   status: FileStatus;
 
-  @Column({ name: 'user_id', nullable: true }) 
+  @Column({ name: 'user_id', nullable: true }) // Temporarily allowed empty (for debugging), should be required in future
   userId: string;
 
   @Column({ name: 'tenant_id', nullable: true, type: 'text' })
@@ -62,9 +62,9 @@ export class KnowledgeBase {
   tenant: Tenant;
 
   @Column({ type: 'text', nullable: true })
-  content: string; 
+  content: string; // Stores text content extracted by Tika
 
-  
+  // Index setting parameters
   @Column({ name: 'chunk_size', type: 'integer', default: 1000 })
   chunkSize: number;
 
@@ -83,10 +83,10 @@ export class KnowledgeBase {
   processingMode: ProcessingMode;
 
   @Column({ type: 'json', nullable: true })
-  metadata: any; 
+  metadata: any; // Stores additional metadata (image descriptions, confidence, etc.)
 
   @Column({ name: 'pdf_path', nullable: true })
-  pdfPath: string; 
+  pdfPath: string; // PDF file path (for preview)
 
   @ManyToMany(() => KnowledgeGroup, (group) => group.knowledgeBases)
   groups: KnowledgeGroup[];

+ 0 - 2
server/src/knowledge-base/knowledge-base.module.ts

@@ -11,7 +11,6 @@ import { EmbeddingService } from './embedding.service';
 import { TextChunkerService } from './text-chunker.service';
 import { RagModule } from '../rag/rag.module';
 import { VisionModule } from '../vision/vision.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
 import { MemoryMonitorService } from './memory-monitor.service';
 import { ChunkConfigService } from './chunk-config.service';
 import { LibreOfficeModule } from '../libreoffice/libreoffice.module';
@@ -31,7 +30,6 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
     ModelConfigModule,
     forwardRef(() => RagModule),
     VisionModule,
-    UserSettingModule,
     LibreOfficeModule,
     Pdf2ImageModule,
     VisionPipelineModule,

File diff suppressed because it is too large
+ 166 - 223
server/src/knowledge-base/knowledge-base.service.ts


+ 61 - 43
server/src/knowledge-base/memory-monitor.service.ts

@@ -1,10 +1,10 @@
 import { Injectable, Logger } from '@nestjs/common';
 
 export interface MemoryStats {
-  heapUsed: number;    
-  heapTotal: number;   
-  external: number;    
-  rss: number;         
+  heapUsed: number;    // Used heap memory (MB)
+  heapTotal: number;   // Total heap memory (MB)
+  external: number;    // External memory (MB)
+  rss: number;         // RSS (Resident Set Size) (MB)
   timestamp: Date;
 }
 
@@ -16,15 +16,17 @@ export class MemoryMonitorService {
   private readonly GC_THRESHOLD_MB: number;
 
   constructor() {
-    
-    this.MAX_MEMORY_MB = parseInt(process.env.MAX_MEMORY_USAGE_MB || '1024'); 
-    this.BATCH_SIZE = parseInt(process.env.CHUNK_BATCH_SIZE || '100'); 
-    this.GC_THRESHOLD_MB = parseInt(process.env.GC_THRESHOLD_MB || '800'); 
+    // Load config from env vars. Default values for memory optimization
+    this.MAX_MEMORY_MB = parseInt(process.env.MAX_MEMORY_USAGE_MB || '1024'); // 1GB limit
+    this.BATCH_SIZE = parseInt(process.env.CHUNK_BATCH_SIZE || '100'); // 100 chunks per batch
+    this.GC_THRESHOLD_MB = parseInt(process.env.GC_THRESHOLD_MB || '800'); // Trigger GC at 800MB
 
-    this.logger.log(`Initialized memory monitoring: Limit=${this.MAX_MEMORY_MB}MB, Batch Size=${this.BATCH_SIZE}, GC Threshold=${this.GC_THRESHOLD_MB}MB`);
+    this.logger.log(`Memory monitor initialized: limit=${this.MAX_MEMORY_MB}MB, batchSize=${this.BATCH_SIZE}, GCThreshold=${this.GC_THRESHOLD_MB}MB`);
   }
 
-  
+  /**
+   * Get current memory usage
+   */
   getMemoryUsage(): MemoryStats {
     const usage = process.memoryUsage();
     return {
@@ -36,26 +38,30 @@ export class MemoryMonitorService {
     };
   }
 
-  
+  /**
+   * Check if memory is approaching limit
+   */
   isMemoryHigh(): boolean {
     const usage = this.getMemoryUsage();
-    return usage.heapUsed > this.MAX_MEMORY_MB * 0.85; 
+    return usage.heapUsed > this.MAX_MEMORY_MB * 0.85; // 85% threshold
   }
 
-  
+  /**
+   * Wait for memory to become available (with timeout)
+   */
   async waitForMemoryAvailable(timeoutMs: number = 30000): Promise<void> {
     const startTime = Date.now();
 
     while (this.isMemoryHigh()) {
       if (Date.now() - startTime > timeoutMs) {
-        throw new Error(`Memory wait timed out: Currently ${this.getMemoryUsage().heapUsed}MB > ${this.MAX_MEMORY_MB * 0.85}MB`);
+        throw new Error(`Memory wait timeout: current ${this.getMemoryUsage().heapUsed}MB > ${this.MAX_MEMORY_MB * 0.85}MB`);
       }
 
       this.logger.warn(
-        `Memory usage is too high. Waiting for release... ${this.getMemoryUsage().heapUsed}/${this.MAX_MEMORY_MB}MB`,
+        `Memory usage too high. Waiting for release... ${this.getMemoryUsage().heapUsed}/${this.MAX_MEMORY_MB}MB`,
       );
 
-      
+      // Force garbage collection (if available)
       if (global.gc) {
         this.logger.log('Running forced garbage collection...');
         global.gc();
@@ -65,35 +71,39 @@ export class MemoryMonitorService {
     }
   }
 
-  
+  /**
+   * Force garbage collection (if available)
+   */
   forceGC(): void {
     if (global.gc) {
       const before = this.getMemoryUsage();
       global.gc();
       const after = this.getMemoryUsage();
       this.logger.log(
-        `GC Complete: ${before.heapUsed}MB → ${after.heapUsed}MB (${before.heapUsed - after.heapUsed}MB released)`,
+        `GC completed: ${before.heapUsed}MB → ${after.heapUsed}MB (${before.heapUsed - after.heapUsed}MB freed)`,
       );
     }
   }
 
-  
+  /**
+   * Dynamically adjust batch size
+   */
   getDynamicBatchSize(currentMemoryMB: number): number {
     const baseBatchSize = this.BATCH_SIZE;
 
     if (currentMemoryMB > this.GC_THRESHOLD_MB) {
-      
+      // Memory pressure, reduce batch size
       const reduced = Math.max(10, Math.floor(baseBatchSize * 0.5));
       this.logger.warn(
-        `Memory constrained (${currentMemoryMB}MB), dynamically adjusting batch size: ${baseBatchSize} → ${reduced}`,
+        `Memory pressure (${currentMemoryMB}MB), adjusting batch size: ${baseBatchSize} → ${reduced}`,
       );
       return reduced;
     } else if (currentMemoryMB < this.MAX_MEMORY_MB * 0.4) {
-      
+      // Enough memory, increase batch size
       const increased = Math.min(200, Math.floor(baseBatchSize * 1.2));
       if (increased > baseBatchSize) {
         this.logger.log(
-          `Memory available (${currentMemoryMB}MB), dynamically adjusting batch size: ${baseBatchSize} → ${increased}`,
+          `Memory available (${currentMemoryMB}MB), adjusting batch size: ${baseBatchSize} → ${increased}`,
         );
       }
       return increased;
@@ -102,7 +112,9 @@ export class MemoryMonitorService {
     return baseBatchSize;
   }
 
-  
+  /**
+   * Process large data: auto-batching and memory control
+   */
   async processInBatches<T, R>(
     items: T[],
     processor: (batch: T[], batchIndex: number) => Promise<R[]>,
@@ -121,38 +133,38 @@ export class MemoryMonitorService {
     let processedCount = 0;
 
     for (let i = 0; i < totalItems;) {
-      
+      // Check memory state and wait
       await this.waitForMemoryAvailable();
 
-      
+      // Dynamically adjust batch size
       const currentMem = this.getMemoryUsage().heapUsed;
       const batchSize = this.getDynamicBatchSize(currentMem);
 
-      
+      // Get current batch
       const batch = items.slice(i, i + batchSize);
       const batchIndex = Math.floor(i / batchSize) + 1;
       const totalBatches = Math.ceil(totalItems / batchSize);
 
       this.logger.log(
-        `Processing batch ${batchIndex}/${totalBatches}: ${batch.length} items (Total ${processedCount}/${totalItems})`,
+        `Processing batch ${batchIndex}/${totalBatches}: ${batch.length} items (cumulative ${processedCount}/${totalItems})`,
       );
 
-      
+      // Process batch
       const batchResults = await processor(batch, batchIndex);
       allResults.push(...batchResults);
       processedCount += batch.length;
 
-      
+      // Callback notification
       if (options?.onBatchComplete) {
         await options.onBatchComplete(batchIndex, totalBatches, batchResults);
       }
 
-      
+      // Force GC if memory near threshold
       if (currentMem > this.GC_THRESHOLD_MB) {
         this.forceGC();
       }
 
-      
+      // Clear references to help GC
       batch.length = 0;
 
       i += batchSize;
@@ -161,21 +173,23 @@ export class MemoryMonitorService {
     const duration = ((Date.now() - startTime) / 1000).toFixed(2);
     const finalMem = this.getMemoryUsage();
     this.logger.log(
-      `Batch processing complete: ${totalItems} items, Duration ${duration}s, Final memory ${finalMem.heapUsed}MB`,
+      `Batch processing completed: ${totalItems} items, duration ${duration}s, final memory ${finalMem.heapUsed}MB`,
     );
 
     return allResults;
   }
 
-  
+  /**
+   * Estimate memory required for processing
+   */
   estimateMemoryUsage(itemCount: number, itemSizeBytes: number, vectorDim: number): number {
-    
+    // Text content memory
     const textMemory = itemCount * itemSizeBytes;
 
-    
+    // Vector memory (dimension * 4 bytes per vector)
     const vectorMemory = itemCount * vectorDim * 4;
 
-    
+    // Object overhead (~100 bytes per object)
     const overhead = itemCount * 100;
 
     const totalMB = Math.round((textMemory + vectorMemory + overhead) / 1024 / 1024);
@@ -183,10 +197,12 @@ export class MemoryMonitorService {
     return totalMB;
   }
 
-  
+  /**
+   * Check if batching should be used
+   */
   shouldUseBatching(itemCount: number, itemSizeBytes: number, vectorDim: number): boolean {
     const estimatedMB = this.estimateMemoryUsage(itemCount, itemSizeBytes, vectorDim);
-    const threshold = this.MAX_MEMORY_MB * 0.7; 
+    const threshold = this.MAX_MEMORY_MB * 0.7; // 70% threshold
 
     if (estimatedMB > threshold) {
       this.logger.warn(
@@ -198,18 +214,20 @@ export class MemoryMonitorService {
     return false;
   }
 
-  
+  /**
+   * Get recommended batch size
+   */
   getRecommendedBatchSize(itemSizeBytes: number, vectorDim: number): number {
-    
+    // Goal: max 200MB memory per batch
     const targetMemoryMB = 200;
     const targetMemoryBytes = targetMemoryMB * 1024 * 1024;
 
-    
+    // Memory per item = text + vector + overhead
     const singleItemMemory = itemSizeBytes + (vectorDim * 4) + 100;
 
     const batchSize = Math.floor(targetMemoryBytes / singleItemMemory);
 
-    
+    // Limit between 10-200
     return Math.max(10, Math.min(200, batchSize));
   }
 }

+ 3 - 3
server/src/knowledge-base/text-chunker.service.ts

@@ -22,7 +22,7 @@ export class TextChunkerService {
     const chunkSizeInChars = chunkSize * 4; // 1 token ≈ 4 chars
     const overlapInChars = overlap * 4;
 
-    
+    // If text length <= chunk size, return entire text as one chunk
     if (cleanText.length <= chunkSizeInChars) {
       return [
         {
@@ -41,7 +41,7 @@ export class TextChunkerService {
     while (start < cleanText.length) {
       let end = Math.min(start + chunkSizeInChars, cleanText.length);
 
-      
+      // Split by sentence boundaries
       if (end < cleanText.length) {
         const sentenceEnd = this.findSentenceEnd(
           cleanText,
@@ -69,7 +69,7 @@ export class TextChunkerService {
         break;
       }
 
-      
+      // Calculate start position of next chunk
       const newStart = end - overlapInChars;
       // Protect against infinite loop if overlap is too large or chunk too small
       if (newStart <= start) {

+ 9 - 16
server/src/knowledge-group/knowledge-group.controller.ts

@@ -15,27 +15,20 @@ import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
 import { UserRole } from '../user/user-role.enum';
 import { KnowledgeGroupService, CreateGroupDto, UpdateGroupDto } from './knowledge-group.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Controller('knowledge-groups')
 @UseGuards(CombinedAuthGuard, RolesGuard)
 export class KnowledgeGroupController {
-  constructor(private readonly groupService: KnowledgeGroupService) { }
+  constructor(
+    private readonly groupService: KnowledgeGroupService,
+    private readonly i18nService: I18nService,
+  ) { }
 
   @Get()
-  async findAll(
-    @Request() req,
-    @Query('flat') flat?: string,
-    @Query('page') page?: number,
-    @Query('limit') limit?: number,
-    @Query('name') name?: string,
-  ) {
-    // All users can see all groups for their tenant
-    return await this.groupService.findAll(req.user.id, req.user.tenantId, {
-      flat: flat === 'true',
-      page: page ? Number(page) : undefined,
-      limit: limit ? Number(limit) : undefined,
-      name,
-    });
+  async findAll(@Request() req) {
+    // All users can see all groups for their tenant (returns tree structure)
+    return await this.groupService.findAll(req.user.id, req.user.tenantId);
   }
 
   @Get(':id')
@@ -63,7 +56,7 @@ export class KnowledgeGroupController {
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async remove(@Param('id') id: string, @Request() req) {
     await this.groupService.remove(id, req.user.id, req.user.tenantId);
-    return { message: 'Group deleted successfully' };
+    return { message: this.i18nService.getMessage('groupDeleted') };
   }
 
   @Get(':id/files')

+ 5 - 48
server/src/knowledge-group/knowledge-group.service.ts

@@ -50,59 +50,16 @@ export class KnowledgeGroupService {
     private i18nService: I18nService,
   ) { }
 
-  async findAll(
-    userId: string,
-    tenantId: string,
-    options: {
-      flat?: boolean;
-      page?: number;
-      limit?: number;
-      name?: string;
-    } = {},
-  ): Promise<GroupWithFileCount[] | PaginatedGroups> {
-    const { flat = false, page = 1, limit = 12, name } = options;
-
-    const queryBuilder = this.groupRepository
+  async findAll(userId: string, tenantId: string): Promise<GroupWithFileCount[]> {
+    // Return all groups for the tenant with file counts
+    const groups = await this.groupRepository
       .createQueryBuilder('group')
       .leftJoin('group.knowledgeBases', 'kb')
       .where('group.tenantId = :tenantId', { tenantId })
       .addSelect('COUNT(kb.id)', 'fileCount')
       .groupBy('group.id')
-      .orderBy('group.createdAt', 'ASC');
-
-    if (name) {
-      queryBuilder.andWhere('group.name LIKE :name', { name: `%${name}%` });
-    }
-
-    if (flat) {
-      const skip = (page - 1) * limit;
-      const total = await queryBuilder.getCount();
-      const rawResults = await queryBuilder
-        .offset(skip)
-        .limit(limit)
-        .getRawAndEntities();
-
-      const items: GroupWithFileCount[] = rawResults.entities.map((group, index) => ({
-        id: group.id,
-        name: group.name,
-        description: group.description,
-        color: group.color,
-        parentId: group.parentId ?? null,
-        fileCount: parseInt(rawResults.raw[index].fileCount) || 0,
-        createdAt: group.createdAt,
-        children: [],
-      }));
-
-      return {
-        items,
-        total,
-        page,
-        limit,
-      };
-    }
-
-    // Return all groups for the tenant with file counts (tree structure)
-    const groups = await queryBuilder.getRawAndEntities();
+      .orderBy('group.createdAt', 'ASC')
+      .getRawAndEntities();
 
     const flatList: GroupWithFileCount[] = groups.entities.map((group, index) => ({
       id: group.id,

+ 4 - 2
server/src/libreoffice/libreoffice.interface.ts

@@ -1,8 +1,10 @@
-
+/**
+ * LibreOffice Service Interface Definition
+ */
 
 export interface LibreOfficeConvertResponse {
   pdf_path?: string;
-  pdf_data?: string; 
+  pdf_data?: string; // base64 encoded PDF data
   converted: boolean;
   original: string;
   file_size: number;

+ 40 - 26
server/src/libreoffice/libreoffice.service.ts

@@ -5,24 +5,30 @@ import * as path from 'path';
 import axios from 'axios';
 import FormData from 'form-data';
 import { LibreOfficeConvertResponse, LibreOfficeHealthResponse } from './libreoffice.interface';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class LibreOfficeService implements OnModuleInit {
   private readonly logger = new Logger(LibreOfficeService.name);
   private baseUrl: string;
 
-  constructor(private configService: ConfigService) { }
+  constructor(
+    private configService: ConfigService,
+    private i18nService: I18nService,
+  ) { }
 
   onModuleInit() {
     const libreofficeUrl = this.configService.get<string>('LIBREOFFICE_URL');
     if (!libreofficeUrl) {
-      throw new Error('LIBREOFFICE_URL environment variable is required but not set');
+      throw new Error(this.i18nService.getMessage('libreofficeUrlRequired'));
     }
     this.baseUrl = libreofficeUrl;
     this.logger.log(`LibreOffice service initialized with base URL: ${this.baseUrl}`);
   }
 
-  
+  /**
+   * Check LibreOffice service health status
+   */
   async healthCheck(): Promise<boolean> {
     try {
       const response = await axios.get<LibreOfficeHealthResponse>(
@@ -36,66 +42,70 @@ export class LibreOfficeService implements OnModuleInit {
     }
   }
 
-  
+  /**
+   * Convert document to PDF
+   * @param filePath Path of file to convert
+   * @returns PDF file path
+   */
   async convertToPDF(filePath: string): Promise<string> {
     const fileName = path.basename(filePath);
     const ext = path.extname(fileName).toLowerCase();
 
-    
+    // Return original path directly if PDF
     if (ext === '.pdf') {
       this.logger.log(`File is already PDF: ${filePath}`);
       return filePath;
     }
 
-    
+    // Check if file exists
     try {
       await fs.access(filePath);
     } catch {
       throw new Error(`File does not exist: ${filePath}`);
     }
 
-    
+    // Generate output PDF path
     const dir = path.dirname(filePath);
     const baseName = path.basename(filePath, ext);
     const targetPdfPath = path.join(dir, `${baseName}.pdf`);
 
-    
+    // Return directly if PDF already exists
     try {
       await fs.access(targetPdfPath);
       this.logger.log(`PDF already exists: ${targetPdfPath}`);
       return targetPdfPath;
     } catch {
-      
+      // Need to convert as PDF does not exist
     }
 
-    
+    // Load file
     const fileBuffer = await fs.readFile(filePath);
 
-    
+    // Build FormData
     const formData = new FormData();
     formData.append('file', fileBuffer, fileName);
 
     this.logger.log(`Converting ${fileName} to PDF...`);
 
-    
+    // Conversion retry count
     const maxRetries = 3;
     let lastError: any;
 
     for (let attempt = 1; attempt <= maxRetries; attempt++) {
       try {
-        
+        // Call LibreOffice service
         const response = await axios.post(
           `${this.baseUrl}/convert`,
           formData,
           {
             headers: formData.getHeaders(),
-            timeout: 300000, 
-            responseType: 'stream', 
-            maxRedirects: 5, 
+            timeout: 300000, // 5 minute timeout
+            responseType: 'stream', // Receive file stream
+            maxRedirects: 5, // Max redirects
           }
         );
 
-        
+        // Write stream to output file
         const writer = (await import('fs')).createWriteStream(targetPdfPath);
         response.data.pipe(writer);
 
@@ -114,21 +124,21 @@ export class LibreOfficeService implements OnModuleInit {
         this.logger.error(`Attempt ${attempt} failed for ${fileName}: ${error.message}`);
         lastError = error;
 
-        
+        // Wait and retry on socket hang up or connection error
         if (error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message.includes('socket hang up')) {
           if (attempt < maxRetries) {
-            const delay = 2000 * attempt; 
+            const delay = 2000 * attempt; // Increasing delay
             this.logger.log(`Waiting ${delay}ms before retry...`);
             await new Promise(resolve => setTimeout(resolve, delay));
           }
         } else {
-          
+          // Do not retry other errors
           break;
         }
       }
     }
 
-    
+    // Detailed error handling if all retries fail
     if (lastError.response) {
       try {
         const stream = lastError.response.data;
@@ -148,7 +158,7 @@ export class LibreOfficeService implements OnModuleInit {
         }
 
         if (lastError.response.status === 504) {
-          throw new Error('Conversion timed out. File may be too large');
+          throw new Error('Conversion timed out. The file may be too large.');
         }
         throw new Error(`Conversion failed: ${detail}`);
 
@@ -160,15 +170,17 @@ export class LibreOfficeService implements OnModuleInit {
 
     this.logger.error(`Conversion failed for ${fileName} after ${maxRetries} attempts:`, lastError.message);
     if (lastError.code === 'ECONNREFUSED') {
-      throw new Error('LibreOffice service is not running. Please check the status of the service');
+      throw new Error('LibreOffice service is not running. Please check the service status.');
     }
     if (lastError.code === 'ECONNRESET' || lastError.message.includes('socket hang up')) {
-      throw new Error('The connection to the LibreOffice service has been lost. The service may be unstable');
+      throw new Error('Connection to LibreOffice service was reset. The service may be unstable.');
     }
     throw new Error(`Conversion failed: ${lastError.message}`);
   }
 
-  
+  /**
+   * Batch convert files
+   */
   async batchConvert(filePaths: string[]): Promise<string[]> {
     const results: string[] = [];
     for (const filePath of filePaths) {
@@ -183,7 +195,9 @@ export class LibreOfficeService implements OnModuleInit {
     return results;
   }
 
-  
+  /**
+   * Get service version information
+   */
   async getVersion(): Promise<any> {
     try {
       const response = await axios.get(`${this.baseUrl}/version`);

+ 9 - 9
server/src/migrations/1737800000000-AddKnowledgeBaseEnhancements.ts

@@ -4,7 +4,7 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
   name = 'AddKnowledgeBaseEnhancements1737800000000';
 
   public async up(queryRunner: QueryRunner): Promise<void> {
-    
+    // Create knowledge base group table
     await queryRunner.query(`
       CREATE TABLE "knowledge_groups" (
         "id" varchar PRIMARY KEY NOT NULL,
@@ -17,7 +17,7 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
       )
     `);
 
-    
+    // Create document group related tables
     await queryRunner.query(`
       CREATE TABLE "knowledge_base_groups" (
         "knowledge_base_id" varchar NOT NULL,
@@ -29,7 +29,7 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
       )
     `);
 
-    
+    // Create search history table
     await queryRunner.query(`
       CREATE TABLE "search_history" (
         "id" varchar PRIMARY KEY NOT NULL,
@@ -41,7 +41,7 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
       )
     `);
 
-    
+    // Create conversation message table
     await queryRunner.query(`
       CREATE TABLE "chat_messages" (
         "id" varchar PRIMARY KEY NOT NULL,
@@ -54,27 +54,27 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
       )
     `);
 
-    
+    // Add pdf_path field to knowledge_base table
     await queryRunner.query(`
       ALTER TABLE "knowledge_base" ADD COLUMN "pdf_path" varchar
     `);
 
-    
+    // Create index
     await queryRunner.query(`CREATE INDEX "IDX_knowledge_groups_user_id" ON "knowledge_groups" ("user_id")`);
     await queryRunner.query(`CREATE INDEX "IDX_search_history_user_id" ON "search_history" ("user_id")`);
     await queryRunner.query(`CREATE INDEX "IDX_chat_messages_search_history_id" ON "chat_messages" ("search_history_id")`);
   }
 
   public async down(queryRunner: QueryRunner): Promise<void> {
-    
+    // Delete index
     await queryRunner.query(`DROP INDEX "IDX_chat_messages_search_history_id"`);
     await queryRunner.query(`DROP INDEX "IDX_search_history_user_id"`);
     await queryRunner.query(`DROP INDEX "IDX_knowledge_groups_user_id"`);
 
-    
+    // Delete pdf_path field
     await queryRunner.query(`ALTER TABLE "knowledge_base" DROP COLUMN "pdf_path"`);
 
-    
+    // Delete table
     await queryRunner.query(`DROP TABLE "chat_messages"`);
     await queryRunner.query(`DROP TABLE "search_history"`);
     await queryRunner.query(`DROP TABLE "knowledge_base_groups"`);

+ 29 - 0
server/src/migrations/cleanup-settings-tables.sql

@@ -0,0 +1,29 @@
+-- cleanup-settings-tables.sql
+-- Drop unnecessary columns from settings tables to align with the refined architecture.
+
+-- 1. Prune user_settings table
+-- Keeps only id, userId, and language.
+ALTER TABLE user_settings DROP COLUMN selectedLLMId;
+ALTER TABLE user_settings DROP COLUMN selectedEmbeddingId;
+ALTER TABLE user_settings DROP COLUMN selectedRerankId;
+ALTER TABLE user_settings DROP COLUMN temperature;
+ALTER TABLE user_settings DROP COLUMN maxTokens;
+ALTER TABLE user_settings DROP COLUMN enableRerank;
+ALTER TABLE user_settings DROP COLUMN topK;
+ALTER TABLE user_settings DROP COLUMN similarityThreshold;
+ALTER TABLE user_settings DROP COLUMN enableFullTextSearch;
+ALTER TABLE user_settings DROP COLUMN defaultVisionModelId;
+ALTER TABLE user_settings DROP COLUMN coachKbId;
+ALTER TABLE user_settings DROP COLUMN created_at;
+ALTER TABLE user_settings DROP COLUMN updated_at;
+ALTER TABLE user_settings DROP COLUMN rerankSimilarityThreshold;
+ALTER TABLE user_settings DROP COLUMN hybridVectorWeight;
+ALTER TABLE user_settings DROP COLUMN isGlobal;
+ALTER TABLE user_settings DROP COLUMN enableQueryExpansion;
+ALTER TABLE user_settings DROP COLUMN enableHyDE;
+ALTER TABLE user_settings DROP COLUMN chunkSize;
+ALTER TABLE user_settings DROP COLUMN chunkOverlap;
+
+-- 2. Prune tenant_settings table
+-- Language is now strictly a user-level setting.
+ALTER TABLE tenant_settings DROP COLUMN language;

+ 8 - 0
server/src/migrations/restore-timestamps.sql

@@ -0,0 +1,8 @@
+-- restore-timestamps.sql
+-- Restore created_at and updated_at columns to user_settings table.
+
+ALTER TABLE user_settings ADD COLUMN created_at datetime;
+ALTER TABLE user_settings ADD COLUMN updated_at datetime;
+
+UPDATE user_settings SET created_at = datetime('now') WHERE created_at IS NULL;
+UPDATE user_settings SET updated_at = datetime('now') WHERE updated_at IS NULL;

+ 22 - 10
server/src/model-config/dto/create-model-config.dto.ts

@@ -28,50 +28,62 @@ export class CreateModelConfigDto {
 
   @IsString()
   @IsOptional()
-  apiKey?: string; // API key is optional - allow local models
+  apiKey?: string; // API key is optional - allows local models
 
   @IsEnum(ModelType)
   @IsNotEmpty()
   type: ModelType;
 
   @IsNumber()
-  @Min(1, { message: 'The minimum value of the vector dimension is 1' })
-  @Max(4096, { message: 'The maximum vector dimension is 4096 (Elasticsearch limit)' })
+  @Min(1, { message: 'Minimum vector dimension is 1' })
+  @Max(4096, { message: 'Maximum vector dimension is 4096 (Elasticsearch limit)' })
   @IsOptional()
   dimensions?: number;
 
-  
+  // ==================== Additional Fields ====================
 
-  
+  /**
+   * Model input token limit (only valid for embedding/rerank)
+   */
   @IsNumber()
   @Min(1)
   @Max(100000)
   @IsOptional()
   maxInputTokens?: number;
 
-  
+  /**
+   * Batch processing limit (only valid for embedding/rerank)
+   */
   @IsNumber()
   @Min(1)
   @Max(10000)
   @IsOptional()
   maxBatchSize?: number;
 
-  
+  /**
+   * Whether this is a vector model
+   */
   @IsBoolean()
   @IsOptional()
   isVectorModel?: boolean;
 
-  
+  /**
+   * Model provider name
+   */
   @IsString()
   @IsOptional()
   providerName?: string;
 
-  
+  /**
+   * Whether to enable this model
+   */
   @IsBoolean()
   @IsOptional()
   isEnabled?: boolean;
 
-  
+  /**
+   * Whether to use this model as default
+   */
   @IsBoolean()
   @IsOptional()
   isDefault?: boolean;

+ 12 - 0
server/src/model-config/model-config.controller.ts

@@ -80,4 +80,16 @@ export class ModelConfigController {
   async remove(@Req() req, @Param('id') id: string): Promise<void> {
     await this.modelConfigService.remove(req.user.id, req.user.tenantId, id);
   }
+
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  @Patch(':id/set-default')
+  async setDefault(
+    @Req() req,
+    @Param('id') id: string,
+  ): Promise<ModelConfigResponseDto> {
+    const userId = req.user.id;
+    const tenantId = req.user.tenantId;
+    const modelConfig = await this.modelConfigService.setDefault(userId, tenantId, id);
+    return plainToClass(ModelConfigResponseDto, modelConfig);
+  }
 }

+ 32 - 1
server/src/model-config/model-config.entity.ts

@@ -31,23 +31,54 @@ export class ModelConfig {
   type: string; // ModelType enum values
 
   @Column({ type: 'integer', nullable: true })
-  dimensions?: number; 
+  dimensions?: number; // Embedding model dimensions, auto-detected and saved by system
 
+  // ==================== Additional Fields ====================
+  // The following fields are only meaningful for embedding/rerank models
+
+  /**
+   * Model input token limit
+   * Example: OpenAI=8191, Gemini=2048
+   */
   @Column({ type: 'integer', nullable: true, default: 8191 })
   maxInputTokens?: number;
 
+  /**
+   * Batch processing limit (max inputs per request)
+   * Example: OpenAI=2048, Gemini=100
+   */
   @Column({ type: 'integer', nullable: true, default: 2048 })
   maxBatchSize?: number;
 
+  /**
+   * Whether this is a vector model (for system identification)
+   */
   @Column({ type: 'boolean', default: false })
   isVectorModel?: boolean;
 
+  /**
+   * Whether to enable this model
+   * Users can disable models they don't use to prevent accidental selection
+   */
   @Column({ type: 'boolean', default: true })
   isEnabled?: boolean;
 
+  /**
+   * Whether to use this model as default
+   * Only one default allowed per type (llm, embedding, rerank)
+   */
+  @Column({ type: 'boolean', default: false })
+  isDefault?: boolean;
+
+  /**
+   * Model provider name (for display and identification)
+   * Example: "OpenAI", "Google Gemini", "Custom"
+   */
   @Column({ type: 'text', nullable: true })
   providerName?: string;
 
+  // ==================== Existing Fields ====================
+
   @Column({ type: 'text', nullable: true })
   userId: string;
 

+ 51 - 41
server/src/model-config/model-config.service.ts

@@ -7,6 +7,7 @@ import { UpdateModelConfigDto } from './dto/update-model-config.dto';
 import { GLOBAL_TENANT_ID } from '../common/constants';
 import { TenantService } from '../tenant/tenant.service';
 import { ModelType } from '../types';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class ModelConfigService {
@@ -15,6 +16,7 @@ export class ModelConfigService {
     private modelConfigRepository: Repository<ModelConfig>,
     @Inject(forwardRef(() => TenantService))
     private readonly tenantService: TenantService,
+    private i18nService: I18nService,
   ) { }
 
   async create(
@@ -50,7 +52,7 @@ export class ModelConfigService {
 
     if (!modelConfig) {
       throw new NotFoundException(
-        `ModelConfig with ID "${id}" not found.`,
+        this.i18nService.formatMessage('modelConfigNotFound', { id }),
       );
     }
     return modelConfig;
@@ -76,13 +78,13 @@ export class ModelConfigService {
 
     if (!modelConfig) {
       throw new NotFoundException(
-        `ModelConfig with ID "${id}" not found.`,
+        this.i18nService.formatMessage('modelConfigNotFound', { id }),
       );
     }
 
     // Only allow updating if it belongs to the tenant, or if it's a global admin (not fully implemented, so we check tenantId)
     if (modelConfig.tenantId && modelConfig.tenantId !== tenantId) {
-      throw new ForbiddenException('Cannot update models from another tenant');
+      throw new ForbiddenException(this.i18nService.getMessage('cannotUpdateOtherTenantModel'));
     }
 
     // Update the model
@@ -97,59 +99,67 @@ export class ModelConfigService {
     // Only allow removing if it exists and accessible in current tenant context
     const model = await this.findOne(id, userId, tenantId);
     if (model.tenantId && model.tenantId !== tenantId) {
-      throw new ForbiddenException('Cannot delete models from another tenant');
+      throw new ForbiddenException(this.i18nService.getMessage('cannotDeleteOtherTenantModel'));
     }
     const result = await this.modelConfigRepository.delete({ id });
     if (result.affected === 0) {
-      throw new NotFoundException(`ModelConfig with ID "${id}" not found.`);
+      throw new NotFoundException(this.i18nService.formatMessage('modelConfigNotFound', { id }));
     }
   }
 
-  
+  /**
+   * Set the specified model as default
+   */
+  async setDefault(userId: string, tenantId: string, id: string): Promise<ModelConfig> {
+    const modelConfig = await this.findOne(id, userId, tenantId);
+
+    // Clear default flag for other models of the same type (within current tenant or global)
+    await this.modelConfigRepository
+      .createQueryBuilder()
+      .update(ModelConfig)
+      .set({ isDefault: false })
+      .where('type = :type', { type: modelConfig.type })
+      .andWhere('(tenantId = :tenantId OR tenantId IS NULL OR tenantId = :globalTenantId)', {
+        tenantId,
+        globalTenantId: GLOBAL_TENANT_ID
+      })
+      .execute();
+
+    modelConfig.isDefault = true;
+    return this.modelConfigRepository.save(modelConfig);
+  }
+
+  /**
+   * Get default model for specified type
+   * Strict rule: Only return models specified in Index Chat Config, throw error if not found
+   */
   async findDefaultByType(tenantId: string, type: ModelType): Promise<ModelConfig> {
     const settings = await this.tenantService.getSettings(tenantId);
-    
+    if (!settings) {
+      throw new BadRequestException(`Organization settings not found for tenant: ${tenantId}`);
+    }
+
     let modelId: string | undefined;
-    if (settings) {
-      if (type === ModelType.LLM) {
-        modelId = settings.selectedLLMId;
-      } else if (type === ModelType.EMBEDDING) {
-        modelId = settings.selectedEmbeddingId;
-      } else if (type === ModelType.RERANK) {
-        modelId = settings.selectedRerankId;
-      }
+    if (type === ModelType.LLM) {
+      modelId = settings.selectedLLMId;
+    } else if (type === ModelType.EMBEDDING) {
+      modelId = settings.selectedEmbeddingId;
+    } else if (type === ModelType.RERANK) {
+      modelId = settings.selectedRerankId;
     }
 
-    // 1. If we have a specific model ID selected for this tenant, try it
-    if (modelId) {
-      const model = await this.modelConfigRepository.findOne({
-        where: { id: modelId, isEnabled: true }
-      });
-      if (model) return model;
+    if (!modelId) {
+      throw new BadRequestException(`Model of type "${type}" is not configured in Index Chat Config for this organization.`);
     }
 
-    // 2. Fallback: Find any enabled model for this tenant or globally
-    // Prioritize tenant-specific models over global models
-    const availableModels = await this.modelConfigRepository.createQueryBuilder('model')
-      .where('model.type = :type AND model.isEnabled = :enabled', {
-        type,
-        enabled: true
-      })
-      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)', {
-        tenantId,
-        globalTenantId: GLOBAL_TENANT_ID
-      })
-      .orderBy('model.tenantId', 'DESC') // Puts non-null tenantId (e.g. current tenant) before NULL/Global
-      .getMany();
+    const model = await this.modelConfigRepository.findOne({
+      where: { id: modelId, isEnabled: true }
+    });
 
-    if (availableModels.length > 0) {
-      return availableModels[0];
+    if (!model) {
+      throw new BadRequestException(`The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`);
     }
 
-    throw new BadRequestException(
-      modelId 
-        ? `The configured model for "${type}" (ID: ${modelId}) is missing or disabled, and no valid fallback model of this type was found.`
-        : `Model of type "${type}" is not configured for this organization and no enabled global model is available.`
-    );
+    return model;
   }
 }

+ 8 - 8
server/src/note/note-category.service.ts

@@ -2,12 +2,14 @@ import { Injectable, NotFoundException } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { NoteCategory } from './note-category.entity';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class NoteCategoryService {
     constructor(
         @InjectRepository(NoteCategory)
         private readonly categoryRepository: Repository<NoteCategory>,
+        private readonly i18nService: I18nService,
     ) { }
 
     async findAll(userId: string, tenantId: string): Promise<NoteCategory[]> {
@@ -24,10 +26,10 @@ export class NoteCategoryService {
                 where: { id: parentId, userId, tenantId }
             });
             if (!parent) {
-                throw new NotFoundException('Parent category not found');
+                throw new NotFoundException(this.i18nService.getMessage('parentCategoryNotFound'));
             }
             if (parent.level >= 3) {
-                throw new Error('Maximum category depth (3 levels) exceeded');
+                throw new Error(this.i18nService.getMessage('maxCategoryDepthExceeded'));
             }
             level = parent.level + 1;
         }
@@ -47,7 +49,7 @@ export class NoteCategoryService {
             where: { id, userId, tenantId },
         });
         if (!category) {
-            throw new NotFoundException('Category not found');
+            throw new NotFoundException(this.i18nService.getMessage('categoryNotFound'));
         }
 
         if (name !== undefined) {
@@ -62,14 +64,12 @@ export class NoteCategoryService {
                 const parent = await this.categoryRepository.findOne({
                     where: { id: parentId, userId, tenantId }
                 });
-                if (!parent) throw new NotFoundException('Parent category not found');
-                if (parent.level >= 3) throw new Error('Maximum category depth (3 levels) exceeded');
+                if (!parent) throw new NotFoundException(this.i18nService.getMessage('parentCategoryNotFound'));
+                if (parent.level >= 3) throw new Error(this.i18nService.getMessage('maxCategoryDepthExceeded'));
 
                 category.parentId = parentId;
                 category.level = parent.level + 1;
             }
-            // Note: In a real app we'd also need to update all children's levels recursively
-            // But for this requirement we'll assume shallow updates or handle edge cases
         }
 
         return this.categoryRepository.save(category);
@@ -78,7 +78,7 @@ export class NoteCategoryService {
     async remove(userId: string, tenantId: string, id: string): Promise<void> {
         const result = await this.categoryRepository.delete({ id, userId, tenantId });
         if (result.affected === 0) {
-            throw new NotFoundException('Category not found');
+            throw new NotFoundException(this.i18nService.getMessage('categoryNotFound'));
         }
     }
 }

+ 1 - 1
server/src/ocr/ocr.controller.ts

@@ -29,7 +29,7 @@ export class OcrController {
         }
         console.log(`Received image. Size: ${image.size} bytes`);
         const text = await this.ocrService.extractTextFromImage(image.buffer);
-        console.log(`OCR extraction complete. Text length: ${text.length}`);
+        console.log(`OCR extraction completed. Text length: ${text.length}`);
         return { text };
     }
 }

+ 6 - 6
server/src/ocr/ocr.service.ts

@@ -9,7 +9,7 @@ export class OcrService {
     constructor(private readonly i18n: I18nService) { }
 
     async extractTextFromImage(imageBuffer: Buffer): Promise<string> {
-        this.logger.log(`Starting image OCR extraction (${imageBuffer.length} bytes)...`);
+        this.logger.log(`Starting OCR extraction from image (${imageBuffer.length} bytes)...`);
 
         // Create worker for this request to ensure stability
         let worker: any = null;
@@ -17,7 +17,7 @@ export class OcrService {
             worker = await createWorker('chi_sim+eng+jpn');
 
             const { data: { text } } = await worker.recognize(imageBuffer);
-            this.logger.log(`OCR extraction complete. ${text.length} characters extracted.`);
+            this.logger.log(`OCR extraction completed. ${text.length} characters extracted.`);
 
             if (text.length === 0) {
                 this.logger.warn('OCR returned empty text.');
@@ -26,7 +26,7 @@ export class OcrService {
             await worker.terminate();
             return text.trim();
         } catch (error) {
-            this.logger.error(`Failed to extract OCR text: ${error.message}`);
+            this.logger.error(`OCR text extraction failed: ${error.message}`);
             if (worker) {
                 try { await worker.terminate(); } catch (e) { }
             }
@@ -38,13 +38,13 @@ export class OcrService {
         text: string;
         confidence: number;
     }> {
-        this.logger.log(`Starting image OCR extraction with confidence (${imageBuffer.length} bytes)...`);
+        this.logger.log(`Starting OCR extraction with confidence (${imageBuffer.length} bytes)...`);
 
         let worker: any = null;
         try {
             worker = await createWorker('chi_sim+eng+jpn');
             const { data } = await worker.recognize(imageBuffer);
-            this.logger.log(`OCR extraction complete. Confidence: ${data.confidence}%`);
+            this.logger.log(`OCR extraction completed. Confidence: ${data.confidence}%`);
 
             await worker.terminate();
             return {
@@ -52,7 +52,7 @@ export class OcrService {
                 confidence: data.confidence,
             };
         } catch (error) {
-            this.logger.error(`Failed to extract OCR text: ${error.message}`);
+            this.logger.error(`OCR text extraction failed: ${error.message}`);
             if (worker) {
                 try { await worker.terminate(); } catch (e) { }
             }

+ 12 - 10
server/src/pdf2image/pdf2image.interface.ts

@@ -1,18 +1,20 @@
-
+/**
+ * PDF to Image Interface Definitions
+ */
 
 export interface Pdf2ImageOptions {
-  density?: number;        
-  quality?: number;        
-  format?: 'jpeg' | 'png'; 
-  outDir?: string;         
+  density?: number;        // DPI resolution, default 300
+  quality?: number;        // JPEG quality (1-100), default 85
+  format?: 'jpeg' | 'png'; // output format, default jpeg
+  outDir?: string;         // Output directory, default ./temp
 }
 
 export interface ImageInfo {
-  path: string;            
-  pageIndex: number;       
-  size: number;            
-  width?: number;          
-  height?: number;         
+  path: string;            // Image file path
+  pageIndex: number;       // Page number (starting from 1)
+  size: number;            // File size (bytes)
+  width?: number;          // Image width
+  height?: number;         // Image height
 }
 
 export interface ConversionResult {

+ 34 - 19
server/src/pdf2image/pdf2image.service.ts

@@ -6,6 +6,7 @@ import { PDFDocument } from 'pdf-lib';
 import { exec } from 'child_process';
 import { promisify } from 'util';
 import { Pdf2ImageOptions, ImageInfo, ConversionResult } from './pdf2image.interface';
+import { I18nService } from '../i18n/i18n.service';
 
 const execAsync = promisify(exec);
 
@@ -14,11 +15,17 @@ export class Pdf2ImageService {
   private readonly logger = new Logger(Pdf2ImageService.name);
   private tempDir: string;
 
-  constructor(private configService: ConfigService) {
+  constructor(
+    private configService: ConfigService,
+    private i18nService: I18nService,
+  ) {
     this.tempDir = this.configService.get<string>('TEMP_DIR', './temp');
   }
 
-  
+  /**
+   * Convert PDF to list of images
+   * Uses ImageMagick's convert command
+   */
   async convertToImages(
     pdfPath: string,
     options: Pdf2ImageOptions = {}
@@ -30,14 +37,14 @@ export class Pdf2ImageService {
       outDir = this.tempDir,
     } = options;
 
-    
+    // Validate PDF file
     try {
       await fs.access(pdfPath);
     } catch {
-      throw new Error(`PDF file does not exist: ${pdfPath}`);
+      throw new Error(`PDF file not found: ${pdfPath}`);
     }
 
-    
+    // Create output directory
     const timestamp = Date.now();
     const outputDir = path.join(outDir, `pdf2img_${timestamp}`);
     await fs.mkdir(outputDir, { recursive: true });
@@ -46,25 +53,25 @@ export class Pdf2ImageService {
     this.logger.log(`Output directory: ${outputDir}`);
 
     try {
-      
+      // Get total page count using pdf-lib instead of pdfinfo
       const pdfBytes = await fs.readFile(pdfPath);
       const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
       const totalPages = pdfDoc.getPageCount();
 
       if (totalPages === 0) {
-        throw new Error('Unable to get page number of PDF');
+        throw new Error(this.i18nService.getMessage('pdfPageCountError'));
       }
 
-      this.logger.log(`📄 PDF conversion started: ${path.basename(pdfPath)} (Total ${totalPages} pages)`);
-      this.logger.log(`📁 Output directory: ${outputDir}`);
-      this.logger.log(`⚙️  Conversion parameters: Density=${density}dpi, Quality=${quality}%, Format=${format}`);
+      this.logger.log(`Starting PDF conversion: ${path.basename(pdfPath)} (${totalPages} pages)`);
+      this.logger.log(`Output directory: ${outputDir}`);
+      this.logger.log(`Conversion parameters: density=${density}dpi, quality=${quality}%, format=${format}`);
 
-      
+      // Convert using Python script
       const zoom = (density / 72).toFixed(2);
       const pythonScript = path.join(process.cwd(), 'pdf_to_images.py');
       const cmd = `python "${pythonScript}" "${pdfPath}" "${outputDir}" ${zoom} ${quality}`;
 
-      this.logger.log(`Running conversion command: ${cmd}`);
+      this.logger.log(`Executing conversion command: ${cmd}`);
       const { stdout } = await execAsync(cmd);
       const result = JSON.parse(stdout);
 
@@ -77,7 +84,7 @@ export class Pdf2ImageService {
       const failedCount = totalPages - successCount;
 
       this.logger.log(
-        `🎉 PDF conversion complete! ✅ Success: ${successCount} pages, ❌ Failed: ${failedCount} pages, 📊 Total pages: ${totalPages}`
+        `🎉 PDF conversion completed! ✅ Success: ${successCount} pages, ❌ Failed: ${failedCount} pages, 📊 Total pages: ${totalPages}`
       );
 
       return {
@@ -87,13 +94,15 @@ export class Pdf2ImageService {
         failedCount,
       };
     } catch (error) {
-      
+      // Cleanup temp directory
       await this.cleanupDirectory(outputDir);
       throw new Error(`PDF to image conversion failed: ${error.message}`);
     }
   }
 
-  
+  /**
+   * Batch convert multiple PDFs
+   */
   async batchConvert(pdfPaths: string[], options?: Pdf2ImageOptions): Promise<ConversionResult[]> {
     const results: ConversionResult[] = [];
     for (const pdfPath of pdfPaths) {
@@ -108,7 +117,9 @@ export class Pdf2ImageService {
     return results;
   }
 
-  
+  /**
+   * Cleanup image files
+   */
   async cleanupImages(images: ImageInfo[]): Promise<void> {
     for (const image of images) {
       try {
@@ -119,14 +130,16 @@ export class Pdf2ImageService {
       }
     }
 
-    
+    // Try to cleanup empty directory
     if (images.length > 0) {
       const dir = path.dirname(images[0].path);
       await this.cleanupDirectory(dir);
     }
   }
 
-  
+  /**
+   * Cleanup directory
+   */
   async cleanupDirectory(dir: string): Promise<void> {
     try {
       const files = await fs.readdir(dir);
@@ -139,7 +152,9 @@ export class Pdf2ImageService {
     }
   }
 
-  
+  /**
+   * Check if image quality is acceptable
+   */
   isImageQualityGood(imageInfo: ImageInfo, minSizeKB: number = 10): boolean {
     const sizeKB = imageInfo.size / 1024;
     if (sizeKB < minSizeKB) {

+ 5 - 6
server/src/podcasts/podcast.service.ts

@@ -9,6 +9,7 @@ import * as path from 'path';
 import { v4 as uuidv4 } from 'uuid';
 import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
 import { ChatService } from '../chat/chat.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class PodcastService {
@@ -24,6 +25,7 @@ export class PodcastService {
         private groupRepository: Repository<KnowledgeGroup>,
         private configService: ConfigService,
         private chatService: ChatService, // Reusing ChatService to generate script
+        private i18nService: I18nService,
     ) {
         // this.tts = new EdgeTTS();
         this.outputDir = path.join(process.cwd(), 'uploads', 'podcasts');
@@ -33,7 +35,7 @@ export class PodcastService {
     async create(userId: string, createDto: any): Promise<PodcastEpisode> {
         this.logger.log(`Creating podcast with DTO: ${JSON.stringify(createDto)}`);
         if (!userId) {
-            throw new Error('User ID is required to create a podcast');
+            throw new Error(this.i18nService.getMessage('userIdRequired'));
         }
 
         const episode = this.podcastRepository.create({
@@ -66,7 +68,7 @@ export class PodcastService {
 
     async findOne(userId: string, id: string): Promise<PodcastEpisode> {
         const episode = await this.podcastRepository.findOne({ where: { id, userId } });
-        if (!episode) throw new NotFoundException(`Podcast ${id} not found`);
+        if (!episode) throw new NotFoundException(this.i18nService.formatMessage('podcastNotFound', { id }));
         return episode;
     }
 
@@ -185,10 +187,7 @@ export class PodcastService {
                 return JSON.parse(jsonString);
             } catch (e) {
                 this.logger.error('Failed to parse podcast script JSON:', rawContent);
-                // Fallback parsing if JSON fails? Or just throw.
-                // Simple fallback: Split by newlines and try to guess speaker? 
-                // For now, throw to see errors.
-                throw new Error('Script generation failed to produce valid JSON');
+                throw new Error(this.i18nService.getMessage('scriptGenerationFailed'));
             }
         } catch (error) {
             this.logger.error('Failed to generate script:', error);

+ 4 - 2
server/src/rag/rag.module.ts

@@ -3,7 +3,8 @@ import { RagService } from './rag.service';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
 import { EmbeddingService } from '../knowledge-base/embedding.service';
 import { ModelConfigModule } from '../model-config/model-config.module';
-import { UserSettingModule } from '../user-setting/user-setting.module';
+import { TenantModule } from '../tenant/tenant.module';
+import { UserModule } from '../user/user.module';
 
 import { RerankService } from './rerank.service';
 
@@ -11,7 +12,8 @@ import { RerankService } from './rerank.service';
   imports: [
     forwardRef(() => ElasticsearchModule),
     ModelConfigModule,
-    UserSettingModule,
+    TenantModule,
+    UserModule,
   ],
   providers: [RagService, EmbeddingService, RerankService],
   exports: [RagService],

+ 61 - 29
server/src/rag/rag.service.ts

@@ -5,9 +5,10 @@ import { EmbeddingService } from '../knowledge-base/embedding.service';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { RerankService } from './rerank.service';
 import { I18nService } from '../i18n/i18n.service';
-import { UserSettingService } from '../user-setting/user-setting.service';
+import { TenantService } from '../tenant/tenant.service';
 import { ChatOpenAI } from '@langchain/openai';
 import { ModelConfig } from '../types';
+import { UserSettingService } from '../user/user-setting.service';
 
 export interface RagSearchResult {
   content: string;
@@ -15,7 +16,7 @@ export interface RagSearchResult {
   score: number;
   chunkIndex: number;
   fileId?: string;
-  originalScore?: number; 
+  originalScore?: number; // Original score before reranking (for debugging)
   metadata?: any;
 }
 
@@ -32,64 +33,70 @@ export class RagService {
     private rerankService: RerankService,
     private configService: ConfigService,
     private i18nService: I18nService,
+    private tenantService: TenantService,
     private userSettingService: UserSettingService,
   ) {
     this.defaultDimensions = parseInt(
       this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', '2560'),
     );
-    this.logger.log(`Default vector dimensions for RAG service: ${this.defaultDimensions}`);
+    this.logger.log(`RAG service default vector dimensions: ${this.defaultDimensions}`);
   }
 
   async searchKnowledge(
     query: string,
     userId: string,
     topK: number = 5,
-    vectorSimilarityThreshold: number = 0.3, 
+    vectorSimilarityThreshold: number = 0.3, // Vector search similarity threshold
     embeddingModelId?: string,
     enableFullTextSearch: boolean = false,
     enableRerank: boolean = false,
     rerankModelId?: string,
     selectedGroups?: string[],
     effectiveFileIds?: string[],
-    rerankSimilarityThreshold: number = 0.5, 
+    rerankSimilarityThreshold: number = 0.5, // Rerank similarity threshold (default 0.5)
     tenantId?: string, // New
     enableQueryExpansion?: boolean,
     enableHyDE?: boolean,
   ): Promise<RagSearchResult[]> {
-    
-    const globalSettings = await this.userSettingService.getGlobalSettings();
-
-    const effectiveTopK = topK || globalSettings.topK || 5;
-    const effectiveVectorThreshold = vectorSimilarityThreshold !== undefined ? vectorSimilarityThreshold : (globalSettings.similarityThreshold || 0.3);
-    const effectiveRerankThreshold = rerankSimilarityThreshold !== undefined ? rerankSimilarityThreshold : (globalSettings.rerankSimilarityThreshold || 0.5);
-    const effectiveEnableRerank = enableRerank !== undefined ? enableRerank : globalSettings.enableRerank;
-    const effectiveEnableFullText = enableFullTextSearch !== undefined ? enableFullTextSearch : globalSettings.enableFullTextSearch;
-    const effectiveEmbeddingId = embeddingModelId || globalSettings.selectedEmbeddingId;
-    const effectiveRerankId = rerankModelId || globalSettings.selectedRerankId;
-    const effectiveHybridWeight = globalSettings.hybridVectorWeight ?? 0.7;
-    const effectiveEnableQueryExpansion = enableQueryExpansion !== undefined ? enableQueryExpansion : globalSettings.enableQueryExpansion;
-    const effectiveEnableHyDE = enableHyDE !== undefined ? enableHyDE : globalSettings.enableHyDE;
+    // 1. Get organization settings
+    const globalSettings = await this.tenantService.getSettings(tenantId || 'default');
+
+    // Use global settings if parameters are not explicitly provided
+    const effectiveTopK = topK || globalSettings?.topK || 5;
+    const effectiveVectorThreshold = vectorSimilarityThreshold !== undefined ? vectorSimilarityThreshold : (globalSettings?.similarityThreshold || 0.3);
+    const effectiveRerankThreshold = rerankSimilarityThreshold !== undefined ? rerankSimilarityThreshold : (globalSettings?.rerankSimilarityThreshold || 0.5);
+    const effectiveEnableRerank = enableRerank !== undefined ? enableRerank : (globalSettings?.enableRerank ?? false);
+    const effectiveEnableFullText = enableFullTextSearch !== undefined ? enableFullTextSearch : (globalSettings?.enableFullTextSearch ?? false);
+    const effectiveEmbeddingId = embeddingModelId || globalSettings?.selectedEmbeddingId;
+    const effectiveRerankId = rerankModelId || globalSettings?.selectedRerankId;
+    const effectiveHybridWeight = globalSettings?.hybridVectorWeight ?? 0.7;
+    const effectiveEnableQueryExpansion = enableQueryExpansion !== undefined ? enableQueryExpansion : (globalSettings?.enableQueryExpansion ?? false);
+    const effectiveEnableHyDE = enableHyDE !== undefined ? enableHyDE : (globalSettings?.enableHyDE ?? false);
 
     this.logger.log(
       `RAG search: query="${query}", topK=${effectiveTopK}, vectorThreshold=${effectiveVectorThreshold}, rerankThreshold=${effectiveRerankThreshold}, hybridWeight=${effectiveHybridWeight}, QueryExpansion=${effectiveEnableQueryExpansion}, HyDE=${effectiveEnableHyDE}`,
     );
 
     try {
+      // 1. Prepare query (expansion or HyDE)
       let queriesToSearch = [query];
 
       if (effectiveEnableHyDE) {
         const hydeDoc = await this.generateHyDE(query, userId);
-        queriesToSearch = [hydeDoc]; 
+        queriesToSearch = [hydeDoc]; // Use virtual document as query for HyDE
       } else if (effectiveEnableQueryExpansion) {
         const expanded = await this.expandQuery(query, userId);
         queriesToSearch = [...new Set([query, ...expanded])];
       }
 
+      // Check if embedding model ID is provided
       if (!effectiveEmbeddingId) {
-        throw new Error('Embedding model ID not provided');
+        throw new Error(this.i18nService.getMessage('embeddingModelIdNotProvided'));
       }
 
+      // 2. Parallel search for multiple queries
       const searchTasks = queriesToSearch.map(async (searchQuery) => {
+        // Get query vector
         const queryEmbedding = await this.embeddingService.getEmbeddings(
           [searchQuery],
           userId,
@@ -97,6 +104,7 @@ export class RagService {
         );
         const queryVector = queryEmbedding[0];
 
+        // Select search strategy based on settings
         let results;
         if (effectiveEnableFullText) {
           results = await this.elasticsearchService.hybridSearch(
@@ -128,15 +136,19 @@ export class RagService {
       const allResultsRaw = await Promise.all(searchTasks);
       let searchResults = this.deduplicateResults(allResultsRaw.flat());
 
+      // Initial similarity filtering
       const initialCount = searchResults.length;
 
+      // Log output
       searchResults.forEach((r, idx) => {
         this.logger.log(`Hit ${idx}: score=${r.score.toFixed(4)}, fileName=${r.fileName}`);
       });
 
+      // Apply threshold filtering
       searchResults = searchResults.filter(r => r.score >= effectiveVectorThreshold);
       this.logger.log(`Initial hits: ${initialCount} -> filtered by vectorThreshold: ${searchResults.length}`);
 
+      // 3. Rerank
       let finalResults = searchResults;
 
       if (effectiveEnableRerank && effectiveRerankId && searchResults.length > 0) {
@@ -147,29 +159,33 @@ export class RagService {
             docs,
             userId,
             effectiveRerankId,
-            effectiveTopK * 2 
+            effectiveTopK * 2 // Keep a bit more results
           );
 
           finalResults = rerankedIndices.map(r => {
             const originalItem = searchResults[r.index];
             return {
               ...originalItem,
-              score: r.score, 
-              originalScore: originalItem.score 
+              score: r.score, // Rerank score
+              originalScore: originalItem.score // Original score
             };
           });
 
+          // Filter after reranking
           const beforeRerankFilter = finalResults.length;
           finalResults = finalResults.filter(r => r.score >= effectiveRerankThreshold);
           this.logger.log(`After rerank: ${beforeRerankFilter} -> filtered by rerankThreshold: ${finalResults.length}`);
 
         } catch (error) {
           this.logger.warn(`Rerank failed, falling back to filtered vector search: ${error.message}`);
+          // Fall back to filtered vector search results if rerank fails
         }
       }
 
+      // Final result count limit
       finalResults = finalResults.slice(0, effectiveTopK);
 
+      // 4. Convert to RAG result format
       const ragResults: RagSearchResult[] = finalResults.map((result) => ({
         content: result.content,
         fileName: result.fileName,
@@ -193,10 +209,13 @@ export class RagService {
     language: string = 'ja',
   ): string {
     const lang = language || 'ja';
+
+    // Build context
     let context = '';
     if (searchResults.length === 0) {
       context = this.i18nService.getMessage('ragNoDocumentFound', lang);
     } else {
+      // Group by file
       const fileGroups = new Map<string, RagSearchResult[]>();
       searchResults.forEach((result) => {
         if (!fileGroups.has(result.fileName)) {
@@ -205,6 +224,7 @@ export class RagService {
         fileGroups.get(result.fileName)!.push(result);
       });
 
+      // Build context string
       const contextParts: string[] = [];
       fileGroups.forEach((chunks, fileName) => {
         contextParts.push(this.i18nService.formatMessage('ragSource', { fileName }, lang));
@@ -250,6 +270,9 @@ ${answerHeader}`;
     return Array.from(uniqueFiles);
   }
 
+  /**
+   * Deduplicate search results
+   */
   private deduplicateResults(results: any[]): any[] {
     const unique = new Map<string, any>();
     results.forEach(r => {
@@ -261,13 +284,16 @@ ${answerHeader}`;
     return Array.from(unique.values()).sort((a, b) => b.score - a.score);
   }
 
+  /**
+   * Expand query to generate variations
+   */
   async expandQuery(query: string, userId: string, tenantId?: string): Promise<string[]> {
     try {
       const llm = await this.getInternalLlm(userId, tenantId || 'default');
       if (!llm) return [query];
 
-      const userSettings = await this.userSettingService.findOrCreate(userId);
-      const lang = userSettings.language || 'ja';
+      const userSettings = await this.userSettingService.getByUser(userId);
+      const lang = userSettings.language || 'zh';
       const prompt = this.i18nService.formatMessage('queryExpansionPrompt', { query }, lang);
 
       const response = await llm.invoke(prompt);
@@ -277,7 +303,7 @@ ${answerHeader}`;
         .split('\n')
         .map(q => q.trim())
         .filter(q => q.length > 0)
-        .slice(0, 3); 
+        .slice(0, 3); // Limit to maximum 3
 
       this.logger.log(`Query expanded: "${query}" -> [${expandedQueries.join(', ')}]`);
       return expandedQueries.length > 0 ? expandedQueries : [query];
@@ -287,13 +313,16 @@ ${answerHeader}`;
     }
   }
 
+  /**
+   * Generate hypothetical document (HyDE)
+   */
   async generateHyDE(query: string, userId: string, tenantId?: string): Promise<string> {
     try {
       const llm = await this.getInternalLlm(userId, tenantId || 'default');
       if (!llm) return query;
 
-      const userSettings = await this.userSettingService.findOrCreate(userId);
-      const lang = userSettings.language || 'ja';
+      const userSettings = await this.userSettingService.getByUser(userId);
+      const lang = userSettings.language || 'zh';
       const prompt = this.i18nService.formatMessage('hydePrompt', { query }, lang);
 
       const response = await llm.invoke(prompt);
@@ -307,10 +336,13 @@ ${answerHeader}`;
     }
   }
 
+  /**
+   * Get LLM instance for internal tasks
+   */
   private async getInternalLlm(userId: string, tenantId: string): Promise<ChatOpenAI | null> {
     try {
       const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
-      const defaultLlm = models.find(m => m.type === 'llm' && m.isEnabled);
+      const defaultLlm = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
 
       if (!defaultLlm) {
         this.logger.warn('No enabled LLM configured for internal tasks');

+ 13 - 6
server/src/rag/rerank.service.ts

@@ -19,7 +19,14 @@ export class RerankService {
         private configService: ConfigService,
     ) { }
 
-    
+    /**
+     * Execute reranking
+     * @param query User query
+     * @param documents Candidate document list
+     * @param userId User ID
+     * @param rerankModelId Selected rerank model config ID
+     * @param topN Number of results to return (top N)
+     */
     async rerank(
         query: string,
         documents: string[],
@@ -34,7 +41,7 @@ export class RerankService {
 
         let modelConfig;
         try {
-            
+            // 1. Get model config
             modelConfig = await this.modelConfigService.findOne(rerankModelId, userId, tenantId || 'default');
 
             if (!modelConfig || modelConfig.type !== ModelType.RERANK) {
@@ -49,9 +56,9 @@ export class RerankService {
 
             this.logger.log(`Reranking ${documents.length} docs with model ${modelName} at ${baseUrl}`);
 
-            
-            
-            
+            // 2. Build API request (OpenAI/SiliconFlow compatible Rerank API)
+            // Note: Standard OpenAI API does not have /rerank, but SiliconFlow/Jina/Cohere use similar structure
+            // SiliconFlow format: POST /v1/rerank { model, query, documents, top_n }
 
             const endpoint = baseUrl.replace(/\/+$/, '');
 
@@ -76,7 +83,7 @@ export class RerankService {
                 }
             );
 
-            
+            // 3. Parse response
             // Expected response format (SiliconFlow/Cohere):
             // { results: [ { index: 0, relevance_score: 0.98 }, ... ] }
 

+ 3 - 1
server/src/search-history/chat-message.entity.ts

@@ -32,7 +32,9 @@ export class ChatMessage {
   @CreateDateColumn({ name: 'created_at' })
   createdAt: Date;
 
-  @ManyToOne(() => SearchHistory, (history) => history.messages)
+  @ManyToOne(() => SearchHistory, (history) => history.messages, {
+    onDelete: 'CASCADE',
+  })
   @JoinColumn({ name: 'search_history_id' })
   searchHistory: SearchHistory;
 

+ 6 - 2
server/src/search-history/search-history.controller.ts

@@ -11,11 +11,15 @@ import {
 } from '@nestjs/common';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { SearchHistoryService } from './search-history.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Controller('search-history')
 @UseGuards(CombinedAuthGuard)
 export class SearchHistoryController {
-  constructor(private readonly searchHistoryService: SearchHistoryService) { }
+  constructor(
+    private readonly searchHistoryService: SearchHistoryService,
+    private readonly i18nService: I18nService,
+  ) { }
 
   @Get()
   async findAll(
@@ -51,6 +55,6 @@ export class SearchHistoryController {
   @Delete(':id')
   async remove(@Param('id') id: string, @Request() req) {
     await this.searchHistoryService.remove(id, req.user.id, req.user.tenantId);
-    return { message: 'Conversation history deleted successfully' };
+    return { message: this.i18nService.getMessage('searchHistoryDeleted') };
   }
 }

+ 1 - 1
server/src/search-history/search-history.service.ts

@@ -147,7 +147,7 @@ export class SearchHistoryService {
 
     const savedMessage = await this.chatMessageRepository.save(message);
 
-    
+    // Update history record update time
     await this.searchHistoryRepository.update(historyId, {
       updatedAt: new Date(),
     });

+ 41 - 8
server/src/super-admin/super-admin.controller.ts

@@ -1,10 +1,11 @@
-import { Controller, Get, Post, Put, Body, UseGuards, Param, Delete, HttpCode } from '@nestjs/common';
+import { Controller, Get, Post, Put, Body, UseGuards, Param, Delete, HttpCode, Patch, ForbiddenException, Query } from '@nestjs/common';
 import { SuperAdminService } from './super-admin.service';
 import { TenantService } from '../tenant/tenant.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
 import { UserRole } from '../user/user-role.enum';
+import { I18nService } from '../i18n/i18n.service';
 
 @Controller('v1/tenants')
 @UseGuards(CombinedAuthGuard, RolesGuard)
@@ -13,16 +14,23 @@ export class SuperAdminController {
     constructor(
         private readonly superAdminService: SuperAdminService,
         private readonly tenantService: TenantService,
+        private readonly i18nService: I18nService,
     ) { }
 
     @Get()
-    async getTenants() {
-        return this.superAdminService.getAllTenants();
+    async getTenants(
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        return this.superAdminService.getAllTenants(
+            page ? parseInt(page) : undefined,
+            limit ? parseInt(limit) : undefined
+        );
     }
 
     @Post()
-    async createTenant(@Body() body: { name: string; domain?: string; adminUserId?: string }) {
-        return this.superAdminService.createTenant(body.name, body.domain, body.adminUserId);
+    async createTenant(@Body() body: { name: string; domain?: string; adminUserId?: string; parentId?: string }) {
+        return this.superAdminService.createTenant(body.name, body.domain, body.adminUserId, body.parentId);
     }
 
     @Put(':tenantId/admin')
@@ -44,7 +52,7 @@ export class SuperAdminController {
     @Put(':tenantId')
     async updateTenant(
         @Param('tenantId') tenantId: string,
-        @Body() body: { name?: string; domain?: string }
+        @Body() body: { name?: string; domain?: string; parentId?: string }
     ) {
         return this.superAdminService.updateTenant(tenantId, body);
     }
@@ -57,8 +65,14 @@ export class SuperAdminController {
     // --- Member Management ---
 
     @Get(':tenantId/members')
-    async getMembers(@Param('tenantId') tenantId: string) {
-        return this.tenantService.getMembers(tenantId);
+    async getMembers(
+        @Param('tenantId') tenantId: string,
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        const p = page ? parseInt(page) : undefined;
+        const l = limit ? parseInt(limit) : undefined;
+        return this.tenantService.getMembers(tenantId, p, l);
     }
 
     @Post(':tenantId/members')
@@ -77,4 +91,23 @@ export class SuperAdminController {
     ) {
         await this.tenantService.removeMember(tenantId, userId);
     }
+
+    @Patch(':tenantId/members/:userId')
+    async updateMemberRole(
+        @Param('tenantId') tenantId: string,
+        @Param('userId') userId: string,
+        @Body() body: { role: string },
+    ) {
+        if (body.role !== UserRole.USER && body.role !== UserRole.TENANT_ADMIN) {
+            throw new ForbiddenException(this.i18nService.getErrorMessage('invalidMemberRole'));
+        }
+        return this.tenantService.updateMemberRole(tenantId, userId, body.role);
+    }
+
+    @Get(':tenantId/members/ids')
+    async getMemberIds(
+        @Param('tenantId') tenantId: string,
+    ) {
+        return this.tenantService.getMemberIds(tenantId);
+    }
 }

+ 7 - 7
server/src/super-admin/super-admin.service.ts

@@ -11,12 +11,12 @@ export class SuperAdminService {
         private readonly userService: UserService,
     ) { }
 
-    async getAllTenants() {
-        return this.tenantService.findAll();
+    async getAllTenants(page?: number, limit?: number) {
+        return this.tenantService.findAll(page, limit);
     }
 
-    async createTenant(name: string, domain?: string, adminUserId?: string) {
-        const tenant = await this.tenantService.create(name, domain);
+    async createTenant(name: string, domain?: string, adminUserId?: string, parentId?: string) {
+        const tenant = await this.tenantService.create(name, domain, parentId);
         if (adminUserId) {
             await this.tenantService.addMember(tenant.id, adminUserId, UserRole.TENANT_ADMIN);
         }
@@ -28,7 +28,7 @@ export class SuperAdminService {
         const members = await this.tenantService.getMembers(tenantId);
 
         // Remove existing admins from this tenant (unlinking them, not changing their role)
-        for (const member of members) {
+        for (const member of members.data) {
             if (member.role === UserRole.TENANT_ADMIN || member.role === UserRole.SUPER_ADMIN) {
                 await this.tenantService.removeMember(tenantId, member.userId);
             }
@@ -45,7 +45,7 @@ export class SuperAdminService {
             defaultPassword,
             false, // isAdmin
             tenantId,
-            UserRole.TENANT_ADMIN
+            username // displayName
         );
         return {
             user: result.user,
@@ -53,7 +53,7 @@ export class SuperAdminService {
         };
     }
 
-    async updateTenant(tenantId: string, data: { name?: string; domain?: string }) {
+    async updateTenant(tenantId: string, data: { name?: string; domain?: string; parentId?: string }) {
         return this.tenantService.update(tenantId, data);
     }
 

+ 3 - 4
server/src/tenant/tenant-setting.entity.ts

@@ -25,10 +25,6 @@ export class TenantSetting {
     @JoinColumn({ name: 'tenantId' })
     tenant: Tenant;
 
-    // Default language for the entire organization
-    @Column({ type: 'text', default: 'zh' })
-    language: string;
-
     // Default LLM model (override per user in UserSetting)
     @Column({ type: 'text', nullable: true })
     selectedLLMId: string;
@@ -41,6 +37,9 @@ export class TenantSetting {
     @Column({ type: 'text', nullable: true })
     selectedRerankId: string;
 
+    @Column({ type: 'text', nullable: true })
+    selectedVisionId: string;
+
     // Search configuration defaults
     @Column({ type: 'real', default: 0.3 })
     similarityThreshold: number;

+ 36 - 7
server/src/tenant/tenant.controller.ts

@@ -5,11 +5,13 @@ import {
     ForbiddenException,
     Get,
     Param,
+    Patch,
     Post,
     Put,
     Request,
     UseGuards,
     HttpCode,
+    Query,
 } from '@nestjs/common';
 import { TenantService } from './tenant.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@@ -21,8 +23,13 @@ export class TenantController {
     constructor(private readonly tenantService: TenantService) { }
 
     @Get()
-    findAll() {
-        return this.tenantService.findAll();
+    findAll(
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        const p = page ? parseInt(page) : undefined;
+        const l = limit ? parseInt(limit) : undefined;
+        return this.tenantService.findAll(p, l);
     }
 
     @Get(':id')
@@ -31,12 +38,12 @@ export class TenantController {
     }
 
     @Post()
-    create(@Body() body: { name: string; description?: string }) {
-        return this.tenantService.create(body.name, body.description);
+    create(@Body() body: { name: string; domain?: string; parentId?: string }) {
+        return this.tenantService.create(body.name, body.domain, body.parentId);
     }
 
     @Put(':id')
-    update(@Param('id') id: string, @Body() body: { name?: string; description?: string; isActive?: boolean }) {
+    update(@Param('id') id: string, @Body() body: { name?: string; domain?: string; parentId?: string; isActive?: boolean }) {
         return this.tenantService.update(id, body);
     }
 
@@ -56,8 +63,14 @@ export class TenantController {
     }
 
     @Get(':id/members')
-    getMembers(@Param('id') id: string) {
-        return this.tenantService.getMembers(id);
+    getMembers(
+        @Param('id') id: string,
+        @Query('page') page?: string,
+        @Query('limit') limit?: string,
+    ) {
+        const p = page ? parseInt(page) : undefined;
+        const l = limit ? parseInt(limit) : undefined;
+        return this.tenantService.getMembers(id, p, l);
     }
 
     @Post(':id/members')
@@ -68,6 +81,15 @@ export class TenantController {
         return this.tenantService.addMember(id, body.userId, body.role);
     }
 
+    @Patch(':id/members/:userId')
+    async updateMemberRole(
+        @Param('id') id: string,
+        @Param('userId') userId: string,
+        @Body() body: { role: string },
+    ) {
+        return this.tenantService.updateMemberRole(id, userId, body.role);
+    }
+
     @Delete(':id/members/:userId')
     @HttpCode(204)
     async removeMember(
@@ -76,4 +98,11 @@ export class TenantController {
     ) {
         await this.tenantService.removeMember(id, userId);
     }
+
+    @Get(':id/members/ids')
+    getMemberIds(
+        @Param('id') id: string,
+    ) {
+        return this.tenantService.getMemberIds(id);
+    }
 }

+ 12 - 4
server/src/tenant/tenant.entity.ts

@@ -8,6 +8,7 @@ import {
 } from 'typeorm';
 import { User } from '../user/user.entity';
 import { TenantMember } from './tenant-member.entity';
+import { JoinColumn, ManyToOne } from 'typeorm';
 
 @Entity('tenants')
 export class Tenant {
@@ -20,11 +21,18 @@ export class Tenant {
     @Column({ type: 'text', unique: true, nullable: true })
     domain: string;
 
-    @Column({ type: 'text', default: '{}' })
-    settings: string;
+    @Column({ type: 'boolean', default: false })
+    isSystem: boolean;
 
-    @Column({ name: 'default_model_id', type: 'text', nullable: true })
-    defaultModelId: string;
+    @Column({ name: 'parent_id', type: 'text', nullable: true })
+    parentId: string;
+
+    @ManyToOne(() => Tenant, (tenant) => tenant.children, { onDelete: 'SET NULL' })
+    @JoinColumn({ name: 'parent_id' })
+    parent: Tenant;
+
+    @OneToMany(() => Tenant, (tenant) => tenant.parent)
+    children: Tenant[];
 
     @OneToMany(() => TenantMember, (member) => member.tenant)
     members: TenantMember[];

+ 61 - 52
server/src/tenant/tenant.service.ts

@@ -9,6 +9,7 @@ import { Repository } from 'typeorm';
 import { Tenant } from './tenant.entity';
 import { TenantSetting } from './tenant-setting.entity';
 import { TenantMember } from './tenant-member.entity';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class TenantService {
@@ -21,18 +22,29 @@ export class TenantService {
         private readonly tenantSettingRepository: Repository<TenantSetting>,
         @InjectRepository(TenantMember)
         private readonly tenantMemberRepository: Repository<TenantMember>,
+        private readonly i18nService: I18nService,
     ) { }
 
-    async findAll(): Promise<Tenant[]> {
-        return this.tenantRepository.find({
-            relations: ['members', 'members.user'],
-            order: { createdAt: 'ASC' }
-        });
+    async findAll(page?: number, limit?: number): Promise<{ data: Tenant[]; total: number } | Tenant[]> {
+        const queryBuilder = this.tenantRepository.createQueryBuilder('tenant')
+            .leftJoinAndSelect('tenant.members', 'members')
+            .leftJoinAndSelect('members.user', 'user')
+            .orderBy('tenant.createdAt', 'ASC');
+
+        if (page !== undefined && limit !== undefined) {
+            const [data, total] = await queryBuilder
+                .skip((page - 1) * limit)
+                .take(limit)
+                .getManyAndCount();
+            return { data, total };
+        }
+
+        return queryBuilder.getMany();
     }
 
     async findById(id: string): Promise<Tenant> {
         const tenant = await this.tenantRepository.findOneBy({ id });
-        if (!tenant) throw new NotFoundException(`Tenant ${id} not found`);
+        if (!tenant) throw new NotFoundException(this.i18nService.getMessage('tenantNotFound'));
         return tenant;
     }
 
@@ -40,53 +52,22 @@ export class TenantService {
         return this.tenantRepository.findOneBy({ name });
     }
 
-    async create(name: string, domain?: string): Promise<Tenant> {
-        const existing = await this.findByName(name);
-        if (existing) throw new BadRequestException(`Tenant name "${name}" already exists`);
-
-        const tenant = this.tenantRepository.create({ name, domain, settings: '{}' });
-        const saved = await this.tenantRepository.save(tenant);
-
-        // Auto-create default TenantSettings
-        const setting = this.tenantSettingRepository.create({ tenantId: saved.id });
-        await this.tenantSettingRepository.save(setting);
-
-        return saved;
+    async create(name: string, domain?: string, parentId?: string, isSystem: boolean = false): Promise<Tenant> {
+        const tenant = this.tenantRepository.create({ name, domain, parentId, isSystem });
+        return this.tenantRepository.save(tenant);
     }
 
     async update(id: string, data: Partial<Tenant>): Promise<Tenant> {
-        const tenant = await this.findById(id);
-        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
-            throw new ForbiddenException(`Cannot modify the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
-        }
         await this.tenantRepository.update(id, data);
         return this.findById(id);
     }
 
     async remove(id: string): Promise<void> {
-        const tenant = await this.findById(id);
-        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
-            throw new ForbiddenException(`Cannot delete the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
-        }
         await this.tenantRepository.delete(id);
     }
 
-    async getSettings(tenantId: string): Promise<TenantSetting> {
-        let setting = await this.tenantSettingRepository.findOneBy({ tenantId });
-        if (!setting) {
-            // Defensive: Check if tenant actually exists before creating settings
-            // to avoid FOREIGN KEY constraint failure if tenantId is invalid/legacy
-            const tenantExists = await this.tenantRepository.findOneBy({ id: tenantId });
-            if (!tenantExists) {
-                console.warn(`[TenantService] Attempted to get settings for non-existent tenant: ${tenantId}`);
-                // Return a transient default object without saving to DB
-                return this.tenantSettingRepository.create({ tenantId });
-            }
-
-            setting = this.tenantSettingRepository.create({ tenantId });
-            setting = await this.tenantSettingRepository.save(setting);
-        }
-        return setting;
+    async getSettings(tenantId: string): Promise<TenantSetting | null> {
+        return this.tenantSettingRepository.findOneBy({ tenantId });
     }
 
     async updateSettings(tenantId: string, data: Partial<TenantSetting>): Promise<TenantSetting> {
@@ -110,11 +91,16 @@ export class TenantService {
         return this.tenantSettingRepository.save(setting);
     }
 
-    async addMember(tenantId: string, userId: string, role: string = 'USER'): Promise<TenantMember> {
-        const tenant = await this.findById(tenantId);
-        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
-            throw new ForbiddenException(`Cannot manually bind members to the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+    async updateMemberRole(tenantId: string, userId: string, role: string): Promise<TenantMember> {
+        const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId });
+        if (!existing) {
+            throw new ForbiddenException(`Member not found in this organization`);
         }
+        existing.role = role as any;
+        return this.tenantMemberRepository.save(existing);
+    }
+
+    async addMember(tenantId: string, userId: string, role: string = 'USER'): Promise<TenantMember> {
         const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId });
         if (existing) {
             existing.role = role as any;
@@ -128,11 +114,23 @@ export class TenantService {
         await this.tenantMemberRepository.delete({ tenantId, userId });
     }
 
-    async getMembers(tenantId: string): Promise<TenantMember[]> {
-        return this.tenantMemberRepository.find({
-            where: { tenantId },
-            relations: ['user'],
-        });
+    async getMembers(tenantId: string, page?: number, limit?: number): Promise<{ data: TenantMember[]; total: number }> {
+        const queryBuilder = this.tenantMemberRepository.createQueryBuilder('member')
+            .leftJoinAndSelect('member.user', 'user')
+            .where('member.tenantId = :tenantId', { tenantId })
+            .select(['member', 'user.id', 'user.username', 'user.displayName', 'user.isAdmin'])
+            .orderBy('member.createdAt', 'DESC');
+
+        if (page !== undefined && limit !== undefined) {
+            const [data, total] = await queryBuilder
+                .skip((page - 1) * limit)
+                .take(limit)
+                .getManyAndCount();
+            return { data, total };
+        }
+
+        const [data, total] = await queryBuilder.getManyAndCount();
+        return { data, total };
     }
 
     /**
@@ -142,8 +140,19 @@ export class TenantService {
     async ensureDefaultTenant(): Promise<Tenant> {
         let defaultTenant = await this.findByName(TenantService.DEFAULT_TENANT_NAME);
         if (!defaultTenant) {
-            defaultTenant = await this.create(TenantService.DEFAULT_TENANT_NAME, 'default.localhost');
+            defaultTenant = await this.create(TenantService.DEFAULT_TENANT_NAME, 'default.localhost', undefined, true);
+        } else if (!defaultTenant.isSystem) {
+            defaultTenant.isSystem = true;
+            await this.tenantRepository.save(defaultTenant);
         }
         return defaultTenant;
     }
+
+    async getMemberIds(tenantId: string): Promise<string[]> {
+        const members = await this.tenantMemberRepository.find({
+            where: { tenantId },
+            select: ['userId'],
+        });
+        return members.map(m => m.userId);
+    }
 }

+ 6 - 2
server/src/tika/tika.service.ts

@@ -1,16 +1,20 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import * as fs from 'fs';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class TikaService {
   private readonly logger = new Logger(TikaService.name);
   private readonly tikaHost: string;
 
-  constructor(private configService: ConfigService) {
+  constructor(
+    private configService: ConfigService,
+    private i18nService: I18nService,
+  ) {
     const tikaHost = this.configService.get<string>('TIKA_HOST');
     if (!tikaHost) {
-      throw new Error('TIKA_HOST environment variable is required but not set');
+      throw new Error(this.i18nService.getMessage('tikaHostRequired'));
     }
     this.tikaHost = tikaHost;
   }

+ 8 - 8
server/src/upload/upload.controller.ts

@@ -34,7 +34,7 @@ export interface UploadConfigDto {
   chunkSize?: string;
   chunkOverlap?: string;
   embeddingModelId?: string;
-  mode?: 'fast' | 'precise'; 
+  mode?: 'fast' | 'precise'; // Processing mode
   groupIds?: string; // JSON string of group IDs
 }
 
@@ -98,7 +98,7 @@ export class UploadController {
   @UseInterceptors(
     FileInterceptor('file', {
       fileFilter: (req, file, cb) => {
-        
+        // Check by image MIME type or extension
         const isAllowed = IMAGE_MIME_TYPES.includes(file.mimetype) ||
           isAllowedByExtension(file.originalname);
 
@@ -124,7 +124,7 @@ export class UploadController {
       throw new BadRequestException(this.i18nService.getMessage('uploadNoFile'));
     }
 
-    
+    // Validate file size(frontend limit + backend validation)
     const maxFileSize = parseInt(
       process.env.MAX_FILE_SIZE || String(MAX_FILE_SIZE),
     ); // 100MB
@@ -134,7 +134,7 @@ export class UploadController {
       );
     }
 
-    
+    // Validate embedding model config
     if (!config.embeddingModelId) {
       throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired'));
     }
@@ -145,7 +145,7 @@ export class UploadController {
 
     const fileInfo = await this.uploadService.processUploadedFile(file);
 
-    
+    // Parse config parameters and set safe default values
     const indexingConfig = {
       chunkSize: config.chunkSize ? parseInt(config.chunkSize) : DEFAULT_CHUNK_SIZE,
       chunkOverlap: config.chunkOverlap ? parseInt(config.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
@@ -153,7 +153,7 @@ export class UploadController {
       groupIds: config.groupIds ? JSON.parse(config.groupIds) : [],
     };
 
-    
+    // Ensure overlap <= 50% of chunk size
     if (indexingConfig.chunkOverlap > indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO) {
       indexingConfig.chunkOverlap = Math.floor(indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO);
       this.logger.warn(
@@ -161,7 +161,7 @@ export class UploadController {
       );
     }
 
-    
+    // Save to database and trigger indexing(async)
     const kb = await this.knowledgeBaseService.createAndIndex(
       fileInfo,
       req.user.id,
@@ -180,7 +180,7 @@ export class UploadController {
       status: kb.status,
       mode: config.mode || 'fast',
       config: indexingConfig,
-      estimatedChunks: Math.ceil(file.size / (indexingConfig.chunkSize * 4)), 
+      estimatedChunks: Math.ceil(file.size / (indexingConfig.chunkSize * 4)), // Estimated chunk count
     };
   }
 

+ 6 - 7
server/src/upload/upload.module.ts

@@ -12,8 +12,7 @@ import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
-    KnowledgeBaseModule,
-    KnowledgeGroupModule,
+    KnowledgeBaseModule, // Add to
     UserModule,
     MulterModule.registerAsync({
       imports: [ConfigModule],
@@ -21,14 +20,14 @@ import { UserModule } from '../user/user.module';
         const uploadPath = configService.get<string>(
           'UPLOAD_FILE_PATH',
           './uploads',
-        ); 
+        ); // Get upload path from env varor use default './uploads' use
 
-        
+        // Ensure upload directory exists
         if (!fs.existsSync(uploadPath)) {
           fs.mkdirSync(uploadPath, { recursive: true });
         }
 
-        
+        // Get max file size from env var, default 100MB
         const maxFileSize = parseInt(
           configService.get<string>('MAX_FILE_SIZE', '104857600'), // 100MB in bytes
         );
@@ -45,7 +44,7 @@ import { UserModule } from '../user/user.module';
               cb(null, fullPath);
             },
             filename: (req, file, cb) => {
-              
+              // Fix Chinese filename garbling
               file.originalname = Buffer.from(
                 file.originalname,
                 'latin1',
@@ -59,7 +58,7 @@ import { UserModule } from '../user/user.module';
             },
           }),
           limits: {
-            fileSize: maxFileSize, 
+            fileSize: maxFileSize, // File size limit
           },
         };
       },

+ 7 - 8
server/src/upload/upload.service.ts

@@ -6,20 +6,19 @@ import * as path from 'path';
 
 @Injectable()
 export class UploadService {
-  private readonly logger = new Logger(UploadService.name);
-
-  constructor(
-    private readonly kbService: KnowledgeBaseService,
-    private readonly groupService: KnowledgeGroupService,
-  ) { }
-
   async processUploadedFile(file: Express.Multer.File) {
+    // Add more business logic here. Example:
+    // - Save file info to database
+    // - Call other services to process file (Tika text extraction, ES indexing etc.)
+    // - Validate file format or analyze content
+
+    // Currently only return basic file info
     return {
       filename: file.filename,
       originalname: file.originalname,
       size: file.size,
       mimetype: file.mimetype,
-      path: file.path,
+      path: file.path, // After Multer saves file, full path is in file.path
     };
   }
 

+ 0 - 85
server/src/user-setting/dto/create-user-setting.dto.ts

@@ -1,85 +0,0 @@
-// server/src/user-setting/dto/create-user-setting.dto.ts
-import {
-  IsBoolean,
-  IsNotEmpty,
-  IsNumber,
-  IsOptional,
-  IsString,
-  Max,
-  Min,
-} from 'class-validator';
-import { DEFAULT_SETTINGS } from '../../defaults'; // Import default settings for validation min/max
-
-export class CreateUserSettingDto {
-  @IsString()
-  selectedLLMId: string = DEFAULT_SETTINGS.selectedLLMId;
-
-  @IsString()
-  selectedEmbeddingId: string = DEFAULT_SETTINGS.selectedEmbeddingId;
-
-  @IsString()
-  @IsOptional()
-  selectedRerankId?: string = DEFAULT_SETTINGS.selectedRerankId;
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  temperature: number = DEFAULT_SETTINGS.temperature;
-
-  @IsNumber()
-  @Min(1) // Assuming min 1 token
-  maxTokens: number = DEFAULT_SETTINGS.maxTokens;
-
-  @IsBoolean()
-  enableRerank: boolean = DEFAULT_SETTINGS.enableRerank;
-
-  @IsNumber()
-  @Min(1)
-  topK: number = DEFAULT_SETTINGS.topK;
-
-  @IsNumber()
-  @Min(0) // Score threshold usually 0 to 1
-  @Max(1)
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  @IsOptional()
-  similarityThreshold: number = DEFAULT_SETTINGS.similarityThreshold;
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  @IsOptional()
-  rerankSimilarityThreshold: number = DEFAULT_SETTINGS.rerankSimilarityThreshold;
-
-  @IsBoolean()
-  @IsOptional()
-  enableFullTextSearch: boolean = DEFAULT_SETTINGS.enableFullTextSearch;
-
-  @IsNumber()
-  @Min(0)
-  @Max(1)
-  @IsOptional()
-  hybridVectorWeight: number = DEFAULT_SETTINGS.hybridVectorWeight;
-
-  @IsBoolean()
-  @IsOptional()
-  enableQueryExpansion: boolean = DEFAULT_SETTINGS.enableQueryExpansion;
-
-  @IsBoolean()
-  @IsOptional()
-  enableHyDE: boolean = DEFAULT_SETTINGS.enableHyDE;
-
-  @IsNumber()
-  @IsOptional()
-  chunkSize: number = DEFAULT_SETTINGS.chunkSize;
-
-  @IsNumber()
-  @IsOptional()
-  chunkOverlap: number = DEFAULT_SETTINGS.chunkOverlap;
-
-  @IsString()
-  @IsOptional()
-  coachKbId?: string;
-}

+ 0 - 5
server/src/user-setting/dto/update-user-setting.dto.ts

@@ -1,5 +0,0 @@
-// server/src/user-setting/dto/update-user-setting.dto.ts
-import { PartialType } from '@nestjs/mapped-types';
-import { CreateUserSettingDto } from './create-user-setting.dto';
-
-export class UpdateUserSettingDto extends PartialType(CreateUserSettingDto) {}

+ 0 - 15
server/src/user-setting/dto/user-setting-response.dto.ts

@@ -1,15 +0,0 @@
-// server/src/user-setting/dto/user-setting-response.dto.ts
-import { CreateUserSettingDto } from './create-user-setting.dto';
-import { UserSetting } from '../user-setting.entity';
-
-export class UserSettingResponseDto extends CreateUserSettingDto {
-  id: string;
-  userId: string;
-  createdAt: Date;
-  updatedAt: Date;
-
-  constructor(partial: Partial<UserSetting>) {
-    super(); // Call the constructor of CreateUserSettingDto to apply defaults
-    Object.assign(this, partial);
-  }
-}

+ 0 - 115
server/src/user-setting/user-setting.controller.ts

@@ -1,115 +0,0 @@
-// server/src/user-setting/user-setting.controller.ts
-import {
-  Body,
-  Controller,
-  Get,
-  HttpCode,
-  HttpStatus,
-  Put,
-  Req,
-  UseGuards,
-} from '@nestjs/common';
-import { UserSettingService } from './user-setting.service';
-import { UpdateUserSettingDto } from './dto/update-user-setting.dto';
-import { CombinedAuthGuard } from '../auth/combined-auth.guard';
-import { UserSettingResponseDto } from './dto/user-setting-response.dto';
-import { ModelConfigService } from '../model-config/model-config.service';
-import { plainToClass } from 'class-transformer';
-import { RolesGuard } from '../auth/roles.guard';
-import { Roles } from '../auth/roles.decorator';
-import { UserRole } from '../user/user-role.enum';
-import { TenantService } from '../tenant/tenant.service';
-
-@UseGuards(CombinedAuthGuard, RolesGuard)
-@Controller('settings') // Global prefix /api/settings
-export class UserSettingController {
-  constructor(
-    private readonly userSettingService: UserSettingService,
-    private readonly modelConfigService: ModelConfigService,
-    private readonly tenantService: TenantService,
-  ) { }
-
-  @Get('global')
-  async getGlobal(): Promise<UserSettingResponseDto> {
-    const globalSetting = await this.userSettingService.getGlobalSettings();
-    return plainToClass(UserSettingResponseDto, globalSetting);
-  }
-
-  @Get('tenant')
-  async getTenantSettings(@Req() req) {
-    if (!req.user.tenantId) {
-      return this.userSettingService.getGlobalSettings();
-    }
-    return this.tenantService.getSettings(req.user.tenantId);
-  }
-
-  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-  @Put('global')
-  @HttpCode(HttpStatus.OK)
-  async updateGlobal(
-    @Body() updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSettingResponseDto> {
-    const globalSetting = await this.userSettingService.updateGlobalSettings(
-      updateUserSettingDto,
-    );
-    return plainToClass(UserSettingResponseDto, globalSetting);
-  }
-
-  @Get()
-  async findOne(@Req() req): Promise<UserSettingResponseDto> {
-    const userSetting = await this.userSettingService.findOrCreate(req.user.id);
-    return plainToClass(UserSettingResponseDto, userSetting);
-  }
-
-  @Put()
-  @HttpCode(HttpStatus.OK)
-  async update(
-    @Req() req,
-    @Body() updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSettingResponseDto> {
-    const userSetting = await this.userSettingService.update(
-      req.user.id,
-      updateUserSettingDto,
-    );
-    return plainToClass(UserSettingResponseDto, userSetting);
-  }
-
-  @Get('vision-models')
-  async getVisionModels(@Req() req: any) {
-    const userId = req.user.id;
-    const models = await this.modelConfigService.findByType(userId, req.user.tenantId, 'vision');
-    return models;
-  }
-
-  @Get('vision-model')
-  async getVisionModel(@Req() req: any) {
-    const userId = req.user.id;
-    const visionModelId =
-      await this.userSettingService.getVisionModelId(userId);
-    return { visionModelId };
-  }
-
-  @Put('vision-model')
-  async updateVisionModel(
-    @Req() req: any,
-    @Body() body: { visionModelId: string },
-  ) {
-    const userId = req.user.id;
-    await this.userSettingService.updateVisionModel(userId, body.visionModelId);
-    return { success: true };
-  }
-
-  @Get('language')
-  async getLanguage(@Req() req: any) {
-    const userId = req.user.id;
-    const language = await this.userSettingService.getLanguage(userId);
-    return { language };
-  }
-
-  @Put('language')
-  async updateLanguage(@Req() req: any, @Body() body: { language: string }) {
-    const userId = req.user.id;
-    await this.userSettingService.updateLanguage(userId, body.language);
-    return { success: true };
-  }
-}

+ 0 - 87
server/src/user-setting/user-setting.entity.ts

@@ -1,87 +0,0 @@
-// server/src/user-setting/user-setting.entity.ts
-import {
-  Column,
-  CreateDateColumn,
-  Entity,
-  JoinColumn,
-  OneToOne,
-  PrimaryGeneratedColumn,
-  UpdateDateColumn,
-} from 'typeorm';
-import { User } from '../user/user.entity'; 
-
-@Entity('user_settings')
-export class UserSetting {
-  @PrimaryGeneratedColumn('uuid')
-  id: string;
-
-  @Column({ type: 'text', unique: true, nullable: true }) // Ensure one-to-one relationship via unique userId, but allow null for global
-  userId: string;
-
-  @OneToOne(() => User, (user) => user.userSetting, { onDelete: 'CASCADE', nullable: true })
-  @JoinColumn({ name: 'userId' })
-  user: User;
-
-  @Column({ type: 'boolean', default: false })
-  isGlobal: boolean;
-
-  @Column({ type: 'text', default: 'gpt-3.5-turbo' })
-  selectedLLMId: string;
-
-  @Column({ type: 'text', default: 'text-embedding-3-small' })
-  selectedEmbeddingId: string;
-
-  @Column({ type: 'text', nullable: true })
-  selectedRerankId: string;
-
-  @Column({ type: 'real', default: 0.7 })
-  temperature: number;
-
-  @Column({ type: 'integer', default: 2048 })
-  maxTokens: number;
-
-  @Column({ type: 'boolean', default: false })
-  enableRerank: boolean;
-
-  @Column({ type: 'integer', default: 5 })
-  topK: number;
-
-  @Column({ type: 'real', default: 0.3 })
-  similarityThreshold: number;
-
-  @Column({ type: 'real', default: 0.5 })
-  rerankSimilarityThreshold: number;
-
-  @Column({ type: 'boolean', default: false })
-  enableFullTextSearch: boolean;
-
-  @Column({ type: 'real', default: 0.7 })
-  hybridVectorWeight: number;
-
-  @Column({ type: 'boolean', default: false })
-  enableQueryExpansion: boolean;
-
-  @Column({ type: 'boolean', default: false })
-  enableHyDE: boolean;
-
-  @Column({ type: 'integer', default: 1000 })
-  chunkSize: number;
-
-  @Column({ type: 'integer', default: 100 })
-  chunkOverlap: number;
-
-  @Column({ type: 'text', nullable: true })
-  defaultVisionModelId: string;
-
-  @Column({ type: 'text', default: 'zh' })
-  language: string;
-
-  @Column({ type: 'text', nullable: true })
-  coachKbId: string;
-
-  @CreateDateColumn({ name: 'created_at' })
-  createdAt: Date;
-
-  @UpdateDateColumn({ name: 'updated_at' })
-  updatedAt: Date;
-}

+ 0 - 16
server/src/user-setting/user-setting.module.ts

@@ -1,16 +0,0 @@
-// server/src/user-setting/user-setting.module.ts
-import { Module } from '@nestjs/common';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { UserSetting } from './user-setting.entity';
-import { UserSettingService } from './user-setting.service';
-import { UserSettingController } from './user-setting.controller';
-import { ModelConfigModule } from '../model-config/model-config.module';
-import { TenantModule } from '../tenant/tenant.module';
-
-@Module({
-  imports: [TypeOrmModule.forFeature([UserSetting]), ModelConfigModule, TenantModule],
-  providers: [UserSettingService],
-  controllers: [UserSettingController],
-  exports: [UserSettingService], // Export if other modules need to use UserSettingService
-})
-export class UserSettingModule { }

+ 0 - 165
server/src/user-setting/user-setting.service.ts

@@ -1,165 +0,0 @@
-// server/src/user-setting/user-setting.service.ts
-import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; // Added OnModuleInit, Logger
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-import { UserSetting } from './user-setting.entity';
-import { UpdateUserSettingDto } from './dto/update-user-setting.dto'; // Removed CreateUserSettingDto
-import { DEFAULT_SETTINGS } from '../defaults'; // Corrected import path
-
-@Injectable()
-export class UserSettingService implements OnModuleInit {
-  private readonly logger = new Logger(UserSettingService.name);
-
-  constructor(
-    @InjectRepository(UserSetting)
-    private userSettingRepository: Repository<UserSetting>,
-  ) { }
-
-  async onModuleInit() {
-    await this.initializeGlobalSettings();
-  }
-
-  private async initializeGlobalSettings() {
-    
-    let globalSetting = await this.userSettingRepository.findOne({
-      where: { isGlobal: true },
-    });
-
-    if (globalSetting) {
-      this.logger.log('Global settings already initialized.');
-      return;
-    }
-
-    
-    const legacySystemSetting = await this.userSettingRepository.findOne({
-      where: { userId: 'system' },
-    });
-
-    if (legacySystemSetting) {
-      this.logger.log('Migrating legacy system settings to new global format...');
-      legacySystemSetting.isGlobal = true;
-      legacySystemSetting.userId = null as any; // Clear the old system userId
-      await this.userSettingRepository.save(legacySystemSetting);
-      this.logger.log('Migration complete.');
-    } else {
-      
-      this.logger.log('No global settings found. Creating initial global settings...');
-      const newGlobalSetting = this.userSettingRepository.create({
-        isGlobal: true,
-        userId: null as any,
-        ...DEFAULT_SETTINGS,
-      });
-      await this.userSettingRepository.save(newGlobalSetting);
-      this.logger.log('Initial global settings created.');
-    }
-  }
-
-  async findOrCreate(userId: string): Promise<UserSetting> {
-    let userSetting = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-
-    if (!userSetting) {
-      const newSetting = this.userSettingRepository.create({
-        userId,
-        ...DEFAULT_SETTINGS, // Use default frontend settings as initial backend settings
-      });
-      userSetting = await this.userSettingRepository.save(newSetting);
-    }
-    return userSetting;
-  }
-
-  async update(
-    userId: string,
-    updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSetting> {
-    const existingSetting = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-
-    if (!existingSetting) {
-      // If no setting exists, create one with default values and then apply updates
-      const newSetting = this.userSettingRepository.create({
-        userId,
-        ...DEFAULT_SETTINGS,
-        ...updateUserSettingDto,
-      });
-      return this.userSettingRepository.save(newSetting);
-    }
-
-    const updated = this.userSettingRepository.merge(
-      existingSetting,
-      updateUserSettingDto,
-    );
-    return this.userSettingRepository.save(updated);
-  }
-
-  async updateVisionModel(
-    userId: string,
-    visionModelId: string,
-  ): Promise<UserSetting> {
-    const settings = await this.findOrCreate(userId);
-    settings.defaultVisionModelId = visionModelId;
-    return this.userSettingRepository.save(settings);
-  }
-
-  async getVisionModelId(userId: string): Promise<string | null> {
-    const settings = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-    return settings?.defaultVisionModelId || null;
-  }
-
-  async updateLanguage(userId: string, language: string): Promise<UserSetting> {
-    console.log('=== updateLanguage Debug ===');
-    console.log('userId:', userId);
-    console.log('New language:', language);
-    const settings = await this.findOrCreate(userId);
-    console.log('Settings before update:', settings);
-    settings.language = language;
-    const result = await this.userSettingRepository.save(settings);
-    console.log('Result after update:', result);
-    console.log('===============================');
-    return result;
-  }
-
-  async getLanguage(userId: string): Promise<string> {
-    const settings = await this.userSettingRepository.findOne({
-      where: { userId },
-    });
-    console.log('=== getLanguage Debug ===');
-    console.log('userId:', userId);
-    console.log('settings:', settings);
-    console.log('settings.language:', settings?.language);
-    console.log('Returned language:', settings?.language || 'zh');
-    console.log('============================');
-    return settings?.language || 'zh';
-  }
-
-  
-  async getGlobalSettings(): Promise<UserSetting> {
-    const globalSetting = await this.userSettingRepository.findOne({
-      where: { isGlobal: true },
-    });
-
-    if (!globalSetting) {
-      
-      await this.initializeGlobalSettings();
-      return this.getGlobalSettings();
-    }
-    return globalSetting;
-  }
-
-  
-  async updateGlobalSettings(
-    updateUserSettingDto: UpdateUserSettingDto,
-  ): Promise<UserSetting> {
-    const globalSetting = await this.getGlobalSettings();
-    const updated = this.userSettingRepository.merge(
-      globalSetting,
-      updateUserSettingDto,
-    );
-    return this.userSettingRepository.save(updated);
-  }
-}
-

+ 7 - 4
server/src/user/dto/create-user.dto.ts

@@ -1,4 +1,6 @@
-import { IsNotEmpty, IsString, MinLength, IsOptional } from 'class-validator';
+import { IsNotEmpty, IsString, MinLength, IsOptional, IsEnum } from 'class-validator';
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { UserRole } from '../user-role.enum';
 
 export class CreateUserDto {
   @IsString()
@@ -7,10 +9,11 @@ export class CreateUserDto {
 
   @IsString()
   @IsNotEmpty()
-  @MinLength(8, { message: 'Password must be at least 8 characters long' })
   password: string;
 
-  @IsOptional()
+  @ApiPropertyOptional()
   @IsString()
-  role?: string;
+  @IsOptional()
+  displayName?: string;
+
 }

+ 7 - 4
server/src/user/dto/update-user.dto.ts

@@ -1,6 +1,12 @@
-import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
+import { IsBoolean, IsOptional, IsString, MinLength, IsEnum } from 'class-validator';
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { UserRole } from '../user-role.enum';
 
 export class UpdateUserDto {
+  @IsOptional()
+  @IsString()
+  displayName?: string;
+
   @IsOptional()
   @IsBoolean()
   isAdmin?: boolean;
@@ -9,9 +15,6 @@ export class UpdateUserDto {
   @IsString()
   tenantId?: string;
 
-  @IsOptional()
-  @IsString()
-  role?: string;
 
   @IsOptional()
   @IsString()

+ 2 - 1
server/src/user/dto/user-safe.dto.ts

@@ -5,8 +5,9 @@ import { UserRole } from '../user-role.enum';
 export type SafeUser = {
   id: string;
   username: string;
+  displayName?: string;
   isAdmin: boolean;
-  role: UserRole;
+  role: UserRole; // Computed property
   tenantId: string;
   createdAt: Date;
   updatedAt: Date;

+ 32 - 0
server/src/user/user-setting.entity.ts

@@ -0,0 +1,32 @@
+import {
+  Column,
+  CreateDateColumn,
+  Entity,
+  JoinColumn,
+  OneToOne,
+  PrimaryGeneratedColumn,
+  UpdateDateColumn,
+} from 'typeorm';
+import { User } from './user.entity';
+
+@Entity('user_settings')
+export class UserSetting {
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
+
+  @Column({ type: 'text' })
+  userId: string;
+
+  @OneToOne(() => User, { onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'userId' })
+  user: User;
+
+  @Column({ type: 'text', default: 'zh' })
+  language: string;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
+}

+ 27 - 0
server/src/user/user-setting.service.ts

@@ -0,0 +1,27 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { UserSetting } from './user-setting.entity';
+
+@Injectable()
+export class UserSettingService {
+  constructor(
+    @InjectRepository(UserSetting)
+    private userSettingRepository: Repository<UserSetting>,
+  ) {}
+
+  async getByUser(userId: string): Promise<UserSetting> {
+    let setting = await this.userSettingRepository.findOne({ where: { userId } });
+    if (!setting) {
+      setting = this.userSettingRepository.create({ userId, language: 'zh' });
+      await this.userSettingRepository.save(setting);
+    }
+    return setting;
+  }
+
+  async update(userId: string, language: string): Promise<UserSetting> {
+    const setting = await this.getByUser(userId);
+    setting.language = language;
+    return this.userSettingRepository.save(setting);
+  }
+}

+ 50 - 38
server/src/user/user.controller.ts

@@ -11,11 +11,15 @@ import {
   Put,
   Request,
   UseGuards,
+  Query,
 } from '@nestjs/common';
 import { UserService } from './user.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { CreateUserDto } from './dto/create-user.dto';
 import { UpdateUserDto } from './dto/update-user.dto';
 import { I18nService } from '../i18n/i18n.service';
+import { UserRole } from './user-role.enum';
+import { UserSettingService } from './user-setting.service';
 
 @Controller('users')
 @UseGuards(CombinedAuthGuard)
@@ -23,6 +27,7 @@ export class UserController {
   constructor(
     private readonly userService: UserService,
     private readonly i18nService: I18nService,
+    private readonly userSettingService: UserSettingService,
   ) { }
 
   // --- API Key Management ---
@@ -38,6 +43,18 @@ export class UserController {
     return { apiKey };
   }
 
+  // --- Personal Settings ---
+  @Get('settings')
+  async getSettings(@Request() req) {
+    return this.userSettingService.getByUser(req.user.id);
+  }
+
+  @Put('settings/language')
+  async updateLanguage(@Request() req, @Body() body: { language: string }) {
+    if (!body.language) throw new BadRequestException('language is required');
+    return this.userSettingService.update(req.user.id, body.language);
+  }
+
   // --- Profile ---
   @Get('profile')
   async getProfile(@Request() req: any) {
@@ -65,7 +82,8 @@ export class UserController {
     return {
       id: user.id,
       username: user.username,
-      role: user.role,
+      displayName: user.displayName,
+      role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
       tenantId: user.tenantId,
       tenantName,
       isAdmin: user.isAdmin,
@@ -74,16 +92,23 @@ export class UserController {
   }
 
   @Get()
-  async findAll(@Request() req) {
+  async findAll(
+    @Request() req,
+    @Query('page') page?: string,
+    @Query('limit') limit?: string,
+  ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyViewList'));
     }
 
-    if (callerRole === 'SUPER_ADMIN') {
-      return this.userService.findAll();
+    const p = page ? parseInt(page) : undefined;
+    const l = limit ? parseInt(limit) : undefined;
+
+    if (callerRole === UserRole.SUPER_ADMIN) {
+      return this.userService.findAll(p, l);
     } else {
-      return this.userService.findByTenantId(req.user.tenantId);
+      return this.userService.findByTenantId(req.user.tenantId, p, l);
     }
   }
 
@@ -112,10 +137,10 @@ export class UserController {
   @Post()
   async createUser(
     @Request() req,
-    @Body() body: { username: string; password: string; role?: string },
+    @Body() body: CreateUserDto,
   ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyCreateUser'));
     }
 
@@ -129,22 +154,18 @@ export class UserController {
       throw new BadRequestException(this.i18nService.getErrorMessage('passwordMinLength'));
     }
 
-    // Determine target role based on caller's role and requested role
-    let targetRole = 'USER';
+    // All new global users default to non-admin. 
+    // Elevation to Super Admin status is handled separately.
     let isAdmin = false;
 
-    if (callerRole === 'SUPER_ADMIN') {
-      // Super Admin can create TENANT_ADMIN or USER. Default to requested role, fallback to USER.
-      targetRole = body.role === 'TENANT_ADMIN' ? 'TENANT_ADMIN' : 'USER';
-      isAdmin = targetRole === 'TENANT_ADMIN';
-    } else if (callerRole === 'TENANT_ADMIN') {
-      // Tenant Admin can ONLY create regular users.
-      targetRole = 'USER';
+    if (callerRole === UserRole.SUPER_ADMIN) {
+      isAdmin = false;
+    } else if (callerRole === UserRole.TENANT_ADMIN) {
       isAdmin = false;
     }
 
     // Pass the calculated params to the service
-    return this.userService.createUser(username, password, isAdmin, req.user.tenantId, targetRole as any);
+    return this.userService.createUser(username, password, isAdmin, req.user.tenantId, body.displayName);
   }
 
   @Put(':id')
@@ -154,11 +175,11 @@ export class UserController {
     @Param('id') id: string,
   ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyUpdateUser'));
     }
 
-    
+    // Get user info to update
     const userToUpdate = await this.userService.findOneById(id);
     if (!userToUpdate) {
       throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
@@ -173,20 +194,11 @@ export class UserController {
       throw new ForbiddenException(this.i18nService.getErrorMessage('cannotModifyBuiltinAdmin'));
     }
 
-    // Role modification logic
-    if (body.role && userToUpdate.role !== body.role) {
-      if (callerRole !== 'SUPER_ADMIN') {
-        throw new ForbiddenException('Only Super Admins can change user roles.');
-      }
-      if (userToUpdate.role === 'SUPER_ADMIN') {
-        throw new ForbiddenException('Cannot modify the role of another Super Admin.');
-      }
-
-      // Sync isAdmin based on the newly selected role
-      if (body.role === 'TENANT_ADMIN' || body.role === 'SUPER_ADMIN') {
-        body.isAdmin = true;
-      } else {
-        body.isAdmin = false;
+    // Role modification is now obsolete on global level. 
+    // If Admin wants to elevate, they set isAdmin property directly.
+    if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
+      if (callerRole !== UserRole.SUPER_ADMIN) {
+        throw new ForbiddenException('Only Super Admins can change user admin status.');
       }
     }
 
@@ -199,16 +211,16 @@ export class UserController {
     @Param('id') id: string,
   ) {
     const callerRole = req.user.role;
-    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
+    if (callerRole !== UserRole.SUPER_ADMIN && callerRole !== UserRole.TENANT_ADMIN) {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyDeleteUser'));
     }
 
-    
+    // Prevent admin from deleting themselves
     if (req.user.id === id) {
       throw new BadRequestException(this.i18nService.getErrorMessage('cannotDeleteSelf'));
     }
 
-    
+    // Get user info to delete
     const userToDelete = await this.userService.findOneById(id);
     if (!userToDelete) {
       throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
@@ -218,7 +230,7 @@ export class UserController {
       throw new ForbiddenException('Cannot delete users outside your tenant');
     }
 
-    
+    // Block deletion of built-in admin account
     if (userToDelete.username === 'admin') {
       throw new ForbiddenException(this.i18nService.getErrorMessage('cannotDeleteBuiltinAdmin'));
     }

+ 6 - 10
server/src/user/user.entity.ts

@@ -13,12 +13,12 @@ import {
 } from 'typeorm';
 import * as bcrypt from 'bcrypt';
 import { ModelConfig } from '../model-config/model-config.entity';
-import { UserSetting } from '../user-setting/user-setting.entity';
 import { Tenant } from '../tenant/tenant.entity';
 import { TenantMember } from '../tenant/tenant-member.entity';
 import { ApiKey } from '../auth/entities/api-key.entity';
 
 import { UserRole } from './user-role.enum';
+import { UserSetting } from './user-setting.entity';
 
 @Entity('users')
 export class User {
@@ -35,13 +35,9 @@ export class User {
   @Column({ type: 'boolean', default: false })
   isAdmin: boolean;
 
-  // New role-based access control
-  @Column({
-    type: 'simple-enum',
-    enum: UserRole,
-    default: UserRole.USER,
-  })
-  role: UserRole;
+  @Column({ type: 'text', nullable: false })
+  displayName: string;
+
 
   // Multi-tenancy: A user can belong to multiple tenants via TenantMember
   @OneToMany(() => TenantMember, (member) => member.user)
@@ -55,7 +51,7 @@ export class User {
   @OneToMany(() => ApiKey, (apiKey) => apiKey.user)
   apiKeys: ApiKey[];
 
-  
+  // Quota management field
   @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
   monthlyCost: number;
 
@@ -74,7 +70,7 @@ export class User {
   @OneToMany(() => ModelConfig, (modelConfig) => modelConfig.user)
   modelConfigs: ModelConfig[];
 
-  @OneToOne(() => UserSetting, (userSetting) => userSetting.user)
+  @OneToOne(() => UserSetting, (setting) => setting.user)
   userSetting: UserSetting;
 
   @BeforeInsert()

+ 5 - 3
server/src/user/user.module.ts

@@ -1,6 +1,8 @@
 import { Module, Global } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { User } from './user.entity';
+import { UserSetting } from './user-setting.entity';
+import { UserSettingService } from './user-setting.service';
 import { TenantMember } from '../tenant/tenant-member.entity';
 import { ApiKey } from '../auth/entities/api-key.entity';
 import { UserService } from './user.service';
@@ -10,11 +12,11 @@ import { TenantModule } from '../tenant/tenant.module';
 @Global()
 @Module({
   imports: [
-    TypeOrmModule.forFeature([User, ApiKey, TenantMember]),
+    TypeOrmModule.forFeature([User, ApiKey, TenantMember, UserSetting]),
     TenantModule,
   ],
   controllers: [UserController],
-  providers: [UserService],
-  exports: [UserService],
+  providers: [UserService, UserSettingService],
+  exports: [UserService, UserSettingService],
 })
 export class UserModule { }

+ 52 - 38
server/src/user/user.service.ts

@@ -41,12 +41,41 @@ export class UserService implements OnModuleInit {
 
 
 
-  async findAll(): Promise<User[]> {
-    return this.usersRepository.find({
-      select: ['id', 'username', 'isAdmin', 'role', 'createdAt', 'tenantId'],
-      relations: ['tenantMembers', 'tenantMembers.tenant'],
-      order: { createdAt: 'DESC' },
-    });
+  async findAll(page?: number, limit?: number): Promise<{ data: User[]; total: number }> {
+    const queryBuilder = this.usersRepository.createQueryBuilder('user')
+      .leftJoinAndSelect('user.tenantMembers', 'tenantMember')
+      .leftJoinAndSelect('tenantMember.tenant', 'tenant')
+      .select(['user.id', 'user.username', 'user.displayName', 'user.isAdmin', 'user.createdAt', 'user.tenantId', 'tenantMember', 'tenant'])
+      .orderBy('user.createdAt', 'DESC');
+
+    if (page && limit) {
+      const [data, total] = await queryBuilder
+        .skip((page - 1) * limit)
+        .take(limit)
+        .getManyAndCount();
+      return { data, total };
+    }
+
+    const [data, total] = await queryBuilder.getManyAndCount();
+    return { data, total };
+  }
+
+  async findByTenantId(tenantId: string, page?: number, limit?: number): Promise<{ data: User[]; total: number }> {
+    const queryBuilder = this.usersRepository.createQueryBuilder('user')
+      .innerJoin('user.tenantMembers', 'member', 'member.tenantId = :tenantId', { tenantId })
+      .select(['user.id', 'user.username', 'user.displayName', 'user.isAdmin', 'user.createdAt', 'user.tenantId'])
+      .orderBy('user.createdAt', 'DESC');
+
+    if (page && limit) {
+      const [data, total] = await queryBuilder
+        .skip((page - 1) * limit)
+        .take(limit)
+        .getManyAndCount();
+      return { data, total };
+    }
+
+    const [data, total] = await queryBuilder.getManyAndCount();
+    return { data, total };
   }
 
   async isAdmin(userId: string): Promise<boolean> {
@@ -86,27 +115,26 @@ export class UserService implements OnModuleInit {
     password: string,
     isAdmin: boolean = false,
     tenantId?: string,
-    role?: UserRole,
-  ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
+    displayName?: string,
+  ): Promise<{ message: string; user: { id: string; username: string; displayName: string; isAdmin: boolean } }> {
     const existingUser = await this.findOneByUsername(username);
     if (existingUser) {
       throw new ConflictException(this.i18nService.getMessage('usernameExists'));
     }
 
     const hashedPassword = await bcrypt.hash(password, 10);
-    const targetRoleValue = role ?? (isAdmin ? UserRole.TENANT_ADMIN : UserRole.USER);
-    console.log(`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}, role: ${targetRoleValue}`);
+    console.log(`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}`);
     const user = await this.usersRepository.save({
       username,
       password: hashedPassword,
+      displayName,
       isAdmin,
       tenantId: tenantId ?? undefined,
-      role: targetRoleValue,
     } as any);
 
     return {
       message: this.i18nService.getMessage('userCreated'),
-      user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
+      user: { id: user.id, username: user.username, displayName: user.displayName, isAdmin: user.isAdmin },
     };
   }
 
@@ -125,19 +153,12 @@ export class UserService implements OnModuleInit {
     return apiKey ? apiKey.user : null;
   }
 
-  async findByTenantId(tenantId: string): Promise<User[]> {
-    const members = await this.tenantMemberRepository.find({
-      where: { tenantId },
-      relations: ['user']
-    });
-    return members.map(m => m.user);
-  }
-
   async getUserTenants(userId: string): Promise<(TenantMember & { features?: { isNotebookEnabled: boolean } })[]> {
-    const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['role'] });
+    const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['isAdmin'] });
 
-    if (user?.role === UserRole.SUPER_ADMIN) {
-      const allTenants = await this.tenantService.findAll();
+    if (user?.isAdmin) {
+      const tenantsData = await this.tenantService.findAll();
+      const allTenants = Array.isArray(tenantsData) ? tenantsData : tenantsData.data;
       const results = await Promise.all(allTenants.map(async t => {
         const settings = await this.tenantService.getSettings(t.id);
         return {
@@ -222,38 +243,30 @@ export class UserService implements OnModuleInit {
 
   async updateUser(
     userId: string,
-    updateData: { isAdmin?: boolean; role?: string; password?: string; tenantId?: string },
-  ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
+    updateData: { username?: string; isAdmin?: boolean; password?: string; tenantId?: string; displayName?: string },
+  ): Promise<{ message: string; user: { id: string; username: string; displayName: string; isAdmin: boolean } }> {
     const user = await this.usersRepository.findOne({ where: { id: userId } });
     if (!user) {
       throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
     }
 
-    
+    // Hash password first if update needed
     if (updateData.password) {
       const hashedPassword = await bcrypt.hash(updateData.password, 10);
       updateData.password = hashedPassword;
     }
 
-    
+    // Block any changes to user "admin"
     if (user.username === 'admin') {
       throw new ForbiddenException(this.i18nService.getMessage('cannotModifyBuiltinAdmin'));
     }
 
-    // Apply role update logic
-    if (updateData.role) {
-      if (updateData.role === UserRole.TENANT_ADMIN || updateData.role === UserRole.SUPER_ADMIN) {
-        updateData.isAdmin = true;
-      } else {
-        updateData.isAdmin = false;
-      }
-    }
 
     await this.usersRepository.update(userId, updateData as any);
 
     const updatedUser = await this.usersRepository.findOne({
       where: { id: userId },
-      select: ['id', 'username', 'isAdmin'],
+      select: ['id', 'username', 'displayName', 'isAdmin'],
     });
 
     return {
@@ -261,6 +274,7 @@ export class UserService implements OnModuleInit {
       user: {
         id: updatedUser!.id,
         username: updatedUser!.username,
+        displayName: updatedUser!.displayName,
         isAdmin: updatedUser!.isAdmin
       },
     };
@@ -272,7 +286,7 @@ export class UserService implements OnModuleInit {
       throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
     }
 
-    
+    // Block deletion of user "admin"
     if (user.username === 'admin') {
       throw new ForbiddenException(this.i18nService.getMessage('cannotDeleteBuiltinAdmin'));
     }
@@ -301,7 +315,7 @@ export class UserService implements OnModuleInit {
         role: UserRole.SUPER_ADMIN,
       });
 
-      console.log('\n=== Administrator Account Created ===');
+      console.log('\n=== Admin account created ===');
       console.log('Username: admin');
       console.log('Password:', randomPassword);
       console.log('========================================\n');

+ 58 - 35
server/src/vision-pipeline/cost-control.service.ts

@@ -1,4 +1,7 @@
-
+/**
+ * Cost control and quota management service
+ * Used to manage API call costs for Vision Pipeline
+ */
 
 import { Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
@@ -8,16 +11,16 @@ import { User } from '../user/user.entity';
 
 export interface UserQuota {
   userId: string;
-  monthlyCost: number;      
-  maxCost: number;          
-  remaining: number;        
-  lastReset: Date;          
+  monthlyCost: number;      // Current month used cost
+  maxCost: number;          // Monthly max cost
+  remaining: number;        // Remaining cost
+  lastReset: Date;          // Last reset time
 }
 
 export interface CostEstimate {
-  estimatedCost: number;    
-  estimatedTime: number;    
-  pageBreakdown: {          
+  estimatedCost: number;    // Estimated cost
+  estimatedTime: number;    // Estimated time(seconds)
+  pageBreakdown: {          // Per-page breakdown
     pageIndex: number;
     cost: number;
   }[];
@@ -26,8 +29,8 @@ export interface CostEstimate {
 @Injectable()
 export class CostControlService {
   private readonly logger = new Logger(CostControlService.name);
-  private readonly COST_PER_PAGE = 0.01; 
-  private readonly DEFAULT_MONTHLY_LIMIT = 100; 
+  private readonly COST_PER_PAGE = 0.01; // Cost per page(USD)
+  private readonly DEFAULT_MONTHLY_LIMIT = 100; // Default monthly limit(USD)
 
   constructor(
     private configService: ConfigService,
@@ -35,9 +38,11 @@ export class CostControlService {
     private userRepository: Repository<User>,
   ) { }
 
-  
+  /**
+   * Estimate processing cost
+   */
   estimateCost(pageCount: number, quality: 'low' | 'medium' | 'high' = 'medium'): CostEstimate {
-    
+    // Adjust cost coefficient based on quality
     const qualityMultiplier = {
       low: 0.5,
       medium: 1.0,
@@ -45,7 +50,7 @@ export class CostControlService {
     };
 
     const baseCost = pageCount * this.COST_PER_PAGE * qualityMultiplier[quality];
-    const estimatedTime = pageCount * 3; 
+    const estimatedTime = pageCount * 3; // // Approximately 3 seconds
 
     const pageBreakdown = Array.from({ length: pageCount }, (_, i) => ({
       pageIndex: i + 1,
@@ -59,7 +64,9 @@ export class CostControlService {
     };
   }
 
-  
+  /**
+   * Check user quota
+   */
   async checkQuota(userId: string, estimatedCost: number): Promise<{
     allowed: boolean;
     quota: UserQuota;
@@ -67,12 +74,12 @@ export class CostControlService {
   }> {
     const quota = await this.getUserQuota(userId);
 
-    
+    // Check monthly reset
     this.checkAndResetMonthlyQuota(quota);
 
     if (quota.remaining < estimatedCost) {
       this.logger.warn(
-        `Insufficient quota for user ${userId}: Remaining $${quota.remaining.toFixed(2)}, Required $${estimatedCost.toFixed(2)}`,
+        `User ${userId} quota insufficient: remaining $${quota.remaining.toFixed(2)}, required $${estimatedCost.toFixed(2)}`,
       );
       return {
         allowed: false,
@@ -87,7 +94,9 @@ export class CostControlService {
     };
   }
 
-  
+  /**
+   * Deduct from quota
+   */
   async deductQuota(userId: string, actualCost: number): Promise<void> {
     const quota = await this.getUserQuota(userId);
     quota.monthlyCost += actualCost;
@@ -98,11 +107,13 @@ export class CostControlService {
     });
 
     this.logger.log(
-      `Deducted $${actualCost.toFixed(2)} from user ${userId}'s quota. Remaining $${quota.remaining.toFixed(2)}`,
+      `Deducted $${actualCost.toFixed(2)} from user ${userId} quota. Remaining: $${quota.remaining.toFixed(2)}`,
     );
   }
 
-  
+  /**
+   * Get user quota
+   */
   async getUserQuota(userId: string): Promise<UserQuota> {
     const user = await this.userRepository.findOne({ where: { id: userId } });
 
@@ -110,7 +121,7 @@ export class CostControlService {
       throw new Error(`User ${userId} does not exist`);
     }
 
-    
+    // Use default if user has no quota info
     const monthlyCost = user.monthlyCost || 0;
     const maxCost = user.maxCost || this.DEFAULT_MONTHLY_LIMIT;
     const lastReset = user.lastQuotaReset || new Date();
@@ -124,24 +135,26 @@ export class CostControlService {
     };
   }
 
-  
+  /**
+   * Check and reset monthly quota
+   */
   private checkAndResetMonthlyQuota(quota: UserQuota): void {
     const now = new Date();
     const lastReset = quota.lastReset;
 
-    
+    // Check if crossed month
     if (
       now.getMonth() !== lastReset.getMonth() ||
       now.getFullYear() !== lastReset.getFullYear()
     ) {
       this.logger.log(`Reset monthly quota for user ${quota.userId}`);
 
-      
+      // Reset quota
       quota.monthlyCost = 0;
       quota.remaining = quota.maxCost;
       quota.lastReset = now;
 
-      
+      // Update database
       this.userRepository.update(quota.userId, {
         monthlyCost: 0,
         lastQuotaReset: now,
@@ -149,13 +162,17 @@ export class CostControlService {
     }
   }
 
-  
+  /**
+   * Set user quota limit
+   */
   async setQuotaLimit(userId: string, maxCost: number): Promise<void> {
     await this.userRepository.update(userId, { maxCost });
-    this.logger.log(`Set quota limit for user ${userId} to $${maxCost}`);
+    this.logger.log(`Set quota limit to $${maxCost} for user ${userId}`);
   }
 
-  
+  /**
+   * Get cost report
+   */
   async getCostReport(userId: string, days: number = 30): Promise<{
     totalCost: number;
     dailyAverage: number;
@@ -163,13 +180,13 @@ export class CostControlService {
       totalPages: number;
       avgCostPerPage: number;
     };
-    quotaUsage: number; 
+    quotaUsage: number; // Percentage
   }> {
     const quota = await this.getUserQuota(userId);
     const usagePercent = (quota.monthlyCost / quota.maxCost) * 100;
 
-    
-    
+    // Query history records here(if implemented)
+    // Return current quota info temporarily
 
     return {
       totalCost: quota.monthlyCost,
@@ -182,7 +199,9 @@ export class CostControlService {
     };
   }
 
-  
+  /**
+   * Check cost warning threshold
+   */
   async checkWarningThreshold(userId: string): Promise<{
     shouldWarn: boolean;
     message: string;
@@ -193,14 +212,14 @@ export class CostControlService {
     if (usagePercent >= 90) {
       return {
         shouldWarn: true,
-        message: `⚠️ Quota usage has reached ${usagePercent.toFixed(1)}%. Remaining $${quota.remaining.toFixed(2)}`,
+        message: `⚠️ Quota usage reached ${usagePercent.toFixed(1)}%. Remaining: $${quota.remaining.toFixed(2)}`,
       };
     }
 
     if (usagePercent >= 75) {
       return {
         shouldWarn: true,
-        message: `💡 Quota usage ${usagePercent.toFixed(1)}%. Be careful with controlling costs`,
+        message: `💡 Quota usage at ${usagePercent.toFixed(1)}%. Please monitor your costs carefully`,
       };
     }
 
@@ -210,12 +229,16 @@ export class CostControlService {
     };
   }
 
-  
+  /**
+   * Format cost display
+   */
   formatCost(cost: number): string {
     return `$${cost.toFixed(2)}`;
   }
 
-  
+  /**
+   * Format time display
+   */
   formatTime(seconds: number): string {
     if (seconds < 60) {
       return `${seconds.toFixed(0)}s`;

+ 57 - 40
server/src/vision-pipeline/vision-pipeline-cost-aware.service.ts

@@ -1,4 +1,7 @@
-
+/**
+ * Vision Pipeline Service (with cost control)
+ * This is an extended version of vision-pipeline.service.ts with integrated cost control
+ */
 
 import { Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
@@ -12,6 +15,7 @@ import { ModelConfigService } from '../model-config/model-config.service';
 import { PreciseModeOptions, PipelineResult, ProcessingStatus, ModeRecommendation } from './vision-pipeline.interface';
 import { VisionModelConfig, VisionAnalysisResult } from '../vision/vision.interface';
 import { CostControlService } from './cost-control.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class VisionPipelineCostAwareService {
@@ -24,10 +28,13 @@ export class VisionPipelineCostAwareService {
     private elasticsearch: ElasticsearchService,
     private modelConfigService: ModelConfigService,
     private configService: ConfigService,
-    private costControl: CostControlService, 
+    private costControl: CostControlService,
+    private i18nService: I18nService,
   ) { }
 
-  
+  /**
+   * Main processing flow: Precise mode (with cost control)
+   */
   async processPreciseMode(
     filePath: string,
     options: PreciseModeOptions,
@@ -45,12 +52,12 @@ export class VisionPipelineCostAwareService {
     );
 
     try {
-      
+      // Step 1: Convert format
       this.updateStatus('converting', 10, 'Converting document format...');
       pdfPath = await this.convertToPDF(filePath);
 
-      
-      this.updateStatus('splitting', 30, 'Converting PDF to image...');
+      // Step 2: Convert PDF to images
+      this.updateStatus('splitting', 30, 'Converting PDF to images...');
       const conversionResult = await this.pdf2Image.convertToImages(pdfPath, {
         density: 300,
         quality: 85,
@@ -58,24 +65,24 @@ export class VisionPipelineCostAwareService {
       });
 
       if (conversionResult.images.length === 0) {
-        throw new Error('PDF to image conversion failed. No image was generated');
+        throw new Error(this.i18nService.getMessage('pdfToImageConversionFailed'));
       }
 
-      
+      // Limit processing pages
       imagesToProcess = options.maxPages
         ? conversionResult.images.slice(0, options.maxPages)
         : conversionResult.images;
 
       const pageCount = imagesToProcess.length;
 
-      
-      this.updateStatus('checking', 40, 'Checking quotas and estimating costs...');
+      // Step 3: Cost estimation and quota check
+      this.updateStatus('checking', 40, 'Checking quota and estimating cost...');
       const costEstimate = this.costControl.estimateCost(pageCount);
       this.logger.log(
         `Estimated cost: $${costEstimate.estimatedCost.toFixed(2)}, Estimated time: ${this.costControl.formatTime(costEstimate.estimatedTime)}`
       );
 
-      
+      // Quota check
       const quotaCheck = await this.costControl.checkQuota(
         options.userId,
         costEstimate.estimatedCost,
@@ -85,17 +92,17 @@ export class VisionPipelineCostAwareService {
         throw new Error(quotaCheck.reason);
       }
 
-      
+      // Cost warning check
       const warning = await this.costControl.checkWarningThreshold(options.userId);
       if (warning.shouldWarn) {
         this.logger.warn(warning.message);
       }
 
-      
+      // Step 4: Get Vision model config
       const modelConfig = await this.getVisionModelConfig(options.userId, options.modelId, options.tenantId);
 
-      
-      this.updateStatus('analyzing', 50, 'Analyzing the page using the vision model...');
+      // Step 5: VL model analysis
+      this.updateStatus('analyzing', 50, 'Analyzing pages with Vision model...');
       const batchResult = await this.vision.batchAnalyze(
         imagesToProcess.map(img => img.path),
         modelConfig,
@@ -110,17 +117,17 @@ export class VisionPipelineCostAwareService {
       failedPages = batchResult.failedCount;
       results.push(...batchResult.results);
 
-      
+      // Step 6: Subtract actual cost
       if (totalCost > 0) {
         await this.costControl.deductQuota(options.userId, totalCost);
-        this.logger.log(`Actual deducted cost: $${totalCost.toFixed(2)}`);
+        this.logger.log(`Actual cost deducted: $${totalCost.toFixed(2)}`);
       }
 
-      
-      this.updateStatus('completed', 100, 'Processing completed. Cleaning up temporary files...');
+      // Step 7: Cleanup temp files
+      this.updateStatus('completed', 100, 'Processing completed. Cleaning up temp files...');
       await this.pdf2Image.cleanupImages(imagesToProcess);
 
-      
+      // Cleanup converted PDF file if converted
       if (pdfPath !== filePath) {
         try {
           await fs.unlink(pdfPath);
@@ -151,7 +158,7 @@ export class VisionPipelineCostAwareService {
     } catch (error) {
       this.logger.error(`Precise mode failed: ${error.message}`);
 
-      
+      // Try to clean up temp files
       try {
         if (pdfPath !== filePath && pdfPath !== filePath) {
           await fs.unlink(pdfPath);
@@ -176,15 +183,17 @@ export class VisionPipelineCostAwareService {
     }
   }
 
-  
+  /**
+   * Get Vision model configuration
+   */
   private async getVisionModelConfig(userId: string, modelId: string, tenantId?: string): Promise<VisionModelConfig> {
     const config = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
 
     if (!config) {
-      throw new Error(`Model configuration not found: ${modelId}`);
+      throw new Error(`Model config not found: ${modelId}`);
     }
 
-    // API key is optional - allow local models
+    // API key is optional - allows local models
 
     return {
       baseUrl: config.baseUrl || '',
@@ -193,20 +202,24 @@ export class VisionPipelineCostAwareService {
     };
   }
 
-  
+  /**
+   * Convert to PDF
+   */
   private async convertToPDF(filePath: string): Promise<string> {
     const ext = path.extname(filePath).toLowerCase();
 
-    
+    // Return as-is if already PDF
     if (ext === '.pdf') {
       return filePath;
     }
 
-    
+    // Call LibreOffice to convert
     return await this.libreOffice.convertToPDF(filePath);
   }
 
-  
+  /**
+   * Format detection and mode recommendation (with cost estimation)
+   */
   async recommendMode(filePath: string): Promise<ModeRecommendation> {
     const ext = path.extname(filePath).toLowerCase();
     const stats = await fs.stat(filePath);
@@ -219,44 +232,46 @@ export class VisionPipelineCostAwareService {
       return {
         recommendedMode: 'fast',
         reason: `Unsupported file format: ${ext}`,
-        warnings: ['Using Fast Mode (text extraction only)'],
+        warnings: ['Using fast mode (text extraction only)'],
       };
     }
 
     if (!preciseFormats.includes(ext)) {
       return {
         recommendedMode: 'fast',
-        reason: `Format ${ext} does not support Precise Mode`,
-        warnings: ['Using Fast Mode (text extraction only)'],
+        reason: `Format ${ext} does not support precise mode`,
+        warnings: ['Using fast mode (text extraction only)'],
       };
     }
 
-    
+    // Estimate page count(based on file size)
     const estimatedPages = Math.max(1, Math.ceil(sizeMB * 2));
     const costEstimate = this.costControl.estimateCost(estimatedPages);
 
-    
+    // Recommend precise mode for large files
     if (sizeMB > 50) {
       return {
         recommendedMode: 'precise',
-        reason: 'Due to large files, Precise Mode is recommended to retain complete information',
+        reason: 'File is large, recommend precise mode to preserve full content',
         estimatedCost: costEstimate.estimatedCost,
         estimatedTime: costEstimate.estimatedTime,
-        warnings: ['Processing time may be longer', 'API charges may apply'],
+        warnings: ['Processing time may be longer', 'API costs will be incurred'],
       };
     }
 
-    
+    // Recommend precise mode
     return {
       recommendedMode: 'precise',
-      reason: 'Precise Mode is available. Can hold mixed content of text and images',
+      reason: 'Precise mode available. Can preserve mixed text and image content',
       estimatedCost: costEstimate.estimatedCost,
       estimatedTime: costEstimate.estimatedTime,
-      warnings: ['API charges will apply'],
+      warnings: ['API costs will be incurred'],
     };
   }
 
-  
+  /**
+   * Get user quota information
+   */
   async getUserQuotaInfo(userId: string) {
     const quota = await this.costControl.getUserQuota(userId);
     const report = await this.costControl.getCostReport(userId);
@@ -268,7 +283,9 @@ export class VisionPipelineCostAwareService {
     };
   }
 
-  
+  /**
+   * Update processing status (for real-time feedback)
+   */
   private updateStatus(status: ProcessingStatus['status'], progress: number, message: string): void {
     this.logger.log(`[${status}] ${progress}% - ${message}`);
   }

+ 5 - 3
server/src/vision-pipeline/vision-pipeline.interface.ts

@@ -1,4 +1,6 @@
-
+/**
+ * Vision Pipeline Interface Definitions
+ */
 
 import { VisionAnalysisResult } from '../vision/vision.interface';
 
@@ -21,7 +23,7 @@ export interface PipelineResult {
   failedPages: number;
   results: VisionAnalysisResult[];
   cost: number;
-  duration: number; 
+  duration: number; // seconds
   mode: 'precise';
 }
 
@@ -42,6 +44,6 @@ export interface ModeRecommendation {
   recommendedMode: 'precise' | 'fast';
   reason: string;
   estimatedCost?: number;
-  estimatedTime?: number; 
+  estimatedTime?: number; // seconds
   warnings?: string[];
 }

+ 3 - 1
server/src/vision-pipeline/vision-pipeline.service.ts

@@ -16,6 +16,7 @@ import {
   VisionAnalysisResult,
   VisionModelConfig,
 } from '../vision/vision.interface';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class VisionPipelineService {
@@ -27,6 +28,7 @@ export class VisionPipelineService {
     private vision: VisionService,
     private modelConfigService: ModelConfigService,
     private configService: ConfigService,
+    private i18nService: I18nService,
   ) { }
 
   /**
@@ -66,7 +68,7 @@ export class VisionPipelineService {
       });
 
       if (conversionResult.images.length === 0) {
-        throw new Error('Failed to convert PDF to images. No images were generated.');
+        throw new Error(this.i18nService.getMessage('pdfToImageConversionFailed'));
       }
 
       this.logger.log(

+ 12 - 10
server/src/vision/vision.interface.ts

@@ -1,17 +1,19 @@
-
+/**
+ * Vision Service Interface Definitions
+ */
 
 export interface VisionAnalysisResult {
-  text: string;              
-  images: ImageDescription[]; 
-  layout: string;            
-  confidence: number;        
-  pageIndex?: number;        
+  text: string;              // Extracted text content
+  images: ImageDescription[]; // Image description
+  layout: string;            // Layout type
+  confidence: number;        // Confidence (0-1)
+  pageIndex?: number;        // Page number
 }
 
 export interface ImageDescription {
-  type: string;              
-  description: string;       
-  position?: number;         
+  type: string;              // Image type (chart/diagram/flowchart etc.)
+  description: string;       // Detailed description
+  position?: number;         // Position in page
 }
 
 export interface VisionModelConfig {
@@ -25,7 +27,7 @@ export interface BatchAnalysisResult {
   totalPages: number;
   successCount: number;
   failedCount: number;
-  estimatedCost: number;     
+  estimatedCost: number;     // Estimated cost(USD)
 }
 
 export interface PageQuality {

+ 61 - 43
server/src/vision/vision.service.ts

@@ -15,14 +15,16 @@ export class VisionService {
     private i18nService: I18nService,
   ) { }
 
-  
+  /**
+   * Analyze single image (document page)
+   */
   async analyzeImage(
     imagePath: string,
     modelConfig: VisionModelConfig,
     pageIndex?: number,
   ): Promise<VisionAnalysisResult> {
     const maxRetries = 3;
-    const baseDelay = 3000; 
+    const baseDelay = 3000; // 3 second base delay
 
     for (let attempt = 1; attempt <= maxRetries; attempt++) {
       try {
@@ -34,7 +36,7 @@ export class VisionService {
           throw new Error(this.i18nService.formatMessage('visionAnalysisFailed', { message: error.message }));
         }
 
-        const delay = baseDelay + Math.random() * 2000; 
+        const delay = baseDelay + Math.random() * 2000; // 3-5 second random delay
         this.logger.warn(
           `⚠️ Failed to analyze page ${pageIndex || '?'} (${attempt}/${maxRetries}), retrying in ${delay.toFixed(0)}ms: ${error.message}`
         );
@@ -43,33 +45,35 @@ export class VisionService {
       }
     }
 
-    
+    // This line theoretically should not execute, but included to satisfy TypeScript
     throw new Error(this.i18nService.getMessage('retryMechanismError'));
   }
 
-  
+  /**
+   * Perform actual image analysis
+   */
   private async performAnalysis(
     imagePath: string,
     modelConfig: VisionModelConfig,
     pageIndex?: number,
   ): Promise<VisionAnalysisResult> {
     try {
-      
+      // Load image and convert to base64
       const imageBuffer = await fs.readFile(imagePath);
       const base64Image = imageBuffer.toString('base64');
       const mimeType = this.getMimeType(imagePath);
 
-      
+      // Create vision model instance
       const model = new ChatOpenAI({
         apiKey: modelConfig.apiKey,
         model: modelConfig.modelId,
         configuration: {
           baseURL: modelConfig.baseUrl,
         },
-        temperature: 0.1, 
+        temperature: 0.1, // Reduce randomness, increase consistency
       });
 
-      
+      // Build professional document analysis prompt
       const systemPrompt = this.i18nService.getMessage('visionSystemPrompt');
 
       const message = new HumanMessage({
@@ -87,15 +91,15 @@ export class VisionService {
         ],
       });
 
-      
+      // Call model
       this.logger.log(this.i18nService.formatMessage('visionModelCall', { model: modelConfig.modelId, page: pageIndex || 'single' }));
       const response = await model.invoke([message]);
       let content = response.content as string;
 
-      
+      // Try to parse JSON
       let result: VisionAnalysisResult;
       try {
-        
+        // Clean up markdown code block tags
         content = content.replace(/```json/g, '').replace(/```/g, '').trim();
         const parsed = JSON.parse(content);
 
@@ -107,7 +111,7 @@ export class VisionService {
           pageIndex,
         };
       } catch (parseError) {
-        
+        // If parsing fails, treat entire content as text
         this.logger.warn(`Failed to parse JSON response for ${imagePath}, using raw text`);
         result = {
           text: content,
@@ -121,7 +125,7 @@ export class VisionService {
       this.logger.log(
         this.i18nService.formatMessage('visionAnalysisSuccess', {
           path: imagePath,
-          page: pageIndex ? ` (th page ${pageIndex})` : '',
+          page: pageIndex ? ` (page ${pageIndex})` : '',
           textLen: result.text.length,
           imgCount: result.images.length,
           layout: result.layout,
@@ -131,26 +135,28 @@ export class VisionService {
 
       return result;
     } catch (error) {
-      throw error; 
+      throw error; // Re-throw error for retry mechanism
     }
   }
 
-  
+  /**
+   * Determine if error is retryable
+   */
   private isRetryableError(error: any): boolean {
     const errorMessage = error.message?.toLowerCase() || '';
     const errorCode = error.status || error.code;
 
-    
-    if (errorCode === 429 || errorMessage.includes('rate limit') || errorMessage.includes('Too many requests')) {
+    // 429 rate limit error
+    if (errorCode === 429 || errorMessage.includes('rate limit') || errorMessage.includes('too many requests')) {
       return true;
     }
 
-    // 5xx Server error
+    // 5xx server error
     if (errorCode >= 500 && errorCode < 600) {
       return true;
     }
 
-    
+    // Network related error
     if (errorMessage.includes('timeout') || errorMessage.includes('network') || errorMessage.includes('connection')) {
       return true;
     }
@@ -158,12 +164,16 @@ export class VisionService {
     return false;
   }
 
-  
+  /**
+   * Sleep function
+   */
   private sleep(ms: number): Promise<void> {
     return new Promise(resolve => setTimeout(resolve, ms));
   }
 
-  
+  /**
+   * Batch analyze multiple images
+   */
   async batchAnalyze(
     imagePaths: string[],
     modelConfig: VisionModelConfig,
@@ -179,7 +189,7 @@ export class VisionService {
     let failedCount = 0;
 
     this.logger.log(this.i18nService.formatMessage('batchAnalysisStarted', { count: imagePaths.length }));
-    this.logger.log(`🔧 Model configuration: ${modelConfig.modelId} (${modelConfig.baseUrl || 'OpenAI'})`);
+    this.logger.log(`🔧 Model config: ${modelConfig.modelId} (${modelConfig.baseUrl || 'OpenAI'})`);
 
     for (let i = 0; i < imagePaths.length; i++) {
       const imagePath = imagePaths[i];
@@ -188,20 +198,20 @@ export class VisionService {
 
       this.logger.log(`🖼️  Analyzing page ${pageIndex} (${i + 1}/${imagePaths.length}, ${progress}%)`);
 
-      
+      // Call progress callback
       if (onProgress) {
         onProgress(i + 1, imagePaths.length);
       }
 
-      
+      // Quality check(skip analysis if skipped)
       if (!skipQualityCheck) {
         const quality = await this.checkImageQuality(imagePath);
         if (!quality.isGood) {
-          this.logger.warn(`⚠️  Skipping page ${pageIndex} (Poor quality): ${quality.reason}`);
+          this.logger.warn(`⚠️  Skipped page ${pageIndex} (poor quality): ${quality.reason}`);
           failedCount++;
           continue;
         } else {
-          this.logger.log(`✅ Page ${pageIndex} passed quality check (Score: ${(quality.score || 0).toFixed(2)})`);
+          this.logger.log(`✅ Page ${pageIndex} quality check passed (score: ${(quality.score || 0).toFixed(2)})`);
         }
       }
 
@@ -215,13 +225,13 @@ export class VisionService {
         successCount++;
 
         this.logger.log(
-          `✅ Analysis for page ${pageIndex} complete (Time taken: ${duration}s, ` +
-          `Text: ${result.text.length} chars, ` +
-          `Images: ${result.images.length}, ` +
-          `Confidence: ${(result.confidence * 100).toFixed(1)}%)`
+          `✅ Page ${pageIndex} analysis completed (time: ${duration}s, ` +
+          `text: ${result.text.length} chars, ` +
+          `images: ${result.images.length}, ` +
+          `confidence: ${(result.confidence * 100).toFixed(1)}%)`
         );
 
-        
+        // Call progress callback with result
         if (onProgress) {
           onProgress(i + 1, imagePaths.length, result);
         }
@@ -231,11 +241,11 @@ export class VisionService {
       }
     }
 
-    
+    // Calculate estimated cost (assuming $0.01 per image)
     const estimatedCost = successCount * 0.01;
 
     this.logger.log(
-      `🎉 Vision batch analysis complete! ` +
+      `🎉 Vision batch analysis completed! ` +
       `✅ Success: ${successCount} pages, ❌ Failed: ${failedCount} pages, ` +
       `💰 Estimated cost: $${estimatedCost.toFixed(2)}`
     );
@@ -249,23 +259,25 @@ export class VisionService {
     };
   }
 
-  
+  /**
+   * Check image quality
+   */
   async checkImageQuality(imagePath: string): Promise<{ isGood: boolean; reason?: string; score?: number }> {
     try {
       const stats = await fs.stat(imagePath);
       const sizeKB = stats.size / 1024;
 
-      
+      // Check file size(5KB+)
       if (sizeKB < 5) {
-        return { isGood: false, reason: `File is too small (${sizeKB.toFixed(2)}KB)`, score: 0 };
+        return { isGood: false, reason: `File too small (${sizeKB.toFixed(2)}KB)`, score: 0 };
       }
 
-      
+      // Check file size limit(10MB)
       if (sizeKB > 10240) {
-        return { isGood: false, reason: `File is too large (${sizeKB.toFixed(2)}KB)`, score: 0 };
+        return { isGood: false, reason: `File too large (${sizeKB.toFixed(2)}KB)`, score: 0 };
       }
 
-      
+      // Simple quality scoring
       let score = 0.5;
       if (sizeKB > 50) score += 0.2;
       if (sizeKB > 100) score += 0.2;
@@ -279,7 +291,9 @@ export class VisionService {
     }
   }
 
-  
+  /**
+   * Check if file is a supported image format
+   */
   isImageFile(mimetype: string): boolean {
     const imageMimeTypes = [
       'image/jpeg',
@@ -292,7 +306,9 @@ export class VisionService {
     return imageMimeTypes.includes(mimetype);
   }
 
-  
+  /**
+   * Get MIME type
+   */
   private getMimeType(filePath: string): string {
     const ext = filePath.toLowerCase().split('.').pop();
     if (!ext) return 'image/jpeg';
@@ -308,7 +324,9 @@ export class VisionService {
     return mimeTypes[ext] || 'image/jpeg';
   }
 
-  
+  /**
+   * Legacy interface compatibility: extract content from single image
+   */
   async extractImageContent(
     imagePath: string,
     modelConfig: { baseUrl: string; apiKey: string; modelId: string },

Some files were not shown because too many files changed in this diff