Ver Fonte

new version

shang-chunyu há 3 semanas atrás
pai
commit
8164995420
100 ficheiros alterados com 1710 adições e 2933 exclusões
  1. 14 31
      CLAUDE.md
  2. 0 0
      FEATURE_SUMMARY.md
  3. 4 4
      INTERNAL_DEPLOYMENT_GUIDE.md
  4. 2 2
      INTERNAL_DEPLOYMENT_SUMMARY.md
  5. 4 4
      QUICK_START.md
  6. 3 3
      README.md
  7. 39 14
      docker-compose.yml
  8. 0 1
      docs/API.md
  9. 2 2
      docs/DEPLOYMENT.md
  10. 1 1
      docs/DESIGN.md
  11. 0 119
      docs/FEAT_ANALYSIS.md
  12. 2 2
      docs/PROJECT_EXPLANATION_JA.md
  13. 47 0
      docs/README.md
  14. 0 68
      docs/design/feat-auto-title-generation.md
  15. 0 59
      docs/design/feat-cross-doc-comparison.md
  16. 0 37
      docs/design/feat-highlight-jump.md
  17. 0 52
      docs/design/feat-query-expansion-hyde.md
  18. 0 41
      docs/testing/feat-auto-title-generation.md
  19. 0 43
      docs/testing/feat-query-expansion-hyde.md
  20. 1 1
      libreoffice-server/package.json
  21. 1 1
      package.json
  22. 2 2
      server/package.json
  23. 1 1
      server/src/ai/embedding.service.ts
  24. 6 8
      server/src/api/api.controller.ts
  25. 2 4
      server/src/api/api.service.ts
  26. 10 3
      server/src/app.module.ts
  27. 6 2
      server/src/auth/local.strategy.ts
  28. 17 50
      server/src/chat/chat.controller.ts
  29. 1 1
      server/src/chat/chat.module.ts
  30. 32 105
      server/src/chat/chat.service.ts
  31. 0 739
      server/src/chat/chat.service.updated.ts
  32. 1 0
      server/src/common/constants.ts
  33. 13 0
      server/src/common/file-support.constants.ts
  34. 6 23
      server/src/defaults.ts
  35. 22 21
      server/src/elasticsearch/elasticsearch.service.ts
  36. 27 0
      server/src/i18n/i18n.interceptor.ts
  37. 14 0
      server/src/i18n/i18n.middleware.ts
  38. 33 23
      server/src/i18n/i18n.service.ts
  39. 7 0
      server/src/i18n/i18n.store.ts
  40. 268 0
      server/src/i18n/messages.ts
  41. 14 4
      server/src/knowledge-base/chunk-config.service.ts
  42. 1 3
      server/src/knowledge-base/embedding.service.ts
  43. 20 17
      server/src/knowledge-base/knowledge-base.controller.ts
  44. 0 3
      server/src/knowledge-base/knowledge-base.entity.ts
  45. 1 3
      server/src/knowledge-base/knowledge-base.module.ts
  46. 71 114
      server/src/knowledge-base/knowledge-base.service.ts
  47. 0 6
      server/src/knowledge-group/knowledge-group.entity.ts
  48. 9 9
      server/src/knowledge-group/knowledge-group.service.ts
  49. 20 0
      server/src/migrations/1739260000000-RemoveSupportsVisionColumn.ts
  50. 1 6
      server/src/model-config/dto/create-model-config.dto.ts
  51. 0 1
      server/src/model-config/dto/model-config-response.dto.ts
  52. 0 3
      server/src/model-config/model-config.entity.ts
  53. 4 4
      server/src/note/note.controller.ts
  54. 43 16
      server/src/note/note.service.ts
  55. 1 3
      server/src/rag/rag.module.ts
  56. 106 202
      server/src/rag/rag.service.ts
  57. 0 162
      server/src/scripts/import-course.ts
  58. 4 8
      server/src/search-history/search-history.service.ts
  59. 11 4
      server/src/types.ts
  60. 8 88
      server/src/upload/upload.controller.ts
  61. 16 9
      server/src/user-setting/dto/create-user-setting.dto.ts
  62. 22 3
      server/src/user-setting/user-setting.controller.ts
  63. 12 12
      server/src/user-setting/user-setting.entity.ts
  64. 73 2
      server/src/user-setting/user-setting.service.ts
  65. 1 0
      server/src/user/dto/user-safe.dto.ts
  66. 30 19
      server/src/user/user.controller.ts
  67. 22 16
      server/src/user/user.service.ts
  68. 1 3
      server/src/vision-pipeline/vision-pipeline-cost-aware.service.ts
  69. 1 3
      server/src/vision-pipeline/vision-pipeline.service.ts
  70. 28 37
      server/src/vision/vision.service.ts
  71. 0 25
      server/test/app.e2e-spec.ts
  72. 0 9
      server/test/jest-e2e.json
  73. 0 0
      test_admin_features.md
  74. 6 1
      web/.env
  75. 6 1
      web/.env.example
  76. 5 1
      web/App.tsx
  77. 1 1
      web/components/AICommandDrawer.tsx
  78. 7 5
      web/components/ChatInterface.tsx
  79. 5 12
      web/components/ChatMessage.tsx
  80. 2 2
      web/components/ChunkInfoDrawer.tsx
  81. 62 58
      web/components/ConfigPanel.tsx
  82. 67 0
      web/components/ConfirmDialog.tsx
  83. 1 1
      web/components/CreateNoteFromPDFDialog.tsx
  84. 9 20
      web/components/CreateNotebookDialog.tsx
  85. 4 17
      web/components/CreateNotebookDrawer.tsx
  86. 9 11
      web/components/DragDropUpload.tsx
  87. 12 23
      web/components/EditNotebookDialog.tsx
  88. 4 16
      web/components/EditNotebookDrawer.tsx
  89. 10 8
      web/components/FileGroupTags.tsx
  90. 25 25
      web/components/GlobalDragDropOverlay.tsx
  91. 23 20
      web/components/GroupManager.tsx
  92. 3 1
      web/components/HistoryDrawer.tsx
  93. 111 94
      web/components/ImportFolderDrawer.tsx
  94. 22 6
      web/components/IndexingModalWithMode.tsx
  95. 18 18
      web/components/ModeSelector.tsx
  96. 13 14
      web/components/NotebookDragDropUpload.tsx
  97. 26 25
      web/components/NotebookGlobalDragDropOverlay.tsx
  98. 117 272
      web/components/PDFPreview.tsx
  99. 3 1
      web/components/PDFSelectionTool.tsx
  100. 31 19
      web/components/SearchHistoryList.tsx

+ 14 - 31
CLAUDE.md

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 ## Project Overview
 
-Lumina 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.
+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)
@@ -65,7 +65,7 @@ cd server && yarn format
 
 ### Project Structure
 ```
-lumina/
+simple-kb/
 ├── web/                    # React frontend (Vite)
 │   ├── components/         # UI components (ChatInterface, ConfigPanel, etc.)
 │   ├── contexts/          # React Context providers
@@ -110,27 +110,14 @@ lumina/
 - Source citation and similarity scoring
 - Chunk configuration (size, overlap)
 
-## Development Process (Documentation-Driven)
-
-Every feature development MUST follow this sequence:
-
-1. **Design Phase**: Create a design document in `docs/design/feat-[feature-name].md` before writing any code. Outline the requirements, architecture, and UI/UX changes.
-2. **Implementation Phase**: Develop the feature following the design and code standards.
-3. **Verification Phase**: Create a test case document in `docs/testing/feat-[feature-name].md` documenting test results and edge cases.
-
-For complex features, refer to `docs/FEAT_ANALYSIS.md` for long-term roadmap and architectural guidance.
-
 ## Code Standards
 
-### Naming Conventions
-- **Project Name**: Lumina
-- **Monorepo Packages**: Use `lumina-` prefix for all sub-packages (e.g., `lumina-web`, `lumina-server`).
-
 ### Language Requirements
-- **Code comments must be in Japanese**
+- **Code comments must be in Japanese** (updated from Chinese as per user requirement)
 - **Log messages must be in Japanese**
-- **User Interface strings must follow i18n standards** (JA, ZH, EN).
-- **API error/response messages must support i18n** via translation keys.
+- **Error messages must support internationalization** to enable multi-language frontend interface
+- **API response messages must support internationalization** to enable multi-language frontend interface
+- Interface supports Japanese, Chinese, and English
 
 ### Testing
 - Backend uses Jest for unit and e2e tests
@@ -145,20 +132,16 @@ For complex features, refer to `docs/FEAT_ANALYSIS.md` for long-term roadmap and
 ## Common Development Tasks
 
 ### Adding a New API Endpoint
-1. Create design doc in `docs/design/`
-2. Create controller in appropriate module under `server/src/`
-3. Add service methods with Japanese comments
-4. Update DTOs and validation
-5. Add tests in `*.spec.ts` files
-6. Create test case doc in `docs/testing/`
+1. Create controller in appropriate module under `server/src/`
+2. Add service methods with Japanese comments
+3. Update DTOs and validation
+4. Add tests in `*.spec.ts` files
 
 ### Adding a New Frontend Component
-1. Create design doc in `docs/design/`
-2. Create component in `web/components/`
-3. Add TypeScript interfaces in `web/types.ts`
-4. Use Tailwind CSS for styling
-5. Connect to backend services in `web/services/`
-6. Create test case doc in `docs/testing/`
+1. Create component in `web/components/`
+2. Add TypeScript interfaces in `web/types.ts`
+3. Use Tailwind CSS for styling
+4. Connect to backend services in `web/services/`
 
 ### Debugging
 - Backend logs are in Chinese

+ 0 - 0
docs/FEATURE_SUMMARY.md → FEATURE_SUMMARY.md


+ 4 - 4
docs/INTERNAL_DEPLOYMENT_GUIDE.md → INTERNAL_DEPLOYMENT_GUIDE.md

@@ -1,8 +1,8 @@
-# 内网部署指南 - Lumina 知识库系统
+# 内网部署指南 - Simple-KB 知识库系统
 
 ## 概述
 
-本文档介绍如何在内部网络环境中部署Lumina知识库系统,确保所有外部依赖都被移除或替换为内部资源。
+本文档介绍如何在内部网络环境中部署Simple-KB知识库系统,确保所有外部依赖都被移除或替换为内部资源。
 
 ## 主要修改内容
 
@@ -24,14 +24,14 @@
 
 ### 步骤1: 部署内部AI模型服务
 
-在启动Lumina之前,请确保已部署内部AI模型服务,如:
+在启动Simple-KB之前,请确保已部署内部AI模型服务,如:
 - 自托管的OpenAI兼容接口 (如 vLLM, Text Generation WebUI等)
 - 内部大语言模型服务
 - 内部嵌入模型服务
 
 ### 步骤2: 配置模型端点
 
-1. 启动Lumina系统
+1. 启动Simple-KB系统
 2. 登录系统后,在模型配置页面添加内部模型配置:
    - LLM模型: 配置内部LLM服务的URL和API密钥
    - 嵌入模型: 配置内部嵌入服务的URL和API密钥

+ 2 - 2
docs/INTERNAL_DEPLOYMENT_SUMMARY.md → INTERNAL_DEPLOYMENT_SUMMARY.md

@@ -1,8 +1,8 @@
-# 内网部署修改摘要 - Lumina 知识库系统
+# 内网部署修改摘要 - Simple-KB 知识库系统
 
 ## 修改概述
 
-已完成对Lumina知识库系统的修改,以支持内部网络环境部署,消除了外部依赖。
+已完成对Simple-KB知识库系统的修改,以支持内部网络环境部署,消除了外部依赖。
 
 ## 具体修改内容
 

+ 4 - 4
docs/QUICK_START.md → QUICK_START.md

@@ -6,7 +6,7 @@
 
 ```bash
 # プロジェクトディレクトリに移動
-cd /home/fzxs/workspaces/demo/lumina
+cd /home/fzxs/workspaces/demo/simple-kb
 
 # 環境設定ファイルの作成
 cp server/.env.sample server/.env
@@ -40,8 +40,8 @@ docker-compose ps
 # 期待される出力:
 # NAME                COMMAND                  STATUS
 # local-es            ...                      Up
-# lumina-tika         ...                      Up
-# lumina-libreoffice  ...                      Up
+# simple-kb-tika      ...                      Up
+# simple-kb-libreoffice ...                    Up
 ```
 
 <http://localhost:5173> にアクセスして開始してください!
@@ -181,7 +181,7 @@ docker-compose logs libreoffice
 
 ```bash
 # server イメージを再ビルド
-docker build -t lumina-server:latest ./server/
+docker build -t simple-kb-server:latest ./server/
 docker-compose up -d server
 ```
 

+ 3 - 3
README.md

@@ -1,4 +1,4 @@
-# Lumina (簡易ナレッジベース)
+# 簡易ナレッジベース (Simple Knowledge Base)
 
 React + NestJS をベースにしたフルスタックのナレッジベースQ&Aシステムです。マルチモデル、多言語対応の RAG (検索拡張生成) 機能を備えています。
 
@@ -55,7 +55,7 @@ React + NestJS をベースにしたフルスタックのナレッジベースQ&
 
 ```bash
 git clone <repository-url>
-cd lumina
+cd simple-kb
 ```
 
 ### 2. 依存関係のインストール
@@ -137,7 +137,7 @@ yarn dev
 ## 📁 プロジェクト構造
 
 ```
-lumina/
+simple-kb/
 ├── web/                 # フロントエンド・アプリケーション
 │   ├── components/      # React コンポーネント
 │   ├── services/        # API サービス

+ 39 - 14
docker-compose.yml

@@ -1,7 +1,7 @@
 services:
   es:
     image: elasticsearch:9.2.1
-    container_name: lumina-es
+    container_name: local-es
     environment:
       - discovery.type=single-node
       - xpack.security.enabled=false
@@ -9,38 +9,61 @@ services:
     ports:
       - "9200:9200"
     volumes:
-      - lumina-es-data:/usr/share/elasticsearch/data
+      - es-data:/usr/share/elasticsearch/data
     networks:
-      - lumina-network
+      - simple-kb-network
   #    restart: unless-stopped
 
   tika:
     image: apache/tika:latest
-    container_name: lumina-tika
+    container_name: simple-kb-tika
     ports:
       - "9998:9998"
     networks:
-      - lumina-network
+      - simple-kb-network
     restart: unless-stopped
 
   libreoffice:
     build:
       context: ./libreoffice-server
       dockerfile: Dockerfile
-    container_name: lumina-libreoffice
+    container_name: simple-kb-libreoffice
     ports:
       - "8100:8100"
     volumes:
       - ./uploads:/app/uploads
       - ./temp:/temp
     networks:
-      - lumina-network
+      - simple-kb-network
     restart: unless-stopped
+
+  ollama:
+    image: ollama/ollama:latest
+    container_name: simple-kb-ollama
+    ports:
+      - "11434:11434"
+    environment:
+      - OLLAMA_CPU_ONLY=1
+    volumes:
+      - ollama-data:/root/.ollama
+    networks:
+      - simple-kb-network
+    restart: unless-stopped
+    entrypoint: ["/bin/sh", "-c"]
+    command: >
+      "ollama serve & 
+      sleep 10 && 
+      ollama pull qwen2.5:3b && 
+      ollama pull nomic-embed-text:latest && 
+      ollama pull llava-phi3:3.8b && 
+      echo 'All models pulled successfully!' && 
+      wait"
+
   # server:
   #   build:
   #     context: ./server
   #     dockerfile: Dockerfile
-  #   container_name: lumina-server
+  #   container_name: simple-kb-server
   #   environment:
   #     - NODE_ENV=production
   #     - NODE_OPTIONS=--max-old-space-size=8192
@@ -64,7 +87,7 @@ services:
   #     - libreoffice
   #   #    restart: unless-stopped
   #   networks:
-  #     - lumina-network
+  #     - simple-kb-network
 
   # web:
   #   build:
@@ -72,7 +95,7 @@ services:
   #     dockerfile: ./web/Dockerfile
   #     args:
   #       - VITE_API_BASE_URL=/api
-  #   container_name: lumina-web
+  #   container_name: simple-kb-web
   #   depends_on:
   #     - server
   #   ports:
@@ -81,14 +104,16 @@ services:
   #   volumes:
   #     - ./nginx/conf.d:/etc/nginx/conf.d
   #   networks:
-  #     - lumina-network
+  #     - simple-kb-network
 
 networks:
-  lumina-network:
+  simple-kb-network:
     driver: bridge
 
 volumes:
-  lumina-es-data:
+  es-data:
+    driver: local
+  ollama-data:
     driver: local
-  lumina-data:
+  simple-kb-data:
     driver: local

+ 0 - 1
docs/API.md

@@ -265,7 +265,6 @@ Authorization: Bearer <token>
   "maxTokens": number,
   "topK": number,
   "enableRerank": boolean,
-  "scoreThreshold": number,
   "similarityThreshold": number,
   "enableFullTextSearch": boolean,
   "language": "zh|en|ja"

+ 2 - 2
docs/DEPLOYMENT.md

@@ -12,7 +12,7 @@
 
 ```bash
 git clone <repository-url>
-cd lumina
+cd simple-kb
 ```
 
 ### 2. 依存関係のインストール
@@ -234,7 +234,7 @@ PORT=3000
 VITE_API_BASE_URL=http://localhost:3000
 
 # アプリケーション設定
-VITE_APP_TITLE=Lumina
+VITE_APP_TITLE=簡易ナレッジベース
 VITE_MAX_FILE_SIZE=50
 ```
 

+ 1 - 1
docs/DESIGN.md

@@ -1,4 +1,4 @@
-# Lumina (簡易ナレッジベース) - システム設計ドキュメント
+# 簡易ナレッジベース (Simple Knowledge Base) - システム設計ドキュメント
 
 ## 1. プロジェクト概要
 

+ 0 - 119
docs/FEAT_ANALYSIS.md

@@ -1,119 +0,0 @@
-# 知识库系统功能扩展深度分析报告
-
-基于对当前代码库 (Lumina) 的分析,系统已经具备了坚实的基础,包括:
-- **双模索引**: 高速 (Tika) 与 高精度 (Vision/OCR)。
-- **混合检索**: 向量检索 + 全文检索 + 重排序 (Rerank)。
-- **多模型支持**: 集成了 OpenAI 与 Gemini 体系。
-- **用户隔离**: 完整的权限管理和独立的知识库环境。
-
-为了进一步提升系统的专业性与用户体验,以下是功能扩展的深度建议:
-
----
-
-## 🚀 1. 检索性能与精度优化 (RAG 核心)
-
-### 1.1 查询改写与扩充 (Query Expansion)
-- **现状**: 系统直接使用用户的原始输入进行搜索。
-- **扩展建议**: 
-    - **Multi-Query**: 使用 LLM 将用户的一个问题改写成 3-5 个不同侧重点的问题,从而覆盖更多索引片段。
-    - **HyDE (假设性文档嵌入)**: 让 LLM 先生成一个“伪答案”,利用这个伪答案的向量去检索,能有效解决语义对齐问题。
-
-### 1.2 增强的上下文管理 (Advanced Context)
-- **多轮对话语义压缩**: 在多轮对话中,利用 LLM 提取当前对话的“真实意图”再进行检索,而不是仅搜索最后一句话。
-- **长文本处理**: 针对长上下文模型,优化检索片段的排列顺序(如将最相关的放在首尾,避免“中间迷失”现象)。
-
-### 1.3 知识图谱集成 (Knowledge Graph)
-- **实体关联**: 在索引阶段通过 LLM 提取文档中的实体(人名、地点、概念)及其关系。
-- **价值**: 解决 RAG 无法处理的“跨文档复杂关系推理”问题。
-
----
-
-## ✨ 2. 功能特性扩展 (Rich Features)
-
-### 2.1 实时在线研究 (Web Search Integration)
-- **工具集成**: 接入 Tavily 或 Google Search API。
-- **应用场景**: 知识库内没有答案时,允许 AI 联网搜索最新信息并与本地知识合并回答。
-
-### 2.2 代理化工作流 (Agentic Workflows)
-- **任务规划**: 引入 ReAct 或思维链 (CoT) 模式,让 AI 可以自主判断何时需要搜索知识库、何时进行计算或调用其他工具。
-- **多步处理**: 例如“请帮我对比 A 文档和 B 文档中关于成本的描述,并计算总和”。
-
-### 2.3 自动摘要与报告生成
-- **一键总结**: 为每个“笔记本” (Notebook) 或文档集提供生成月报、摘要 or 导图的功能。
-- **长篇创作**: 基于知识库内容,协助用户完成长篇论文或技术方案。
-
----
-
-## 🤝 3. 社交与协作 (Social & Collaboration)
-
-### 3.1 共享知识库 (Shared Notebooks)
-- **协作研究**: 支持邀请其他用户加入特定的知识库分组,实现团队共享资料。
-- **协同批注**: 允许多名用户在同一个 PDF 预览中进行高亮、评论和讨论。
-
-### 3.2 团队权限管理
-- **细粒度控制**: 区分所有者、编辑者和查看者。
-- **公共/私有切换**: 允许将某些知识库设为“企业内部公共阅览”。
-
----
-
-## 🧠 4. 智能发现与挖掘 (Intelligence & Discovery)
-
-### 4.1 自动标签与分类 (Auto-Tagging)
-- **语义属性**: 上传文档后,AI 自动识别主题、关键词和类别并打上标签。
-- **趋势分析**: 发现知识库中不断增长的主题(如“最近关于 A 项目的讨论突然变多了”)。
-
-### 4.2 关系跨越式推荐 (Related Gems)
-- **关联发现**: 当用户阅读文档 A 时,侧边栏自动推荐“与此内容高度相关”的其他文档 B 或对话片段。
-- **知识孤岛消除**: 帮助用户连接原本看似不相关的分散文件。
-
----
-
-## 🌐 5. 生态集成与入口 (Ecosystem)
-
-### 5.1 万物皆可采集 (Omni-Capture)
-- **浏览器扩展**: 一键保存网页内容(剪辑)到 Lumina 知识库。
-- **邮件转发**: 支持通过发送邮件到特定地址来入库。
-
-### 5.2 跨平台入口
-- **Mobile-Optimized**: 提供完善的移动端 H5 界面。
-- **IM 集成**: 接入 Slack/钉钉/飞书 机器人,实现工作流中的即时问答。
-
----
-
-## 🎨 6. 用户体验增强 (UX Improvements)
-
-### 6.1 深度引用跳转 (Precise Sourcing)
-- **现状**: 已有 PDF 预览,但定位可能不够精确。
-- **扩展建议**: 点击引用标记时,直接在 PDF 预览中高亮显示对应的文本行或段落。
-
-### 6.2 提示词库 (Prompt Templates)
-- **预设场景**: 提供一系列常用的提示词模板(如:合同审查、代码解释、学术总结)。
-- **用户分享**: 允许用户创建并分享自己的提示词快捷方式。
-
-### 6.3 语音与多模态交互
-- **TTS/STT**: 加入语音输入与回复功能。
-- **实时文档对话**: 支持在聊天中直接拖入一个临时文档(不入库)进行即时询问。
-
----
-
-## 🛠️ 7. 管理与运维增强 (Admin & System)
-
-### 7.1 数据分析看板 (Analytics Dashboard)
-- **词云与热点**: 展示用户最常问的问题类型和被检索频次最高的文档。
-- **消耗统计**: 展示各模型的 Token 消耗分布和成本分析。
-
-### 7.2 反馈与持续改进 (Human-in-the-loop)
-- **点赞/点踩**: 建立反馈机制,收集回答质量数据。
-- **检索纠偏**: 如果 AI 找错了片段,允许用户纠正检索结果,并将此反馈反馈给索引系统进行优化。
-
-### 7.3 外部同步 (External Sync)
-- **云端接入**: 支持同步 OneDrive、SharePoint、GitHub 或 Notion 中的文档,实现自动监听与更新。
-
----
-
-## 📅 实施路线图 (Roadmap)
-
-1.  **第一阶段 (性能优先)**: 升级 **Query Expansion** 和 **HyDE**,提升问答准确率;加入 **自动生成标题** 功能。
-2.  **第二阶段 (体验优化)**: 实现 **跨文档复杂对比** 工作流和 **高亮跳转**。
-3.  **第三阶段 (社交协作)**: 推出 **共享笔记本** 和 **团队权限管理**。
-4.  **第四阶段 (生态与智能)**: 接入 **联网搜索**、**浏览器扩展** 以及 **跨文档关联分析**。

+ 2 - 2
docs/PROJECT_EXPLANATION_JA.md

@@ -1,8 +1,8 @@
-# Lumina (lumina) 技術および機能アーキテクチャ
+# Simple Knowledge Base (simple-kb) 技術および機能アーキテクチャ
 
 ## 1. プロジェクト概要
 
-**Lumina (lumina)** は、React と NestJS をベースにしたフルスタックのRAG(検索拡張生成)システムです。ユーザーは多様な形式のドキュメントをアップロードし、カスタム設定でインデックス化を行い、大規模言語モデル(LLM)を用いてナレッジベースに基づいた高度な問答を行うことができます。
+**Simple Knowledge Base (simple-kb)** は、React と NestJS をベースにしたフルスタックのRAG(検索拡張生成)システムです。ユーザーは多様な形式のドキュメントをアップロードし、カスタム設定でインデックス化を行い、大規模言語モデル(LLM)を用いてナレッジベースに基づいた高度な問答を行うことができます。
 
 最近のアップデートでは、Google NotebookLM に触発された「ナレッジグループ(Notebooks)」機能や「ポッドキャスト生成」機能が追加され、単なる検索システムを超えた学習・分析プラットフォームへと進化しています。
 

+ 47 - 0
docs/README.md

@@ -0,0 +1,47 @@
+# ドキュメント索引
+
+## 📚 主要ドキュメント
+
+### 🏗️ システムアーキテクチャ
+
+- [システム設計ドキュメント](DESIGN.md) - アーキテクチャ設計と技術スタックの全容
+- [現在の実装状況](CURRENT_IMPLEMENTATION.md) - 実装済み機能のリスト
+- [API リファレンス](API.md) - API エンドポイントの詳細説明
+
+### 🚀 デプロイと運用
+
+- [デプロイガイド](DEPLOYMENT.md) - 開発および本番環境でのデプロイ手順
+- [サポートされているファイル形式](SUPPORTED_FILE_TYPES.md) - 対応しているファイル拡張子の一覧
+- [開発基準](DEVELOPMENT_STANDARDS.md) - コードコメントおよびログに関する基準
+
+### 🔧 機能の実装
+
+- [RAG 機能の実装](RAG_COMPLETE_IMPLEMENTATION.md) - 検索拡張生成機能の詳細
+- [Vision Pipeline の実装](VISION_PIPELINE_COMPLETE.md) - 画像・テキスト混合処理の詳細
+- [チャンクサイズの制限](CHUNK_SIZE_LIMITS.md) - ドキュメント分割パラメータの管理
+
+### 🐛 修正履歴
+
+- [Embedding モデル ID の修正](EMBEDDING_MODEL_ID_FIX.md) - モデル設定の ID 連携に関する修正
+- [類似度スコアの修正](SIMILARITY_SCORE_BUGFIX.md) - 検索スコアが 100% を超える問題の修正
+- [メモリ最適化の修正](MEMORY_OPTIMIZATION_FIX.md) - 大容量ファイルによるメモリ溢れの問題の修正
+
+## 🔍 クイックリファレンス
+
+### 問題が発生した場合
+
+1. 関連する修正ドキュメントを確認してください (EMBEDDING_MODEL_ID_FIX.md など)。
+2. デプロイ設定を確認してください (DEPLOYMENT.md)。
+3. ファイル形式がサポートされているか確認してください (SUPPORTED_FILE_TYPES.md)。
+
+### 新機能の開発時
+
+1. システム設計を確認してください (DESIGN.md)。
+2. API 仕様を確認してください (API.md)。
+3. 開発基準に従ってください (DEVELOPMENT_STANDARDS.md)。
+
+### システムのデプロイ時
+
+1. デプロイガイドに従って操作してください (DEPLOYMENT.md)。
+2. 環境変数とパラメータを設定してください。
+3. 各機能が正常に動作することを確認してください。

+ 0 - 68
docs/design/feat-auto-title-generation.md

@@ -1,68 +0,0 @@
-# Feature Design: Automatic Title Generation (feat-auto-title-generation)
-
-## 1. Overview
-This feature automatically generates meaningful titles for uploaded documents and chat sessions using AI. It aims to replace generic filenames and "New Conversation" labels with content-aware titles, improving user experience and organization.
-
-## 2. Requirements
-
-### 2.1 Document Title Generation
-- **Trigger**: Automatically triggered after text extraction (Fast or Precise mode).
-- **Process**:
-    1. Extract a sample of the document content (first 2,000 - 3,000 characters).
-    2. Send the content to the default LLM with a specific generation prompt.
-    3. Update the `KnowledgeBase` record with the generated title.
-- **Rules**:
-    - The title should be concise (less than 50 characters).
-    - It should be in the user's preferred language (defaulting to the detected document language if possible).
-    - Output should be "raw" (no preamble like "The title is...").
-
-### 2.2 Chat Title Generation
-- **Trigger**: Triggered after the first user message and its corresponding assistant response are recorded.
-- **Process**:
-    1. Collect the initial message pair.
-    2. Send the pair to the default LLM with a generation prompt.
-    3. Update the `SearchHistory` record's `title` field.
-- **Rules**: Same as document titles.
-
-## 3. Technical Design
-
-### 3.1 Data Model Changes
-- **KnowledgeBase Entity**: Add a `title` field (nullable, optional). If empty, fallback to `originalName`.
-- **SearchHistory Entity**: No changes required (has `title`).
-
-### 3.2 Backend Implementation
-
-#### KnowledgeBaseService
-- Add `generateTitle(kbId: string)` method.
-- Hook into `processFile` after `updateStatus(kbId, FileStatus.EXTRACTED)`.
-
-#### ChatService / SearchHistoryService
-- Add logic to check if the session title is still the default (usually the first message snippet) and trigger `generateTitle(historyId: string)` after the first assistant response.
-
-#### Prompt Design
-- **Document Prompt**:
-  ```text
-  You are a document analyzer. Read the provided text and generate a concise, professional title (max 50 chars). 
-  Return ONLY the title.
-  Language: {userLanguage}
-  Text: {contentSample}
-  ```
-- **Chat Prompt**:
-  ```text
-  Based on the following conversation snippet, generate a short, descriptive title (max 50 chars) that summarizes the topic.
-  Return ONLY the title.
-  Language: {userLanguage}
-  Snippet:
-  User: {userMessage}
-  AI: {aiResponse}
-  ```
-
-## 4. Verification Plan
-
-### Automated Tests
-- Integration tests in `KnowledgeBaseService` to verify the title field is updated after processing.
-- Mock LLM responses to ensure the title update logic works.
-
-### Manual Verification
-- Upload various files (PDF, Word, TXT) and verify the displayed title in the knowledge base list.
-- Start a new chat, send a message, and check the sidebar for the updated session title.

+ 0 - 59
docs/design/feat-cross-doc-comparison.md

@@ -1,59 +0,0 @@
-# Design: Cross-Document Comparison (Agentic Workflow)
-
-## 1. Background & Problem
-Users often need to compare multiple documents (e.g., "Compare the financial reports of Q1 and Q2" or "Differences between Product A and Product B specs").
-Standard RAG retrieves chunks based on semantic similarity to the query. While "Multi-Query" helps, standard RAG might:
-1.  Retrieve too many chunks from one document and miss the other.
-2.  Fail to align comparable attributes (e.g., comparing "revenue" in Doc A with "profit" in Doc B).
-3.  Produce a generic text answer instead of a structured comparison.
-
-## 2. Solution: Agentic Comparison Workflow
-We will implement a specialized workflow (or "Light Agent") that:
-1.  **Analyzes the Request**: Identifies the subjects to compare (e.g., "Q1 Report", "Q2 Report") and the dimensions (e.g., "Revenue", "Risks").
-2.  **Targeted Retrieval**:
-    -   Explicitly filters/searches for Doc A.
-    -   Explicitly filters/searches for Doc B.
-3.  **Structured Synthesis**: Generates the answer, potentially forcing a Markdown Table format for clarity.
-
-## 3. Technical Architecture
-
-### 3.1 Backend (`ComparisonService` or extension to `RagService`)
--   **Intent Detection**: Modify `ChatService` or `RagService` to detect comparison intent (can utilize LLM or simple heuristics + keywords).
--   **Planning**: If comparison is detected:
-    1.  Identify Target Files: Resolve file names/IDs from the query (e.g., "Q1" -> matches file "2024_Q1_Report.pdf").
-    2.  Dimension Extraction: What to compare? (e.g., "summary", "key metrics").
-    3.  Execution:
-        -   Run Search on File A with query "key metrics".
-        -   Run Search on File B with query "key metrics".
-        -   Combine context.
--   **Prompting**: Use a prompt optimized for comparison (e.g., "Generate a comparison table...").
-
-### 3.2 Frontend (`ChatInterface`)
--   **UI Trigger**: (Optional) specific "Compare" button, or just natural language.
--   **Visuals**: Render the response standard markdown (which supports tables).
--   **Source Attribution**: Ensure citations map back to the correct respective documents.
-
-## 4. Implementation Steps
-
-1.  **Intent & Entity Extraction (Simple Version)**:
-    -   In `RagService`, add a step `detectComparisonIntent(query)`.
-    -   Return `subjects: string[]` (approximate filenames) and `dimensions: string`.
-    
-2.  **Targeted Search**:
-    -   Use `elasticsearchService` to search *specifically* within the resolved file IDs (if we can map names to IDs).
-    -   Fall back to broad search if file mapping fails.
-
-3.  **Comparison Prompt**:
-    -   Update `rag.service.ts` to use a `comparisonPromise` if intent is detected.
-
-## 5. Risks & limitations
--   **File Name Matching**: Mapping user spoken "Q1" to "2024_Q1_Report_Final.pdf" is hard without fuzzy matching or LLM resolution.
-    -   *Mitigation*: Use a lightweight LLM call or fuzzy search on the file list to resolve IDs.
--   **Latency**: Two searches + entity resolution might add latency.
-    -   *Mitigation*: Run searches in parallel.
-
-## 6. MVP Scope
--   Automated detection of "Compare A and B".
--   Attempt to identify if A and B refer to specific files in the selected knowledge base.
--   If identified, restrict search scopes accordingly (or boost them).
--   Generate a table response.

+ 0 - 37
docs/design/feat-highlight-jump.md

@@ -1,37 +0,0 @@
-# Feature Design: Highlight Jump (Precise Sourcing)
-
-## Problem Statement
-Currently, when a user clicks a citation in the chat, they can see the source text in a drawer or open the PDF. However, the PDF opens to the first page (or just the file) without pinpointing the exact location of the referenced information. This forces the user to manually search for the content.
-
-## Proposed Solution
-Implement "Highlight Jump" functionality:
-1.  **Page Jump**: When opening a citation, the PDF viewer should immediately jump to the specific page number containing the chunk.
-2.  **Text Highlighting**: The specific text segment used in the citation should be highlighted visually on the PDF page.
-
-## Technical Implementation
-
-### Frontend
-
-#### 1. `PDFPreview.tsx`
--   **Enable Text Layer**: Currently, `PDFPreview` renders only to a `<canvas>`. We must enable `pdf.js` **Text Layer** rendering on top of the canvas. This allows text selection and searching.
--   **New Props**:
-    -   `initialPage`: Already exists? Need to verify it works reliably.
-    -   `highlightText`: A string (the chunk content) to search for and highlight.
--   **Highlight Logic**:
-    -   On page load, if `highlightText` is provided, search for this text in the Text Layer.
-    -   Apply a visual highlight (e.g., yellow background) to the matching DOM elements in the text layer.
-    -   Scroll the highlighted element into view.
-
-#### 2. `SourcePreviewDrawer.tsx`
--   Pass the `pageNumber` and `content` (as `highlightText`) to the `onOpenFile` callback.
--   Update the "Open File" button to trigger this with the correct metadata.
-
-#### 3. `ChatInterface.tsx` / `ChatView.tsx`
--   Ensure the state that manages the open PDF preview receives the `pageNumber` and `highlightText` from the source.
-
-### Backend
--   **No changes required** if `RagSearchResult` already contains `pageNumber`. (Verified: It does).
-
-## Limitations
--   **OCR Files**: If the file was indexed via OCR (images), `pdf.js` might not extract a text layer that matches exactly what Tika extracted, or might have no text layer. In this case, we fallback to just Page Jump.
--   **Text Mismatch**: If the chunk text is slightly different from the PDF text layer (due to cleaning/normalization during indexing), exact string matching might fail. We will try to match a substring or a fuzzy match if possible, but exact match of the first ~50 chars is a good starting point.

+ 0 - 52
docs/design/feat-query-expansion-hyde.md

@@ -1,52 +0,0 @@
-# Feature Design: Query Expansion & HyDE Integration
-
-This document outlines the design for improving search relevance in Lumina using Query Expansion (Multi-Query) and Hypothetical Document Embeddings (HyDE).
-
-## Problem Statement
-The current search implementation relies on the user's original query. Simple vector search can sometimes fail to match relevant documents due to:
-1.  **Keyword Mismatch**: The user might use different terminology than the document.
-2.  **Semantic Gap**: The query might be too brief to capture the full semantic context required for a good vector match.
-
-## Proposed Solution
-
-### 1. Query Expansion (Multi-Query)
-We will use an LLM to generate 3 unique variations of the user's query. This helps to:
-- Capture different facets of the user's intent.
-- Increase the probability of hitting relevant segments in the knowledge base.
-
-### 2. HyDE (Hypothetical Document Embeddings)
-We will use an LLM to generate a brief "hypothetical" answer to the user's query.
-- Instead of embedding the question, we embed the hypothetical answer.
-- This often results in better vector matches because we are comparing "answer-like" vectors with "document-like" segments.
-
-## Technical Implementation
-
-### Backend Changes
-
-#### `RagService` (server/src/rag/rag.service.ts)
-- **New Methods**:
-    - `expandQuery(query: string, userId: string): Promise<string[]>`: Generates 3 variations of the query.
-    - `generateHyDE(query: string, userId: string): Promise<string>`: Generates a hypothetical document.
-- **Update `searchKnowledge`**:
-    - Add `enableQueryExpansion` and `enableHyDE` parameters.
-    - Implement logic to handle multiple search requests (concurrently) and deduplicate results.
-
-#### `ChatService` (server/src/chat/chat.service.ts)
-- Pass the new search options from user settings or request parameters.
-
-### Frontend Changes
-
-#### `types.ts` (web/types.ts)
-- Update `AppSettings` to include `enableQueryExpansion` and `enableHyDE`.
-
-#### `SettingsDrawer.tsx`
-- Add UI toggles for these new search enhancement features.
-
-## Verification Plan
-
-### Backend Logs
-- Verify that LLM calls for expansion and HyDE are being made.
-- Log the generated queries and hypothetical documents for debugging.
-
-### Manual Verification
-- Compare search results with and without these features enabled for complex queries.

+ 0 - 41
docs/testing/feat-auto-title-generation.md

@@ -1,41 +0,0 @@
-# 测试用例 - 标题自动生成 (Auto Title Generation)
-
-## 1. 功能概述
-本功能旨在提高系统的可用性,当用户上传文件或开始新的对话时,系统会自动调用 LLM 生成描述性的标题,而不是仅仅使用文件名或消息摘要。
-
-## 2. 测试场景与结果
-
-### 场景 A: 知识库文件标题生成
-- **测试步骤**:
-  1. 上传一个名为 `画面デザイン・機能案_20260209.pptx` 的文件。
-  2. 观察后台日志中是否调用了标题生成逻辑。
-  3. 检查数据库中该文件的 `title` 字段。
-- **验证结果**:
-  - **日志显示**: `[ChatService] Generated title for KnowledgeBase: 生成AIチャットボットUI改修案`
-  - **数据库确认**:
-    ```text
-    ID: e7841ec0-de0e-4e5e-afd8-aa987d872161
-    Name: 画面デザイン・機能案_20260209.pptx
-    Title: 生成AIチャットボットUI改修案
-    ```
-- **结论**: **通过**
-
-### 场景 B: 聊天会话标题生成
-- **测试步骤**:
-  1. 开启新对话,询问 "RAG アーキテクチャ 意味と仕組み"。
-  2. 等待对话结束。
-  3. 刷新历史列表,查看生成的标题。
-- **验证结果**:
-  - **日志显示**: `[ChatService] Generated title for chat 122c8c54-ffe6-4bf8-96d9-53bd0a3da631: RAG:検索増強生成の概要`
-  - **前端显示**: 历史列表中准确显示 "RAG:検索増強生成の概要"。
-- **结论**: **通过**
-
-## 3. 边缘情况测试
-- **LLM 调用失败**: 系统应回退使用文件名或消息前几个字符。
-- **网络延迟**: 标题生成应异步进行或不阻塞主响应流程。
-
-## 4. 最终状态
-- [x] 代码逻辑实现
-- [x] 数据库字段更新
-- [x] 后端日志验证
-- [x] 前端显示验证

+ 0 - 43
docs/testing/feat-query-expansion-hyde.md

@@ -1,43 +0,0 @@
-# 测试用例 - 检索增强 (Query Expansion & HyDE)
-
-## 1. 功能概述
-本功能通过“查询扩展(Query Expansion)”和“假设性文档嵌入(HyDE)”提升检索的相关性,特别是针对短查询或跨语言查询。
-
-## 2. 测试场景与结果
-
-### 场景 A: 查询扩展 (Query Expansion)
-- **测试步骤**:
-  1. 在设置中开启“查询扩展”。
-  2. 输入查询: "RAG アーキテクチャ 意味と仕組み"。
-  3. 查看后台日志中生成的扩展查询。
-- **验证结果**:
-  - **日志显示**: `[RagService] Generated query variations: RAGの基本的な定義と構成要素 | 検索増強生成の仕組みとメリット | LLMと外部知識の統合プロセス`
-  - **检索执行**: 系统针对这几个变体分别执行了 Elasticsearch 检索并成功合并结果。
-- **结论**: **通过**
-
-### 场景 B: HyDE (Hypothetical Document Embeddings)
-- **测试步骤**:
-  1. 在设置中开启 "HyDE"。
-  2. 输入同样的查询。
-  3. 查看日志中生成的假设性文档。
-- **验证结果**:
-  - **日志显示**: `[RagService] Generated HyDE document: RAG(Retrieval-Augmented Generation)は、大規模言語モデル...`
-  - **检索执行**: 系统基于生成的长段落执行了向量搜索,提升了语义匹配度。
-- **结论**: **通过**
-
-### 场景 C: 重排序 (Rerank) 与阈值问题
-- **测试步骤**:
-  1. 开启 Rerank。
-  2. 检查得分较低(如 0.528)的结果是否能成功返回。
-- **验证结果**:
-  - **初始版本**: 结果被 `similarityThreshold` (0.7) 过滤。
-  - **修复后**: 系统识别到使用了 Rerank,自动应用 `scoreThreshold` (0.5),结果成功返回。
-  - **日志显示**: `Results after filtering (threshold 0.5, usedRerank=true): 1 / 1 items`
-- **结论**: **通过**
-
-## 3. 最终状态
-- [x] 设置面板开关生效
-- [x] 多级检索逻辑正确
-- [x] 结果合并与消重逻辑
-- [x] Rerank 阈值解耦逻辑
-- [x] 前端国际化配置

+ 1 - 1
libreoffice-server/package.json

@@ -1,5 +1,5 @@
 {
-    "name": "lumina-pdf-service",
+    "name": "md-to-pdf-service",
     "version": "1.0.0",
     "dependencies": {
         "marked": "^11.1.1",

+ 1 - 1
package.json

@@ -1,5 +1,5 @@
 {
-  "name": "lumina",
+  "name": "simple-kb-monorepo",
   "private": true,
   "workspaces": [
     "web",

+ 2 - 2
server/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "lumina-server",
+  "name": "server",
   "version": "0.0.1",
   "description": "",
   "author": "",
@@ -101,4 +101,4 @@
     "coverageDirectory": "../coverage",
     "testEnvironment": "node"
   }
-}
+}

+ 1 - 1
server/src/ai/embedding.service.ts

@@ -10,7 +10,7 @@ export class EmbeddingService {
     try {
       // ほとんどの設定が OpenAI インターフェースと互換性があると仮定
       const embeddings = new OpenAIEmbeddings({
-        openAIApiKey: config.apiKey || 'sk-placeholder', // ローカルモデルの場合は key が不要な場合がある
+        openAIApiKey: config.apiKey || 'ollama', // ローカルモデルの場合は key が不要な場合がある
         configuration: {
           baseURL: config.baseUrl,
         },

+ 6 - 8
server/src/api/api.controller.ts

@@ -11,6 +11,7 @@ import {
 import { ApiService } from './api.service';
 import { JwtAuthGuard } from '../auth/jwt-auth.guard';
 import { ModelConfigService } from '../model-config/model-config.service';
+import { I18nService } from '../i18n/i18n.service';
 
 class ChatDto {
   prompt: string;
@@ -21,6 +22,7 @@ export class ApiController {
   constructor(
     private readonly apiService: ApiService,
     private readonly modelConfigService: ModelConfigService,
+    private readonly i18nService: I18nService,
   ) { }
 
   @Get('health')
@@ -34,21 +36,18 @@ export class ApiController {
   async chat(@Request() req, @Body() chatDto: ChatDto) {
     const { prompt } = chatDto;
     if (!prompt) {
-      throw new Error('Prompt is required');
+      throw new Error(this.i18nService.getMessage('promptRequired'));
     }
 
     try {
       // ユーザーの LLM モデル設定を取得
       const models = await this.modelConfigService.findAll(req.user.id);
       const llmModel = models.find((m) => m.type === 'llm');
-
       if (!llmModel) {
-        throw new Error('システム設定で LLM モデルを追加してください');
+        throw new Error(this.i18nService.getMessage('addLLMConfig'));
       }
 
-      if (!llmModel.apiKey) {
-        throw new Error('LLM モデルで API キーを設定してください');
-      }
+      // APIキーはオプションです - ローカルモデルを許可します
 
       // entity タイプを types インターフェースに変換
       const modelConfigForService = {
@@ -58,7 +57,6 @@ export class ApiController {
         baseUrl: llmModel.baseUrl,
         apiKey: llmModel.apiKey,
         type: llmModel.type as any,
-        supportsVision: llmModel.supportsVision,
       };
 
       const response = await this.apiService.getChatCompletion(
@@ -67,7 +65,7 @@ export class ApiController {
       );
       return { response };
     } catch (error) {
-      throw new Error(error.message || 'サーバー内部エラー');
+      throw new Error(error.message || this.i18nService.getMessage('internalServerError'));
     }
   }
 }

+ 2 - 4
server/src/api/api.service.ts

@@ -15,9 +15,7 @@ export class ApiService {
     prompt: string,
     modelConfig: ModelConfig,
   ): Promise<string> {
-    if (!modelConfig.apiKey) {
-      throw new Error('API key is required');
-    }
+    // APIキーはオプションです - ローカルモデルを許可します
 
     try {
       const llm = this.createLLM(modelConfig);
@@ -38,7 +36,7 @@ export class ApiService {
       apiKey: modelConfig.apiKey,
       modelName: modelConfig.modelId,
       configuration: {
-        baseURL: modelConfig.baseUrl || 'https://api.openai.com/v1',
+        baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
       },
     });
   }

+ 10 - 3
server/src/app.module.ts

@@ -1,10 +1,10 @@
-import { Module } from '@nestjs/common';
+import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
 import { ConfigModule, ConfigService } from '@nestjs/config';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ServeStaticModule } from '@nestjs/serve-static';
 import { join } from 'path';
-import { APP_GUARD } from '@nestjs/core';
+import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
 import { AppController } from './app.controller';
 import { AppService } from './app.service';
 import { ApiModule } from './api/api.module';
@@ -28,6 +28,7 @@ import { SearchHistoryModule } from './search-history/search-history.module';
 import { NoteModule } from './note/note.module';
 import { PodcastModule } from './podcasts/podcast.module';
 import { ImportTaskModule } from './import-task/import-task.module';
+import { I18nMiddleware } from './i18n/i18n.middleware';
 import { User } from './user/user.entity';
 import { UserSetting } from './user-setting/user-setting.entity';
 import { ModelConfig } from './model-config/model-config.entity';
@@ -101,4 +102,10 @@ import { ImportTask } from './import-task/import-task.entity';
     },
   ],
 })
-export class AppModule { }
+export class AppModule implements NestModule {
+  configure(consumer: MiddlewareConsumer) {
+    consumer
+      .apply(I18nMiddleware)
+      .forRoutes('*');
+  }
+}

+ 6 - 2
server/src/auth/local.strategy.ts

@@ -3,17 +3,21 @@ import { PassportStrategy } from '@nestjs/passport';
 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';
 
 @Injectable()
 export class LocalStrategy extends PassportStrategy(Strategy) {
-  constructor(private authService: AuthService) {
+  constructor(
+    private authService: AuthService,
+    private i18nService: I18nService,
+  ) {
     super({ usernameField: 'username' });
   }
 
   async validate(username: string, password: string): Promise<SafeUser> {
     const user = await this.authService.validateUser(username, password);
     if (!user) {
-      throw new UnauthorizedException('Incorrect username or password');
+      throw new UnauthorizedException(this.i18nService.getMessage('incorrectCredentials'));
     }
     const { password: userPassword, ...result } = user; // Destructure to remove password
     return result as SafeUser;

+ 17 - 50
server/src/chat/chat.controller.ts

@@ -26,9 +26,7 @@ class StreamChatDto {
   maxTokens?: number; // 新增:maxTokens 参数
   topK?: number; // 新增:topK 参数
   similarityThreshold?: number; // 新增:similarityThreshold 参数
-  enableQueryExpansion?: boolean;
-  enableHyDE?: boolean;
-  scoreThreshold?: number;
+  rerankSimilarityThreshold?: number; // 新增:rerankSimilarityThreshold 参数
 }
 
 @Controller('chat')
@@ -46,25 +44,8 @@ export class ChatController {
     @Res() res: Response,
   ) {
     try {
-      const {
-        message,
-        history = [],
-        userLanguage = 'zh',
-        selectedEmbeddingId,
-        selectedLLMId,
-        selectedGroups,
-        selectedFiles,
-        historyId,
-        enableRerank,
-        selectedRerankId,
-        temperature,
-        maxTokens,
-        topK,
-        similarityThreshold,
-        scoreThreshold,
-        enableQueryExpansion,
-        enableHyDE,
-      } = body;
+      console.log('Full Request Body:', JSON.stringify(body, null, 2));
+      const { message, history = [], userLanguage = 'zh', selectedEmbeddingId, selectedLLMId, selectedGroups, selectedFiles, historyId, enableRerank, selectedRerankId, temperature, maxTokens, topK, similarityThreshold, rerankSimilarityThreshold } = body;
       const userId = req.user.id;
 
       console.log('=== 聊天调试信息 ===');
@@ -80,15 +61,14 @@ export class ChatController {
       console.log('Max Tokens:', maxTokens);
       console.log('Top K:', topK);
       console.log('Similarity Threshold:', similarityThreshold);
-      console.log('Query Expansion:', enableQueryExpansion);
-      console.log('HyDE:', enableHyDE);
+      console.log('Rerank Similarity Threshold:', rerankSimilarityThreshold);
 
       // 获取用户的LLM模型配置
       const models = await this.modelConfigService.findAll(userId);
 
       let llmModel;
       if (selectedLLMId) {
-        llmModel = models.find(m => m.id === selectedLLMId && m.type === 'llm' && m.apiKey);
+        llmModel = models.find(m => m.id === selectedLLMId && m.type === 'llm');
         if (llmModel) {
           console.log('使用选中的LLM模型:', llmModel.name);
         } else {
@@ -96,15 +76,9 @@ export class ChatController {
         }
       }
 
-      // Fallback: デフォルトモデルを優先
-      if (!llmModel) {
-        // まずデフォルトモデルを探す
-        llmModel = models.find((m) => m.type === 'llm' && m.apiKey && m.isDefault && m.isEnabled !== false);
-
-        // デフォルトがない場合、有効な最初のモデルを使用(後方互換性)
-        if (!llmModel) {
-          llmModel = models.find((m) => m.type === 'llm' && m.apiKey && m.isEnabled !== false);
-        }
+      // Fallback: 仅在未选择时尝试获取标记为默认的模型
+      if (!llmModel && selectedLLMId === undefined) {
+        llmModel = models.find((m) => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
       }
 
       console.log('最终使用的LLM模型:', llmModel ? llmModel.name : '无');
@@ -131,18 +105,16 @@ export class ChatController {
         llmModel as any,
         userLanguage,
         selectedEmbeddingId,
-        selectedGroups,
-        selectedFiles,
-        historyId,
+        selectedGroups, // 新增
+        selectedFiles, // 新增
+        historyId, // 新增
         enableRerank,
         selectedRerankId,
-        temperature,
-        maxTokens,
-        topK,
-        similarityThreshold,
-        enableQueryExpansion,
-        enableHyDE,
-        scoreThreshold,
+        temperature, // 传递 temperature 参数
+        maxTokens, // 传递 maxTokens 参数
+        topK, // 传递 topK 参数
+        similarityThreshold, // 传递 similarityThreshold 参数
+        rerankSimilarityThreshold // 传递 rerankSimilarityThreshold 参数
       );
 
       for await (const chunk of stream) {
@@ -178,12 +150,7 @@ export class ChatController {
       const models = await this.modelConfigService.findAll(userId);
 
       // デフォルトモデルを優先
-      let llmModel = models.find((m) => m.type === 'llm' && m.apiKey && m.isDefault && m.isEnabled !== false);
-
-      // デフォルトがない場合、有効な最初のモデルを使用
-      if (!llmModel) {
-        llmModel = models.find((m) => m.type === 'llm' && m.apiKey && m.isEnabled !== false);
-      }
+      const llmModel = models.find((m) => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
 
       res.setHeader('Content-Type', 'text/event-stream');
       res.setHeader('Cache-Control', 'no-cache');

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

@@ -17,7 +17,7 @@ import { RagModule } from '../rag/rag.module';
     UserSettingModule,
     forwardRef(() => KnowledgeGroupModule),
     SearchHistoryModule,
-    forwardRef(() => RagModule),
+    RagModule,
   ],
   controllers: [ChatController],
   providers: [ChatService],

+ 32 - 105
server/src/chat/chat.service.ts

@@ -58,10 +58,8 @@ export class ChatService {
     maxTokens?: number, // 新規: maxTokens パラメータ
     topK?: number, // 新規: topK パラメータ
     similarityThreshold?: number, // 新規: similarityThreshold パラメータ
-    enableQueryExpansion: boolean = false,
-    enableHyDE: boolean = false,
-    scoreThreshold?: number,
-  ): AsyncGenerator<{ type: 'content' | 'sources'; data: any }> {
+    rerankSimilarityThreshold?: number // 新規: rerankSimilarityThreshold パラメータ
+  ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
     console.log('=== ChatService.streamChat ===');
     console.log('ユーザーID:', userId);
     console.log('ユーザー言語:', userLanguage);
@@ -73,8 +71,7 @@ export class ChatService {
     console.log('Max Tokens:', maxTokens);
     console.log('Top K:', topK);
     console.log('類似度しきい値:', similarityThreshold);
-    console.log('Query Expansion:', enableQueryExpansion);
-    console.log('HyDE:', enableHyDE);
+    console.log('Rerankしきい値:', rerankSimilarityThreshold);
     console.log('モデル設定:', {
       name: modelConfig.name,
       modelId: modelConfig.modelId,
@@ -100,6 +97,7 @@ export class ChatService {
         );
         currentHistoryId = searchHistory.id;
         console.log(this.i18nService.getMessage('creatingHistory', effectiveUserLanguage) + currentHistoryId);
+        yield { type: 'historyId', data: currentHistoryId };
       }
 
       // ユーザーメッセージを保存
@@ -111,24 +109,23 @@ export class ChatService {
       let embeddingModel;
       if (selectedEmbeddingId) {
         embeddingModel = models.find(
-          (m) => m.id === selectedEmbeddingId && m.type === 'embedding' && m.apiKey && m.isEnabled !== false,
+          (m) =>
+            m.id === selectedEmbeddingId &&
+            m.type === 'embedding' &&
+            m.isEnabled !== false,
+        );
+        console.log(
+          'selectedEmbeddingId に基づいてモデルを検索:',
+          selectedEmbeddingId,
         );
         console.log(this.i18nService.getMessage('searchingModelById', effectiveUserLanguage) + selectedEmbeddingId);
       }
 
       // 見つからない場合は、デフォルトの埋め込みモデルに戻る
-      if (!embeddingModel) {
+      if (!embeddingModel && selectedEmbeddingId === undefined) {
         console.log('デフォルトの埋め込みモデルを検索中...');
         embeddingModel = models.find(
-          (m) => m.type === 'embedding' && m.apiKey && m.isDefault && m.isEnabled !== false,
-        );
-      }
-
-      // それでも見つからない場合は、最初に使用可能な埋め込みモデルに戻る(後方互換性)
-      if (!embeddingModel) {
-        console.log(this.i18nService.getMessage('searchModelFallback', effectiveUserLanguage));
-        embeddingModel = models.find(
-          (m) => m.type === 'embedding' && m.apiKey && m.isEnabled !== false,
+          (m) => m.type === 'embedding' && m.isDefault && m.isEnabled !== false,
         );
       }
 
@@ -153,27 +150,21 @@ export class ChatService {
         if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
           // ナレッジグループからファイルIDを取得
           effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId);
-          // 如果组内没有文件,应该是一个空数组,从而在后面限制检索范围为“无”
-          if (!effectiveFileIds) {
-            effectiveFileIds = [];
-          }
         }
 
         // 3. RagService を使用して検索 (混合検索 + Rerank をサポート)
         const ragResults = await this.ragService.searchKnowledge(
           message,
           userId,
-          topK !== undefined ? topK : 5, // 渡されたtopK値を使用、デフォルトは5
-          similarityThreshold !== undefined ? similarityThreshold : 0.3, // 渡されたsimilarityThreshold値を使用、デフォルトは0.3
+          topK,
+          similarityThreshold,
           embeddingModel.id,
           true, // enableFullTextSearch (Chat defaults to hybrid)
           enableRerank,
           selectedRerankId,
-          undefined, // selectedGroups - 現在はeffectiveFileIdsパラメータを通じて渡されます
-          effectiveFileIds,  // effectiveFileIds (selectedGroupsから取得したファイルIDを含む)
-          enableQueryExpansion,
-          enableHyDE,
-          scoreThreshold !== undefined ? scoreThreshold : 0.5,
+          undefined, // selectedGroups
+          effectiveFileIds,
+          rerankSimilarityThreshold
         );
 
         // RagSearchResult を ChatService が必要とする形式 (any[]) に変換
@@ -183,7 +174,7 @@ export class ChatService {
         console.log(this.i18nService.getMessage('searchResultsCount', effectiveUserLanguage) + searchResults.length);
 
         // 4. コンテキストの構築
-        context = this.buildContext(searchResults);
+        context = this.buildContext(searchResults, effectiveUserLanguage);
 
         if (searchResults.length === 0) {
           if (selectedGroups && selectedGroups.length > 0) {
@@ -220,13 +211,13 @@ export class ChatService {
       // 5. ストリーム回答生成
       this.logger.log(`${this.i18nService.getMessage('modelCall', effectiveUserLanguage)} タイプ: LLM, モデル: ${modelConfig.name} (${modelConfig.modelId}), ユーザー: ${userId}`);
       const llm = new ChatOpenAI({
-        apiKey: modelConfig.apiKey,
+        apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
         temperature: temperature !== undefined ? temperature : 0.3,
         maxTokens: maxTokens !== undefined ? maxTokens : undefined,
         modelName: modelConfig.modelId,
         configuration: {
-          baseURL: modelConfig.baseUrl || 'https://api.openai.com/v1',
+          baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
         },
       });
 
@@ -266,20 +257,10 @@ export class ChatService {
           content: String(result.content).substring(0, 200) + '...',
           score: result.score,
           chunkIndex: result.chunkIndex,
-          pageNumber: result.pageNumber, // 追加
           fileId: result.fileId,
         })),
       );
 
-      // 初期タイトルの更新(最初のメッセージペアの後に非同期で実行)
-      // タイトルが非常に短い場合やデフォルトの場合にのみ更新するロジックを検討
-      const messages = await this.searchHistoryService.findOne(currentHistoryId, userId);
-      if (messages.messages.length === 2) {
-        this.generateChatTitle(currentHistoryId, message, fullResponse, userId).catch(err => {
-          this.logger.error(`Failed to generate title for ${currentHistoryId}`, err);
-        });
-      }
-
       // 6. 引用元を返却
       yield {
         type: 'sources',
@@ -305,12 +286,12 @@ export class ChatService {
     try {
       this.logger.log(`${this.i18nService.getMessage('modelCall', 'ja')} タイプ: LLM (Assist), モデル: ${modelConfig.name} (${modelConfig.modelId})`);
       const llm = new ChatOpenAI({
-        apiKey: modelConfig.apiKey,
+        apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
         temperature: 0.7,
         modelName: modelConfig.modelId,
         configuration: {
-          baseURL: modelConfig.baseUrl || 'https://api.openai.com/v1',
+          baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
         },
       });
 
@@ -385,11 +366,11 @@ ${instruction}`;
     }
   }
 
-  private buildContext(results: any[]): string {
+  private buildContext(results: any[], language: string = 'ja'): string {
     return results
       .map(
         (result, index) =>
-          `[${index + 1}] ${this.i18nService.getMessage('file', 'ja')}:${result.fileName}\n${this.i18nService.getMessage('content', 'ja')}:${result.content}\n`,
+          `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`,
       )
       .join('\n');
   }
@@ -414,12 +395,7 @@ ${instruction}`;
       const models = await this.modelConfigService.findAll(userId);
 
       // デフォルトの埋め込みモデルを優先
-      let embeddingModel = models.find(m => m.type === 'embedding' && m.apiKey && m.isDefault && m.isEnabled !== false);
-
-      // デフォルトがない場合、有効な最初のモデルを使用
-      if (!embeddingModel) {
-        embeddingModel = models.find(m => m.type === 'embedding' && m.apiKey && m.isEnabled !== false);
-      }
+      const embeddingModel = models.find(m => m.type === 'embedding' && m.isDefault && m.isEnabled !== false);
       if (!embeddingModel) return '';
 
       const results = await this.hybridSearch(
@@ -441,7 +417,6 @@ ${instruction}`;
     messages: ChatMessage[],
     userId: string,
     modelConfig?: ModelConfig, // Optional, looks up if not provided
-    temperature: number = 0.7, // Default to 0.7
   ): Promise<string> {
     try {
       let config = modelConfig;
@@ -450,12 +425,7 @@ ${instruction}`;
         const models = await this.modelConfigService.findAll(userId);
         // Cast to unknown first to bypass partial mismatch between Entity and Interface
         // デフォルトのLLMモデルを優先
-        let found = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
-
-        // デフォルトがない場合、有効な最初のモデルを使用
-        if (!found) {
-          found = models.find(m => m.type === 'llm' && m.isEnabled !== false);
-        }
+        const found = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
         if (found) {
           config = found as unknown as ModelConfig;
         }
@@ -466,12 +436,13 @@ ${instruction}`;
       }
 
       this.logger.log(`${this.i18nService.getMessage('modelCall', 'ja')} タイプ: LLM (Simple), モデル: ${config.name} (${config.modelId}), ユーザー: ${userId}`);
+      const settings = await this.userSettingService.findOrCreate(userId);
       const llm = new ChatOpenAI({
-        apiKey: config.apiKey,
-        temperature: temperature,
+        apiKey: config.apiKey || 'ollama',
+        temperature: settings.temperature ?? 0.7, // ユーザー設定またはデフォルトを使用
         modelName: config.modelId,
         configuration: {
-          baseURL: config.baseUrl || 'https://api.openai.com/v1',
+          baseURL: config.baseUrl || 'http://localhost:11434/v1',
         },
       });
 
@@ -485,48 +456,4 @@ ${instruction}`;
       throw error;
     }
   }
-
-  async generateChatTitle(historyId: string, userMessage: string, assistantResponse: string, userId: string): Promise<string | null> {
-    try {
-      const userLanguage = await this.userSettingService.getLanguage(userId);
-
-      const languageMap: Record<string, string> = {
-        'ja': 'Japanese',
-        'en': 'English',
-        'zh': 'Simplified Chinese',
-      };
-      const targetLanguage = languageMap[userLanguage] || userLanguage;
-
-      const prompt = `Based on the following conversation snippet, generate a short, descriptive title (max 50 chars) that summarizes the topic.
-DO NOT include prefixes like "Title:" or quotes.
-Return ONLY the title text.
-IMPORTANT: Generate the title in ${targetLanguage}.
-Snippet:
-User: ${userMessage}
-AI: ${assistantResponse}`;
-
-      const title = await this.generateSimpleChat(
-        [{ role: 'user', content: prompt }],
-        userId,
-        undefined,
-        0.3, // Use lower temperature for deterministic output
-      );
-
-      // Enhanced cleaning
-      let cleanTitle = title.trim()
-        .replace(/^["']|["']$/g, '') // Remove quotes
-        .replace(/^(Title|Subject|Topic):\s*/i, ''); // Remove common prefixes
-
-      if (cleanTitle.length > 50) {
-        cleanTitle = cleanTitle.substring(0, 50);
-      }
-
-      await this.searchHistoryService.updateTitle(historyId, cleanTitle);
-      this.logger.log(`Generated title for chat ${historyId}: ${cleanTitle}`);
-      return cleanTitle;
-    } catch (err) {
-      this.logger.error(`Failed to generate chat title for ${historyId}`, err);
-      return null;
-    }
-  }
 }

+ 0 - 739
server/src/chat/chat.service.updated.ts

@@ -1,739 +0,0 @@
-import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
-import { ChatOpenAI } from '@langchain/openai';
-import { PromptTemplate } from '@langchain/core/prompts';
-import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
-import { EmbeddingService } from '../knowledge-base/embedding.service';
-import { ModelConfigService } from '../model-config/model-config.service';
-import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
-import { SearchHistoryService } from '../search-history/search-history.service';
-import { ModelConfig } from '../types';
-import { RagService } from '../rag/rag.service';
-
-// 国际化消息管理
-const I18N_MESSAGES = {
-  ja: {
-    noEmbeddingModel: '先にシステム設定で埋め込みモデルを設定してください',
-    searching: 'ナレッジベースを検索中...',
-    noResults: '関連する知識が見つかりませんでした。一般的な知識に基づいて回答します...',
-    searchFailed: 'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
-    generatingResponse: '回答を生成中',
-    files: '個のファイル',
-    notebooks: '個のノートブック',
-    all: 'すべて',
-    items: '件',
-    searchResults: '検索結果',
-    relevantInfoFound: '件の関連情報が見つかりました',
-    searchHits: '検索ヒット',
-    relevance: '関連度',
-    sourceFiles: '元ファイル',
-    searchScope: '検索範囲',
-    error: 'エラー',
-    creatingHistory: '新規対話履歴を作成:',
-    searchingModelById: 'selectedEmbeddingId に基づいてモデルを検索:',
-    searchModelFallback: '指定された埋め込みモデルが見つかりません。最初に使用可能なモデルを使用します。',
-    noEmbeddingModelFound: '埋め込みモデルの設定が見つかりません',
-    usingEmbeddingModel: '使用する埋め込みモデル:',
-    startingSearch: 'ナレッジベースの検索を開始...',
-    searchResultsCount: '検索結果数:',
-    searchFailedLog: '検索失敗',
-    modelCall: '[モデル呼び出し]',
-    chatStreamError: 'Chat stream error',
-    assistStreamError: 'Assist stream error',
-    file: 'ファイル',
-    content: '内容',
-    userLabel: 'ユーザー',
-    assistantLabel: 'アシスタント',
-    intelligentAssistant: 'あなたはインテリジェントな執筆アシスタントです。',
-    searchString: '検索文字列:',
-    embeddingModelIdNotProvided: '埋め込みモデルIDが提供されていません',
-    generatingEmbeddings: '埋め込みベクトルを生成中...',
-    embeddingsGenerated: '埋め込みベクトルの生成が完了しました',
-    dimensions: '次元数',
-    performingHybridSearch: 'ES 混合検索を実行中...',
-    esSearchCompleted: 'ES 検索が完了しました',
-    resultsCount: '結果数',
-    hybridSearchFailed: '混合検索に失敗しました',
-    getContextForTopicFailed: 'getContextForTopic failed',
-    noLLMConfigured: 'No LLM model configured for user',
-    simpleChatGenerationError: 'Simple chat generation error',
-  },
-  zh: {
-    noEmbeddingModel: '先にシステム設定で埋め込みモデルを設定してください', // 根据要求应使用日语
-    searching: 'ナレッジベースを検索中...', // 根据要求应使用日语
-    noResults: '関連する知識が見つかりませんでした。一般的な知識に基づいて回答します...', // 根据要求应使用日语
-    searchFailed: 'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...', // 根据要求应使用日语
-    generatingResponse: '回答を生成中',
-    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: '简单聊天生成错误',
-  },
-  en: {
-    noEmbeddingModel: 'Please configure embedding model in system settings first',
-    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',
-    modelCall: '[Model call]',
-    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',
-  }
-};
-
-// 简化的国际化服务
-class SimpleI18nService {
-  static getMessage(key: string, language: string = 'ja'): string {
-    // 如果指定语言不存在,则回退到日语
-    const lang = I18N_MESSAGES[language] ? language : 'ja';
-    return I18N_MESSAGES[lang][key] || key;
-  }
-
-  static getPrompt(lang: string = 'ja', type: 'withContext' | 'withoutContext' = 'withContext'): string {
-    if (lang === 'zh') {
-      return type === 'withContext' ? `
-基于以下知识库内容回答用户问题。
-
-知识库内容:
-{context}
-
-历史对话:
-{history}
-
-用户问题:{question}
-
-请用中文回答,并严格遵循以下 Markdown 格式要求:
-
-1. **段落与结构**:
-   - 使用清晰的段落分隔,每个要点之间空一行
-   - 使用标题(## 或 ###)组织长回答
-
-2. **文本格式**:
-   - 使用 **粗体** 强调重要概念和关键词
-   - 使用列表(- 或 1.)组织多个要点
-   - 使用 \`代码\` 标记技术术语、命令、文件名
-
-3. **代码展示**:
-   - 使用代码块展示代码,并指定语言:
-     \`\`\`python
-     def example():
-         return "示例"
-     \`\`\`
-   - 支持的语言:python, javascript, typescript, java, bash, sql 等
-
-4. **流程图与图表**:
-   - 使用 Mermaid 语法展示流程图、时序图等:
-     \`\`\`mermaid
-     graph LR
-         A[开始] --> B[处理]
-         B --> C[结束]
-     \`\`\`
-   - 适用场景:流程说明、架构图、状态图、时序图
-
-5. **其他要求**:
-   - 回答要简洁明了,避免冗长
-   - 如果有多个步骤,使用编号列表
-   - 使用表格展示对比信息(如果适用)
-` : `
-作为一个智能助手,请回答用户的问题。
-
-历史对话:
-{history}
-
-用户问题:{question}
-
-请用中文回答。
-`;
-    } else if (lang === 'en') {
-      return type === 'withContext' ? `
-Answer the user's question based on the following knowledge base content.
-
-Knowledge base content:
-{context}
-
-Conversation history:
-{history}
-
-User question: {question}
-
-Please answer in English and strictly follow these Markdown formatting guidelines:
-
-1. **Paragraphs & Structure**:
-   - Use clear paragraph breaks with blank lines between key points
-   - Use headings (## or ###) to organize longer answers
-
-2. **Text Formatting**:
-   - Use **bold** to emphasize important concepts and keywords
-   - Use lists (- or 1.) to organize multiple points
-   - Use \`code\` to mark technical terms, commands, file names
-
-3. **Code Display**:
-   - Use code blocks with language specification:
-     \`\`\`python
-     def example():
-         return "example"
-     \`\`\`
-   - Supported languages: python, javascript, typescript, java, bash, sql, etc.
-
-4. **Diagrams & Charts**:
-   - Use Mermaid syntax for flowcharts, sequence diagrams, etc.:
-     \`\`\`mermaid
-     graph LR
-         A[Start] --> B[Process]
-         B --> C[End]
-     \`\`\`
-   - Use cases: process flows, architecture diagrams, state diagrams, sequence diagrams
-
-5. **Other Requirements**:
-   - Keep answers concise and clear
-   - Use numbered lists for multi-step processes
-   - Use tables for comparison information (if applicable)
-` : `
-As an intelligent assistant, please answer the user's question.
-
-Conversation history:
-{history}
-
-User question: {question}
-
-Please answer in English.
-`;
-    } else { // 默认为日语,符合项目要求
-      return type === 'withContext' ? `
-以下のナレッジベースの内容に基づいてユーザーの質問に答えてください。
-
-ナレッジベースの内容:
-{context}
-
-会話履歴:
-{history}
-
-ユーザーの質問:{question}
-
-日本語で回答してください。以下の Markdown 書式要件に厳密に従ってください:
-
-1. **段落と構造**:
-   - 明確な段落分けを使用し、要点間に空行を入れる
-   - 長い回答には見出し(## または ###)を使用
-
-2. **テキスト書式**:
-   - 重要な概念やキーワードを強調するために **太字** を使用
-   - 複数のポイントを整理するためにリスト(- または 1.)を使用
-   - 技術用語、コマンド、ファイル名をマークするために \`コード\` を使用
-
-3. **コード表示**:
-   - 言語を指定してコードブロックを使用:
-     \`\`\`python
-     def example():
-         return "例"
-     \`\`\`
-   - 対応言語:python, javascript, typescript, java, bash, sql など
-
-4. **図表とチャート**:
-   - フローチャート、シーケンス図などに Mermaid 構文を使用:
-     \`\`\`mermaid
-     graph LR
-         A[開始] --> B[処理]
-         B --> C[終了]
-     \`\`\`
-   - 使用例:プロセスフロー、アーキテクチャ図、状態図、シーケンス図
-
-5. **その他の要件**:
-   - 簡潔で明確な回答を心がける
-   - 複数のステップがある場合は番号付きリストを使用
-   - 比較情報には表を使用(該当する場合)
-` : `
-インテリジェントアシスタントとして、ユーザーの質問に答えてください。
-
-会話履歴:
-{history}
-
-ユーザーの質問:{question}
-
-日本語で回答してください。
-`;
-    }
-  }
-}
-
-export interface ChatMessage {
-  role: 'user' | 'assistant';
-  content: string;
-}
-
-@Injectable()
-export class ChatService {
-  private readonly logger = new Logger(ChatService.name);
-  private readonly defaultDimensions: number;
-
-  constructor(
-    @Inject(forwardRef(() => ElasticsearchService))
-    private elasticsearchService: ElasticsearchService,
-    private embeddingService: EmbeddingService,
-    private modelConfigService: ModelConfigService,
-    @Inject(forwardRef(() => KnowledgeGroupService))
-    private knowledgeGroupService: KnowledgeGroupService,
-    private searchHistoryService: SearchHistoryService,
-    private configService: ConfigService,
-    private ragService: RagService,
-  ) {
-    this.defaultDimensions = parseInt(
-      this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', '2560'),
-    );
-  }
-
-  async *streamChat(
-    message: string,
-    history: ChatMessage[],
-    userId: string,
-    modelConfig: ModelConfig,
-    userLanguage: string = 'ja', // 根据项目要求,默认使用日语
-    selectedEmbeddingId?: string,
-    selectedGroups?: string[], // 新規:選択されたグループ
-    selectedFiles?: string[], // 新規:選択されたファイル
-    historyId?: string, // 新規:対話履歴ID
-    enableRerank: boolean = false,
-    selectedRerankId?: string,
-    temperature?: number, // 新增:temperature 参数
-    maxTokens?: number, // 新增:maxTokens 参数
-    topK?: number, // 新增:topK 参数
-    similarityThreshold?: number // 新增:similarityThreshold 参数
-  ): AsyncGenerator<{ type: 'content' | 'sources'; data: any }> {
-    console.log('=== ChatService.streamChat ===');
-    console.log('User ID:', userId);
-    console.log('User Language:', userLanguage);
-    console.log('Selected Embedding ID:', selectedEmbeddingId);
-    console.log('Selected Groups:', selectedGroups);
-    console.log('Selected Files:', selectedFiles);
-    console.log('History ID:', historyId);
-    console.log('Temperature:', temperature);
-    console.log('Max Tokens:', maxTokens);
-    console.log('Top K:', topK);
-    console.log('Similarity Threshold:', similarityThreshold);
-    console.log('Model Config:', {
-      name: modelConfig.name,
-      modelId: modelConfig.modelId,
-      baseUrl: modelConfig.baseUrl,
-    });
-    console.log('API Key プレフィックス:', modelConfig.apiKey?.substring(0, 10) + '...');
-    console.log('API Key 長さ:', modelConfig.apiKey?.length);
-
-    let currentHistoryId = historyId;
-    let fullResponse = '';
-
-    try {
-      // historyId がない場合は、新しい対話履歴を作成
-      if (!currentHistoryId) {
-        const searchHistory = await this.searchHistoryService.create(
-          userId,
-          message,
-          selectedGroups,
-        );
-        currentHistoryId = searchHistory.id;
-        console.log(SimpleI18nService.getMessage('creatingHistory', userLanguage) + currentHistoryId);
-      }
-
-      // ユーザーメッセージを保存
-      await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
-
-      // 1. ユーザーの埋め込みモデル設定を取得
-      const models = await this.modelConfigService.findAll(userId);
-
-      // ユーザーが選択した埋め込みモデルIDを優先し、そうでない場合は最初のものを使用
-      let embeddingModel;
-      if (selectedEmbeddingId) {
-        embeddingModel = models.find(
-          (m) => m.id === selectedEmbeddingId && m.type === 'embedding' && m.apiKey && m.isEnabled !== false,
-        );
-        console.log(SimpleI18nService.getMessage('searchingModelById', userLanguage) + selectedEmbeddingId);
-      }
-
-      // 見つからない場合は、最初に使用可能な埋め込みモデルに戻る
-      if (!embeddingModel) {
-        console.log(SimpleI18nService.getMessage('searchModelFallback', userLanguage));
-        embeddingModel = models.find(
-          (m) => m.type === 'embedding' && m.apiKey && m.isEnabled !== false,
-        );
-      }
-
-      if (!embeddingModel) {
-        console.log(SimpleI18nService.getMessage('noEmbeddingModelFound', userLanguage));
-        yield { type: 'content', data: SimpleI18nService.getMessage('noEmbeddingModel', userLanguage) };
-        return;
-      }
-
-      console.log(SimpleI18nService.getMessage('usingEmbeddingModel', userLanguage) +
-                  embeddingModel.name + ' ' + embeddingModel.modelId + ', ID:' + embeddingModel.id);
-
-      // 2. ユーザーのクエリを直接使用して検索
-      console.log(SimpleI18nService.getMessage('startingSearch', userLanguage));
-      yield { type: 'content', data: SimpleI18nService.getMessage('searching', userLanguage) + '\n' };
-
-      let searchResults: any[] = [];
-      let context = '';
-
-      try {
-        // 3. 選択された知識グループがある場合、まずそれらのグループ内のファイルIDを取得
-        let effectiveFileIds = selectedFiles; // 优先使用明确指定的文件
-        if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
-          // 从知识组获取文件ID
-          effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId);
-        }
-
-        // 3. RagService を使用して検索 (混合検索 + Rerank をサポート)
-        const ragResults = await this.ragService.searchKnowledge(
-          message,
-          userId,
-          topK !== undefined ? topK : 5, // 使用传递的topK值,默认为5
-          similarityThreshold !== undefined ? similarityThreshold : 0.6, // 使用传递的similarityThreshold值,默认为0.6
-          embeddingModel.id,
-          true, // enableFullTextSearch (Chat defaults to hybrid)
-          enableRerank,
-          selectedRerankId,
-          undefined, // selectedGroups - 现在通过effectiveFileIds参数传递
-          effectiveFileIds  // effectiveFileIds (包括从selectedGroups获取的文件ID)
-        );
-
-        // RagSearchResult を ChatService が必要とする形式 (any[]) に変換
-        // HybridSearch は ES の hit 構造を返しますが、RagSearchResult は正規化されています。
-        // BuildContext は {fileName, content} を期待します。RagSearchResult はこれらを持っています。
-        searchResults = ragResults;
-        console.log(SimpleI18nService.getMessage('searchResultsCount', userLanguage) + searchResults.length);
-
-        // 4. コンテキストの構築
-        context = this.buildContext(searchResults);
-
-        if (searchResults.length === 0) {
-          yield { type: 'content', data: SimpleI18nService.getMessage('noResults', userLanguage) + '\n\n' };
-          yield { type: 'content', data: `[Debug] ${SimpleI18nService.getMessage('searchScope', userLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + SimpleI18nService.getMessage('files', userLanguage) : selectedGroups ? selectedGroups.length + ' ' + SimpleI18nService.getMessage('notebooks', userLanguage) : SimpleI18nService.getMessage('all', userLanguage)}\n` };
-          yield { type: 'content', data: `[Debug] ${SimpleI18nService.getMessage('searchResults', userLanguage)}: 0 ${SimpleI18nService.getMessage('items', userLanguage)}\n` };
-        } else {
-          yield {
-            type: 'content',
-            data: `${searchResults.length} ${SimpleI18nService.getMessage('relevantInfoFound', userLanguage)}。${SimpleI18nService.getMessage('generatingResponse', userLanguage)}...\n\n`,
-          };
-          // 一時的なデバッグ情報
-          const scores = searchResults.map(r => r.score.toFixed(2)).join(', ');
-          const files = [...new Set(searchResults.map(r => r.fileName))].join(', ');
-          yield { type: 'content', data: `> [Debug] ${SimpleI18nService.getMessage('searchHits', userLanguage)}: ${searchResults.length} ${SimpleI18nService.getMessage('items', userLanguage)}\n` };
-          yield { type: 'content', data: `> [Debug] ${SimpleI18nService.getMessage('relevance', userLanguage)}: ${scores}\n` };
-          yield { type: 'content', data: `> [Debug] ${SimpleI18nService.getMessage('sourceFiles', userLanguage)}: ${files}\n\n---\n\n` };
-        }
-      } catch (searchError) {
-        console.error(SimpleI18nService.getMessage('searchFailedLog', userLanguage) + ':', searchError);
-        yield { type: 'content', data: SimpleI18nService.getMessage('searchFailed', userLanguage) + '\n\n' };
-      }
-
-      // 5. ストリーム回答生成
-      this.logger.log(`${SimpleI18nService.getMessage('modelCall', userLanguage)} タイプ: LLM, モデル: ${modelConfig.name} (${modelConfig.modelId}), ユーザー: ${userId}`);
-      const llm = new ChatOpenAI({
-        apiKey: modelConfig.apiKey,
-        streaming: true,
-        temperature: temperature !== undefined ? temperature : 0.3,
-        maxTokens: maxTokens !== undefined ? maxTokens : undefined,
-        modelName: modelConfig.modelId,
-        configuration: {
-          baseURL: modelConfig.baseUrl || 'https://api.openai.com/v1',
-        },
-      });
-
-      const promptTemplate =
-        context.length > 0
-          ? SimpleI18nService.getPrompt(userLanguage, 'withContext')
-          : SimpleI18nService.getPrompt(userLanguage, 'withoutContext');
-
-      const prompt = PromptTemplate.fromTemplate(promptTemplate);
-
-      const chain = prompt.pipe(llm);
-
-      const stream = await chain.stream({
-        context,
-        history: this.formatHistory(history, userLanguage),
-        question: message,
-      });
-
-      for await (const chunk of stream) {
-        if (chunk.content) {
-          fullResponse += chunk.content;
-          yield { type: 'content', data: chunk.content };
-        }
-      }
-
-      // AI 回答を保存
-      await this.searchHistoryService.addMessage(
-        currentHistoryId,
-        'assistant',
-        fullResponse,
-        searchResults.map((result) => ({
-          fileName: result.fileName,
-          content: String(result.content).substring(0, 200) + '...',
-          score: result.score,
-          chunkIndex: result.chunkIndex,
-          fileId: result.fileId,
-        })),
-      );
-
-      // 6. 引用元を返却
-      yield {
-        type: 'sources',
-        data: searchResults.map((result) => ({
-          fileName: result.fileName,
-          content: String(result.content).substring(0, 200) + '...',
-          score: result.score,
-          chunkIndex: result.chunkIndex,
-          fileId: result.fileId,
-        })),
-      };
-    } catch (error) {
-      this.logger.error(SimpleI18nService.getMessage('chatStreamError', userLanguage), error);
-      yield { type: 'content', data: `${SimpleI18nService.getMessage('error', userLanguage)}: ${error.message}` };
-    }
-  }
-
-  async *streamAssist(
-    instruction: string,
-    context: string,
-    modelConfig: ModelConfig,
-  ): AsyncGenerator<{ type: 'content'; data: any }> {
-    try {
-      this.logger.log(`${SimpleI18nService.getMessage('modelCall', 'ja')} タイプ: LLM (Assist), モデル: ${modelConfig.name} (${modelConfig.modelId})`);
-      const llm = new ChatOpenAI({
-        apiKey: modelConfig.apiKey,
-        streaming: true,
-        temperature: 0.7,
-        modelName: modelConfig.modelId,
-        configuration: {
-          baseURL: modelConfig.baseUrl || 'https://api.openai.com/v1',
-        },
-      });
-
-      const systemPrompt = `${SimpleI18nService.getMessage('intelligentAssistant', 'ja')}
-提供されたテキスト内容を、ユーザーの指示に基づいて修正または改善してください。
-挨拶や結びの言葉(「わかりました、こちらが...」など)は含めず、修正後の内容のみを直接出力してください。
-
-コンテキスト(現在の内容):
-${context}
-
-ユーザーの指示:
-${instruction}`;
-
-      const stream = await llm.stream(systemPrompt);
-
-      for await (const chunk of stream) {
-        if (chunk.content) {
-          yield { type: 'content', data: chunk.content };
-        }
-      }
-    } catch (error) {
-      this.logger.error(SimpleI18nService.getMessage('assistStreamError', 'ja'), error);
-      yield { type: 'content', data: `${SimpleI18nService.getMessage('error', 'ja')}: ${error.message}` };
-    }
-  }
-
-  private async hybridSearch(
-    keywords: string[],
-    userId: string,
-    embeddingModelId?: string,
-    selectedGroups?: string[], // 新增参数
-    explicitFileIds?: string[], // 新規パラメータ
-  ): Promise<any[]> {
-    try {
-      // キーワードを検索文字列に結合
-      const combinedQuery = keywords.join(' ');
-      console.log(SimpleI18nService.getMessage('searchString', 'ja') + combinedQuery);
-
-      // 埋め込みモデルIDが提供されているか確認
-      if (!embeddingModelId) {
-        console.log(SimpleI18nService.getMessage('embeddingModelIdNotProvided', 'ja'));
-        return [];
-      }
-
-      // 実際の埋め込みベクトルを使用
-      console.log(SimpleI18nService.getMessage('generatingEmbeddings', 'ja'));
-      const queryEmbedding = await this.embeddingService.getEmbeddings(
-        [combinedQuery],
-        userId,
-        embeddingModelId,
-      );
-      const queryVector = queryEmbedding[0];
-      console.log(`${SimpleI18nService.getMessage('embeddingsGenerated', 'ja')}。${SimpleI18nService.getMessage('dimensions', 'ja')}: ${queryVector.length}`);
-
-      // 混合検索
-      console.log(SimpleI18nService.getMessage('performingHybridSearch', 'ja'));
-      const results = await this.elasticsearchService.hybridSearch(
-        queryVector,
-        combinedQuery,
-        userId,
-        10,
-        0.6,
-        selectedGroups, // 選択されたグループを渡す
-        explicitFileIds, // 明示的なファイルIDを渡す
-      );
-      console.log(`${SimpleI18nService.getMessage('esSearchCompleted', 'ja')}。${SimpleI18nService.getMessage('resultsCount', 'ja')}: ${results.length}`);
-
-      return results.slice(0, 10);
-    } catch (error) {
-      console.error(SimpleI18nService.getMessage('hybridSearchFailed', 'ja') + ':', error);
-      return [];
-    }
-  }
-
-  private buildContext(results: any[]): string {
-    return results
-      .map(
-        (result, index) =>
-          `[${index + 1}] ${SimpleI18nService.getMessage('file', 'ja')}:${result.fileName}\n${SimpleI18nService.getMessage('content', 'ja')}:${result.content}\n`,
-      )
-      .join('\n');
-  }
-
-  private formatHistory(
-    history: ChatMessage[],
-    userLanguage: string = 'ja',
-  ): string {
-    const userLabel = SimpleI18nService.getMessage('userLabel', userLanguage);
-    const assistantLabel = SimpleI18nService.getMessage('assistantLabel', userLanguage);
-
-    return history
-      .slice(-6)
-      .map(
-        (msg) =>
-          `${msg.role === 'user' ? userLabel : assistantLabel}:${msg.content}`,
-      )
-      .join('\n');
-  }
-
-  async getContextForTopic(topic: string, userId: string, groupId?: string, fileIds?: string[]): Promise<string> {
-    try {
-      const models = await this.modelConfigService.findAll(userId);
-      const embeddingModel = models.find(m => m.type === 'embedding' && m.apiKey && m.isEnabled !== false);
-      if (!embeddingModel) return '';
-
-      const results = await this.hybridSearch(
-        [topic],
-        userId,
-        embeddingModel.id,
-        groupId ? [groupId] : undefined,
-        fileIds
-      );
-
-      return this.buildContext(results);
-    } catch (err) {
-      this.logger.error(`${SimpleI18nService.getMessage('getContextForTopicFailed', 'ja')}: ${err.message}`);
-      return '';
-    }
-  }
-
-  async generateSimpleChat(
-    messages: ChatMessage[],
-    userId: string,
-    modelConfig?: ModelConfig, // Optional, looks up if not provided
-  ): Promise<string> {
-    try {
-      let config = modelConfig;
-      if (!config) {
-        // Find default LLM
-        const models = await this.modelConfigService.findAll(userId);
-        // Cast to unknown first to bypass partial mismatch between Entity and Interface
-        const found = models.find(m => m.type === 'llm' && m.isEnabled !== false);
-        if (found) {
-          config = found as unknown as ModelConfig;
-        }
-
-        if (!config) {
-          throw new Error(SimpleI18nService.getMessage('noLLMConfigured', 'ja'));
-        }
-      }
-
-      this.logger.log(`${SimpleI18nService.getMessage('modelCall', 'ja')} タイプ: LLM (Simple), モデル: ${config.name} (${config.modelId}), ユーザー: ${userId}`);
-      const llm = new ChatOpenAI({
-        apiKey: config.apiKey,
-        temperature: 0.7, // 独創的な執筆のために温度を高めに設定
-        modelName: config.modelId,
-        configuration: {
-          baseURL: config.baseUrl || 'https://api.openai.com/v1',
-        },
-      });
-
-      const response = await llm.invoke(
-        messages.map(m => [m.role, m.content])
-      );
-
-      return String(response.content);
-    } catch (error) {
-      this.logger.error(SimpleI18nService.getMessage('simpleChatGenerationError', 'ja'), error);
-      throw error;
-    }
-  }
-}

+ 1 - 0
server/src/common/constants.ts

@@ -7,6 +7,7 @@ export const DEFAULT_CHUNK_SIZE = 200;
 export const MIN_CHUNK_SIZE = 50;
 export const MAX_CHUNK_SIZE = 8191;
 export const DEFAULT_CHUNK_OVERLAP = 40;
+export const MIN_CHUNK_OVERLAP = 25;
 export const DEFAULT_MAX_OVERLAP_RATIO = 0.5;
 
 // ベクトル次元のデフォルト値 (OpenAI Standard)

+ 13 - 0
server/src/common/file-support.constants.ts

@@ -0,0 +1,13 @@
+
+export const DOC_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'csv', 'txt', 'md', 'html', 'json', 'xml', 'odt', 'ods', 'odp'];
+export const CODE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'css', 'py', 'java', 'sql', 'cpp', 'h', 'go', 'rs', 'php', 'rb'];
+export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'];
+export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', 'image/tiff'];
+
+export const ALL_ALLOWED_EXTENSIONS = [...DOC_EXTENSIONS, ...CODE_EXTENSIONS, ...IMAGE_EXTENSIONS];
+
+export const isAllowedByExtension = (filename: string): boolean => {
+    const ext = filename.toLowerCase().split('.').pop();
+    if (!ext) return false;
+    return ALL_ALLOWED_EXTENSIONS.includes(ext);
+};

+ 6 - 23
server/src/defaults.ts

@@ -1,27 +1,11 @@
 // server/src/defaults.ts
 import { AppSettings, ModelConfig, ModelType } from './types'; // Import from local types
 
-export const DEFAULT_MODELS: ModelConfig[] = [
-  {
-    id: 'default-openai-chat',
-    name: 'OpenAI Compatible Chat',
-    modelId: 'gpt-3.5-turbo',
-    baseUrl: 'https://api.openai.com/v1',
-    type: ModelType.LLM,
-    supportsVision: false,
-  },
-  {
-    id: 'default-embedding',
-    name: 'OpenAI Compatible Embedding',
-    modelId: 'text-embedding-ada-002',
-    baseUrl: 'https://api.openai.com/v1',
-    type: ModelType.EMBEDDING,
-  },
-];
+export const DEFAULT_MODELS: ModelConfig[] = [];
 
 export const DEFAULT_SETTINGS: AppSettings = {
-  selectedLLMId: 'default-openai-chat',
-  selectedEmbeddingId: 'default-embedding',
+  selectedLLMId: '',
+  selectedEmbeddingId: '',
   selectedRerankId: '',
 
   temperature: 0.3,
@@ -29,11 +13,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
 
   enableRerank: false,
   topK: 4,
-  scoreThreshold: 0.5,
-
+  similarityThreshold: 0.3,
+  rerankSimilarityThreshold: 0.5,
   enableFullTextSearch: false,
-  enableQueryExpansion: false,
-  enableHyDE: false,
+  hybridVectorWeight: 0.7,
 
   language: 'ja',
 };

+ 22 - 21
server/src/elasticsearch/elasticsearch.service.ts

@@ -1,3 +1,4 @@
+
 import { Injectable, Logger, OnModuleInit, Inject, forwardRef } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Client } from '@elastic/elasticsearch';
@@ -171,7 +172,6 @@ export class ElasticsearchService implements OnModuleInit {
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
         chunkIndex: hit._source?.chunkIndex,
-        pageNumber: hit._source?.pageNumber, // 追加
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
       }));
@@ -220,7 +220,6 @@ export class ElasticsearchService implements OnModuleInit {
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
         chunkIndex: hit._source?.chunkIndex,
-        pageNumber: hit._source?.pageNumber, // 追加
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
       }));
@@ -259,6 +258,11 @@ export class ElasticsearchService implements OnModuleInit {
       fileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId);
     }
 
+    if (fileIds && fileIds.length === 0) {
+      this.logger.log('検索対象ファイルが0件のため、検索をスキップします');
+      return [];
+    }
+
     if (fileIds) {
       this.logger.log(`最終検索対象ファイル範囲: ${fileIds.length} 個のファイル`);
     }
@@ -324,7 +328,6 @@ export class ElasticsearchService implements OnModuleInit {
           fileId: result.fileId,
           fileName: result.fileName,
           chunkIndex: result.chunkIndex,
-          pageNumber: result.pageNumber, // 追加
           startPosition: result.startPosition,
           endPosition: result.endPosition,
         };
@@ -405,16 +408,16 @@ export class ElasticsearchService implements OnModuleInit {
         return [];
       }
 
+      if (fileIds && fileIds.length === 0) {
+        this.logger.log('Filter resulted in 0 files, returning empty results for vector search');
+        return [];
+      }
+
       let filter: any;
-      if (fileIds) {
-        if (fileIds.length > 0) {
-          filter = {
-            terms: { fileId: fileIds },
-          };
-        } else {
-          // ID列表存在但为空,表示强制不匹配任何东西
-          filter = { match_none: {} };
-        }
+      if (fileIds && fileIds.length > 0) {
+        filter = {
+          terms: { fileId: fileIds },
+        };
       } else {
         filter = {}; // No filter when no file IDs specified
       }
@@ -446,7 +449,6 @@ export class ElasticsearchService implements OnModuleInit {
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
         chunkIndex: hit._source?.chunkIndex,
-        pageNumber: hit._source?.pageNumber, // 追加
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
       }));
@@ -478,6 +480,11 @@ export class ElasticsearchService implements OnModuleInit {
         return [];
       }
 
+      if (fileIds && fileIds.length === 0) {
+        this.logger.log('Filter resulted in 0 files, returning empty results for full-text search');
+        return [];
+      }
+
       const mustClause: any[] = [
         {
           match: {
@@ -490,13 +497,8 @@ export class ElasticsearchService implements OnModuleInit {
       ];
 
       const filter: any[] = [];
-      if (fileIds) {
-        if (fileIds.length > 0) {
-          filter.push({ terms: { fileId: fileIds } });
-        } else {
-          // ID列表存在但为空,表示强制不匹配任何东西
-          filter.push({ match_none: {} });
-        }
+      if (fileIds && fileIds.length > 0) {
+        filter.push({ terms: { fileId: fileIds } });
       }
 
       const queryBody: any = {
@@ -525,7 +527,6 @@ export class ElasticsearchService implements OnModuleInit {
         fileId: hit._source?.fileId,
         fileName: hit._source?.fileName,
         chunkIndex: hit._source?.chunkIndex,
-        pageNumber: hit._source?.pageNumber, // 追加
         startPosition: hit._source?.startPosition,
         endPosition: hit._source?.endPosition,
       }));

+ 27 - 0
server/src/i18n/i18n.interceptor.ts

@@ -0,0 +1,27 @@
+import {
+    Injectable,
+    NestInterceptor,
+    ExecutionContext,
+    CallHandler,
+} from '@nestjs/common';
+import { Observable } from 'rxjs';
+import { i18nStore } from './i18n.store';
+import { DEFAULT_LANGUAGE } from '../common/constants';
+
+@Injectable()
+export class I18nInterceptor implements NestInterceptor {
+    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
+        const request = context.switchToHttp().getRequest();
+        const language = request.headers['x-user-language'] || DEFAULT_LANGUAGE;
+
+        return new Observable((observer) => {
+            i18nStore.run({ language: String(language) }, () => {
+                next.handle().subscribe({
+                    next: (value) => observer.next(value),
+                    error: (err) => observer.error(err),
+                    complete: () => observer.complete(),
+                });
+            });
+        });
+    }
+}

+ 14 - 0
server/src/i18n/i18n.middleware.ts

@@ -0,0 +1,14 @@
+import { Injectable, NestMiddleware } from '@nestjs/common';
+import { Request, Response, NextFunction } from 'express';
+import { i18nStore } from './i18n.store';
+import { DEFAULT_LANGUAGE } from '../common/constants';
+
+@Injectable()
+export class I18nMiddleware implements NestMiddleware {
+    use(req: Request, res: Response, next: NextFunction) {
+        const language = req.headers['x-user-language'] || DEFAULT_LANGUAGE;
+        i18nStore.run({ language: String(language) }, () => {
+            next();
+        });
+    }
+}

+ 33 - 23
server/src/i18n/i18n.service.ts

@@ -1,28 +1,35 @@
 import { Injectable } from '@nestjs/common';
 import { errorMessages, logMessages, statusMessages } from './messages';
+import { i18nStore } from './i18n.store';
 
 @Injectable()
 export class I18nService {
   private readonly defaultLanguage = 'ja'; // プロジェクト要件に従い、日本語をデフォルトとして使用
 
+  private getLanguage(lang?: string): string {
+    if (lang) return lang;
+    const store = i18nStore.getStore();
+    return store?.language || this.defaultLanguage;
+  }
+
   getErrorMessage(key: string, language?: string): string {
-    const lang = language || this.defaultLanguage;
+    const lang = this.getLanguage(language);
     return errorMessages[lang]?.[key] || errorMessages[this.defaultLanguage][key] || key;
   }
 
   getLogMessage(key: string, language?: string): string {
-    const lang = language || this.defaultLanguage;
+    const lang = this.getLanguage(language);
     return logMessages[lang]?.[key] || logMessages[this.defaultLanguage][key] || key;
   }
 
   getStatusMessage(key: string, language?: string): string {
-    const lang = language || this.defaultLanguage;
+    const lang = this.getLanguage(language);
     return statusMessages[lang]?.[key] || statusMessages[this.defaultLanguage][key] || key;
   }
 
   // 汎用メッセージ取得メソッド、順次検索
   getMessage(key: string, language?: string): string {
-    const lang = language || this.defaultLanguage;
+    const lang = this.getLanguage(language);
     // ステータスメッセージ、エラーメッセージ、ログメッセージの順に検索
     return statusMessages[lang]?.[key] ||
       statusMessages[this.defaultLanguage][key] ||
@@ -54,13 +61,16 @@ export class I18nService {
 
   // システムプロンプトを取得
   getPrompt(lang: string = this.defaultLanguage, type: 'withContext' | 'withoutContext' = 'withContext', hasKnowledgeGroup: boolean = false): string {
-    if (lang === 'zh') {
+    const language = this.getLanguage(lang);
+    const noMatchMsg = statusMessages[language]?.noMatchInKnowledgeGroup || statusMessages['ja'].noMatchInKnowledgeGroup;
+
+    if (language === 'zh') {
       return type === 'withContext' ? `
 基于以下知识库内容回答用户问题。
-
-${hasKnowledgeGroup ? `**重要提示**: 用户已选择特定知识组,请严格基于以下知识库内容回答。如果知识库中没有相关信息,请明确告知用户"知识库中未找到相关内容,以下是基于模型的一般性回答:",然后再提供答案。
-
-` : ''}知识库内容:
+${hasKnowledgeGroup ? `
+**重要提示**: 用户已选择特定知识组,请严格基于以下知识库内容回答。如果知识库中没有相关信息,请明确告知用户:"${noMatchMsg}",然后再提供答案。
+` : ''}
+知识库内容:
 {context}
 
 历史对话:
@@ -85,23 +95,23 @@ ${hasKnowledgeGroup ? `**重要提示**: 用户已选择特定知识组,请严
      def example():
          return "示例"
      \`\`\`
-   - 支持语言:python, javascript, typescript, java, bash, sql 等
+   - 支持语言:python, javascript, typescript, java, bash, sql 等
 
-4. **流程图与图表**:
-   - 使用 Mermaid 语法展示流程图、时序图等:
+4. **图表与可视化**:
+   - 使用 Mermaid 语法绘制流程图、序列图等:
      \`\`\`mermaid
      graph LR
          A[开始] --> B[处理]
          B --> C[结束]
      \`\`\`
-   - 适用场景:流程说明、架构图、状态图、时序图
+   - 适用场景:流程、架构、状态机、时序图
 
 5. **其他要求**:
-   - 回答要简洁明了,避免冗长
-   - 如果有多个步骤,使用编号列表
-   - 使用表格展示对比信息(如果适用)
+   - 回答精炼准确
+   - 多步骤操作使用有序列表
+   - 对比类信息建议用表格展示(如果适用)
 ` : `
-作为一个智能助手,请回答用户的问题。
+作为智能助手,请回答用户的问题。
 
 历史对话:
 {history}
@@ -110,13 +120,13 @@ ${hasKnowledgeGroup ? `**重要提示**: 用户已选择特定知识组,请严
 
 请用中文回答。
 `;
-    } else if (lang === 'en') {
+    } else if (language === 'en') {
       return type === 'withContext' ? `
 Answer the user's question based on the following knowledge base content.
-
-${hasKnowledgeGroup ? `**IMPORTANT**: The user has selected a specific knowledge group. Please answer strictly based on the knowledge base content below. If there is no relevant information in the knowledge base, please explicitly inform the user "No relevant content found in the selected knowledge group. The following is a general answer based on the model:", and then provide your answer.
-
-` : ''}Knowledge base content:
+${hasKnowledgeGroup ? `
+**IMPORTANT**: The user has selected a specific knowledge group. Please answer strictly based on the knowledge base content below. If the relevant information is not found in the knowledge base, explicitly tell the user: "${noMatchMsg}", before providing an answer.
+` : ''}
+Knowledge Base CONTENT:
 {context}
 
 Conversation history:
@@ -170,7 +180,7 @@ Please answer in English.
       return type === 'withContext' ? `
 以下のナレッジベースの内容に基づいてユーザーの質問に答えてください。
 ${hasKnowledgeGroup ? `
-**重要**: ユーザーが特定の知識グループを選択しました。以下のナレッジベースの内容に厳密に基づいて回答してください。ナレッジベースに関連情報がない場合は、「選択された知識グループに関連する内容が見つかりませんでした。以下はモデルに基づく一般的な回答です:」とユーザーに明示的に伝えてから、回答を提供してください。
+**重要**: ユーザーが特定の知識グループを選択しました。以下のナレッジベースの内容に厳密に基づいて回答してください。ナレッジベースに関連情報がない場合は、「${noMatchMsg}」とユーザーに明示的に伝えてから、回答を提供してください。
 ` : ''}
 ナレッジベースの内容:
 {context}

+ 7 - 0
server/src/i18n/i18n.store.ts

@@ -0,0 +1,7 @@
+import { AsyncLocalStorage } from 'async_hooks';
+
+export interface I18nContext {
+    language: string;
+}
+
+export const i18nStore = new AsyncLocalStorage<I18nContext>();

+ 268 - 0
server/src/i18n/messages.ts

@@ -15,6 +15,7 @@ export const errorMessages = {
     chunkOverflow: '切片大小 {size} 超过上限 {max} ({reason})。已自动调整',
     chunkUnderflow: '切片大小 {size} 小于最小值 {min}。已自动调整',
     overlapOverflow: '重叠大小 {size} 超过上限 {max}。已自动调整',
+    overlapUnderflow: '重叠大小 {size} 小于最小值 {min}。已自动调整',
     overlapRatioExceeded: '重叠大小 {size} 超过切片大小的50% ({max})。已自动调整',
     batchOverflowWarning: '建议切片大小不超过 {safeSize} 以避免批量处理溢出 (当前: {size}, 模型限制的 {percent}%)',
     estimatedChunkCountExcessive: '预计切片数量过多 ({count}),处理可能较慢',
@@ -22,6 +23,40 @@ export const errorMessages = {
     embeddingModelNotFound: '找不到嵌入模型 {id} 或类型不是 embedding',
     ocrFailed: '提取文本失败: {message}',
     noImageUploaded: '未上传图片',
+    adminOnlyViewList: '只有管理员可以查看用户列表',
+    passwordsRequired: '当前密码和新密码不能为空',
+    newPasswordMinLength: '新密码长度不能少于6位',
+    adminOnlyCreateUser: '只有管理员可以创建用户',
+    usernamePasswordRequired: '用户名和密码不能为空',
+    passwordMinLength: '密码长度不能少于6位',
+    adminOnlyUpdateUser: '只有管理员可以更新用户信息',
+    userNotFound: '用户不存在',
+    cannotModifyBuiltinAdmin: '无法修改内置管理员账户',
+    adminOnlyDeleteUser: '只有管理员可以删除用户',
+    cannotDeleteSelf: '不能删除自己的账户',
+    cannotDeleteBuiltinAdmin: '无法删除内置管理员账户',
+    incorrectCredentials: '用户名或密码不正确',
+    incorrectCurrentPassword: '当前密码错误',
+    usernameExists: '用户名已存在',
+    noteNotFound: '找不到笔记: {id}',
+    knowledgeGroupNotFound: '找不到知识组: {id}',
+    accessDeniedNoToken: '访问被拒绝:缺少令牌',
+    invalidToken: '无效的令牌',
+    pdfFileNotFound: '找不到 PDF 文件',
+    pdfFileEmpty: 'PDF 文件为空,转换可能失败',
+    pdfConversionFailed: 'PDF 文件不存在或转换失败',
+    pdfConversionFailedDetail: 'PDF 转换失败(文件 ID: {id}),请稍后重试',
+    pdfPreviewNotSupported: '该文件格式不支持预览',
+    pdfServiceUnavailable: 'PDF 服务不可用: {message}',
+    pageImageNotFound: '找不到页面图像',
+    pdfPageImageFailed: '无法获取 PDF 页面图像',
+    someGroupsNotFound: '部分组不存在',
+    promptRequired: '提示词是必填项',
+    addLLMConfig: '请在系统设置中添加 LLM 模型',
+    visionAnalysisFailed: '视觉分析失败: {message}',
+    retryMechanismError: '重试机制异常',
+    imageLoadError: '无法读取图像: {message}',
+    groupNotFound: '分组不存在',
   },
   ja: {
     noEmbeddingModel: '先にシステム設定で埋め込みモデルを設定してください',
@@ -39,6 +74,7 @@ export const errorMessages = {
     chunkOverflow: 'チャンクサイズ {size} が上限 {max} ({reason}) を超えています。自動調整されました',
     chunkUnderflow: 'チャンクサイズ {size} が最小値 {min} 未満です。自動調整されました',
     overlapOverflow: '重なりサイズ {size} が上限 {max} を超えています。自動調整されました',
+    overlapUnderflow: '重なりサイズ {size} が最小値 {min} 未満です。自動調整されました',
     overlapRatioExceeded: '重なりサイズ {size} がチャンクサイズの50% ({max}) を超えています。自動調整されました',
     batchOverflowWarning: 'バッチ処理のオーバーフローを避けるため、チャンクサイズを {safeSize} 以下にすることをお勧めします (現在: {size}, モデル制限の {percent}%)',
     estimatedChunkCountExcessive: '推定チャンク数が多すぎます ({count})。処理に時間がかかる可能性があります',
@@ -46,6 +82,40 @@ export const errorMessages = {
     embeddingModelNotFound: '埋め込みモデル {id} が見つかりません、またはタイプが embedding ではありません',
     ocrFailed: 'テキストの抽出に失敗しました: {message}',
     noImageUploaded: '画像がアップロードされていません',
+    adminOnlyViewList: '管理者のみがユーザーリストを表示できます',
+    passwordsRequired: '現在のパスワードと新しいパスワードは必須です',
+    newPasswordMinLength: '新しいパスワードは少なくとも6文字以上である必要があります',
+    adminOnlyCreateUser: '管理者のみがユーザーを作成できます',
+    usernamePasswordRequired: 'ユーザー名とパスワードは必須です',
+    passwordMinLength: 'パスワードは少なくとも6文字以上である必要があります',
+    adminOnlyUpdateUser: '管理者のみがユーザー情報を更新できます',
+    userNotFound: 'ユーザーが見つかりません',
+    cannotModifyBuiltinAdmin: 'ビルトイン管理者アカウントを変更できません',
+    adminOnlyDeleteUser: '管理者のみがユーザーを削除できます',
+    cannotDeleteSelf: '自分自身のアカウントを削除できません',
+    cannotDeleteBuiltinAdmin: 'ビルトイン管理者アカウントを削除できません',
+    incorrectCredentials: 'ユーザー名またはパスワードが間違っています',
+    incorrectCurrentPassword: '現在のパスワードが間違っています',
+    usernameExists: 'ユーザー名が既に存在します',
+    noteNotFound: 'ノートが見つかりません: {id}',
+    knowledgeGroupNotFound: 'ナレッジグループが見つかりません: {id}',
+    accessDeniedNoToken: 'アクセス不許可:トークンがありません',
+    invalidToken: '無効なトークンです',
+    pdfFileNotFound: 'PDF ファイルが見つかりません',
+    pdfFileEmpty: 'PDF ファイルが空です。変換に失敗した可能性があります',
+    pdfConversionFailed: 'PDF ファイルが存在しないか、変換に失敗しました',
+    pdfConversionFailedDetail: 'PDF 変換に失敗しました(ファイル ID: {id})。後でもう一度お試しください',
+    pdfPreviewNotSupported: 'このファイル形式はプレビューをサポートしていません',
+    pdfServiceUnavailable: 'PDF サービスを利用できません: {message}',
+    pageImageNotFound: 'ページ画像が見つかりません',
+    pdfPageImageFailed: 'PDF ページの画像を取得できませんでした',
+    someGroupsNotFound: '一部のグループが存在しません',
+    promptRequired: 'プロンプトは必須です',
+    addLLMConfig: 'システム設定で LLM モデルを追加してください',
+    visionAnalysisFailed: 'ビジョン分析に失敗しました: {message}',
+    retryMechanismError: '再試行メカニズムの異常',
+    imageLoadError: '画像を読み込めません: {message}',
+    groupNotFound: 'グループが存在しません',
   },
   en: {
     noEmbeddingModel: 'Please configure embedding model in system settings first',
@@ -63,6 +133,7 @@ export const errorMessages = {
     chunkOverflow: 'Chunk size {size} exceeds limit {max} ({reason}). Auto-adjusted',
     chunkUnderflow: 'Chunk size {size} is below minimum {min}. Auto-adjusted',
     overlapOverflow: 'Overlap size {size} exceeds limit {max}. Auto-adjusted',
+    overlapUnderflow: 'Overlap size {size} is below minimum {min}. Auto-adjusted',
     overlapRatioExceeded: 'Overlap size {size} exceeds 50% of chunk size ({max}). Auto-adjusted',
     batchOverflowWarning: 'Recommended chunk size below {safeSize} to avoid batch overflow (Current: {size}, {percent}% of model limit)',
     estimatedChunkCountExcessive: 'Estimated chunk count is too high ({count}). Processing may be slow',
@@ -70,6 +141,41 @@ export const errorMessages = {
     embeddingModelNotFound: 'Embedding model {id} not found or type is not embedding',
     ocrFailed: 'Failed to extract text: {message}',
     noImageUploaded: 'No image uploaded',
+    adminOnlyViewList: 'Only admins can view the user list',
+    passwordsRequired: 'Current and new passwords are required',
+    newPasswordMinLength: 'New password must be at least 6 characters',
+    adminOnlyCreateUser: 'Only admins can create users',
+    usernamePasswordRequired: 'Username and password are required',
+    passwordMinLength: 'Password must be at least 6 characters',
+    adminOnlyUpdateUser: 'Only admins can update user info',
+    userNotFound: 'User not found',
+    cannotModifyBuiltinAdmin: 'Cannot modify built-in admin account',
+    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',
+    incorrectCredentials: 'Incorrect username or password',
+    incorrectCurrentPassword: 'Incorrect current password',
+    usernameExists: 'Username already exists',
+    noteNotFound: 'Note with ID {id} not found',
+    knowledgeGroupNotFound: 'Knowledge group with ID {id} not found',
+    accessDeniedNoToken: 'Access Denied: Missing token',
+    invalidToken: 'Invalid token',
+    pdfFileNotFound: 'PDF file not found',
+    pdfFileEmpty: 'PDF file is empty. Conversion may have failed',
+    pdfConversionFailed: 'PDF file does not exist or conversion failed',
+    pdfConversionFailedDetail: 'PDF conversion failed for file ID: {id}. Please try again later.',
+    pdfPreviewNotSupported: 'Preview is not supported for this file format',
+    pdfServiceUnavailable: 'PDF service unavailable: {message}',
+    pageImageNotFound: 'Page image not found',
+    pdfPageImageFailed: 'Could not retrieve PDF page image',
+    someGroupsNotFound: 'Some groups not found',
+    promptRequired: 'Prompt is required',
+    addLLMConfig: 'Please add LLM model in system settings',
+    visionAnalysisFailed: 'Vision analysis failed: {message}',
+    retryMechanismError: 'Retry mechanism error',
+    imageLoadError: 'Cannot load image: {message}',
+    groupNotFound: 'Group not found',
   }
 };
 
@@ -88,6 +194,16 @@ export const logMessages = {
     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})',
@@ -103,6 +219,16 @@ export const logMessages = {
     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: 'サーバー内部エラー',
   },
   en: {
     processingFile: 'Processing file: {name} ({size})',
@@ -118,6 +244,16 @@ export const logMessages = {
     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',
   }
 };
 
@@ -168,6 +304,50 @@ export const statusMessages = {
     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: '未找到相关文档。',
   },
   ja: {
     searching: 'ナレッジベースを検索中...',
@@ -214,6 +394,50 @@ export const statusMessages = {
     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: '✅ 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: '関連するドキュメントが見つかりませんでした。',
   },
   en: {
     searching: 'Searching knowledge base...',
@@ -260,5 +484,49 @@ export const statusMessages = {
     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.',
   }
 };

+ 14 - 4
server/src/knowledge-base/chunk-config.service.ts

@@ -15,6 +15,7 @@ import {
   DEFAULT_CHUNK_SIZE,
   MIN_CHUNK_SIZE,
   DEFAULT_CHUNK_OVERLAP,
+  MIN_CHUNK_OVERLAP,
   DEFAULT_MAX_OVERLAP_RATIO,
   DEFAULT_MAX_BATCH_SIZE,
   DEFAULT_VECTOR_DIMENSIONS
@@ -30,6 +31,7 @@ export class ChunkConfigService {
     chunkSize: DEFAULT_CHUNK_SIZE,
     chunkOverlap: DEFAULT_CHUNK_OVERLAP,
     minChunkSize: MIN_CHUNK_SIZE,
+    minChunkOverlap: MIN_CHUNK_OVERLAP,
     maxOverlapRatio: DEFAULT_MAX_OVERLAP_RATIO,  // 重なりはチャンクサイズの50%まで
     maxBatchSize: DEFAULT_MAX_BATCH_SIZE,    // デフォルトのバッチ制限
     expectedDimensions: DEFAULT_VECTOR_DIMENSIONS, // デフォルトのベクトル次元
@@ -49,7 +51,7 @@ export class ChunkConfigService {
       this.configService.get<string>('MAX_CHUNK_SIZE', '8191')
     );
     this.envMaxOverlapSize = parseInt(
-      this.configService.get<string>('MAX_OVERLAP_SIZE', '200')
+      this.configService.get<string>('MAX_OVERLAP_SIZE', '2000')
     );
 
     this.logger.log(
@@ -181,8 +183,14 @@ export class ChunkConfigService {
       chunkOverlap = maxOverlapByRatio;
     }
 
-    if (chunkOverlap < 0) {
-      chunkOverlap = 0;
+    if (chunkOverlap < this.DEFAULTS.minChunkOverlap) {
+      warnings.push(
+        this.i18nService.formatMessage('overlapUnderflow', {
+          size: chunkOverlap,
+          min: this.DEFAULTS.minChunkOverlap
+        })
+      );
+      chunkOverlap = this.DEFAULTS.minChunkOverlap;
     }
 
     // 6. バッチ処理の安全チェックを追加
@@ -313,6 +321,7 @@ export class ChunkConfigService {
   ): Promise<{
     maxChunkSize: number;
     maxOverlapSize: number;
+    minOverlapSize: number;
     defaultChunkSize: number;
     defaultOverlapSize: number;
     modelInfo: {
@@ -338,8 +347,9 @@ export class ChunkConfigService {
     return {
       maxChunkSize,
       maxOverlapSize,
+      minOverlapSize: this.DEFAULTS.minChunkOverlap,
       defaultChunkSize: Math.min(this.DEFAULTS.chunkSize, maxChunkSize),
-      defaultOverlapSize: Math.min(this.DEFAULTS.chunkOverlap, maxOverlapSize),
+      defaultOverlapSize: Math.max(this.DEFAULTS.minChunkOverlap, Math.min(this.DEFAULTS.chunkOverlap, maxOverlapSize)),
       modelInfo: {
         name: modelName,
         maxInputTokens: limits.maxInputTokens,

+ 1 - 3
server/src/knowledge-base/embedding.service.ts

@@ -48,9 +48,7 @@ export class EmbeddingService {
       throw new Error(`モデル ${modelConfig.name} は無効化されているため、埋め込みベクトルを生成できません`);
     }
 
-    if (!modelConfig.apiKey) {
-      throw new Error(`モデル ${modelConfig.name} に API キーが設定されていません`);
-    }
+    // APIキーはオプションです - ローカルモデルを許可します
 
     if (!modelConfig.baseUrl) {
       throw new Error(`モデル ${modelConfig.name} に baseUrl が設定されていません`);

+ 20 - 17
server/src/knowledge-base/knowledge-base.controller.ts

@@ -22,6 +22,7 @@ import { Public } from '../auth/public.decorator';
 import { KnowledgeBase } from './knowledge-base.entity';
 import { ChunkConfigService } from './chunk-config.service';
 import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
+import { I18nService } from '../i18n/i18n.service';
 
 @Controller('knowledge-bases')
 @UseGuards(JwtAuthGuard)
@@ -32,6 +33,7 @@ export class KnowledgeBaseController {
     private readonly knowledgeBaseService: KnowledgeBaseService,
     private readonly chunkConfigService: ChunkConfigService,
     private readonly knowledgeGroupService: KnowledgeGroupService,
+    private readonly i18nService: I18nService,
   ) { }
 
   @Get()
@@ -44,7 +46,7 @@ export class KnowledgeBaseController {
   @UseGuards(AdminGuard)  // Only admin can clear all knowledge base
   async clearAll(@Request() req): Promise<{ message: string }> {
     await this.knowledgeBaseService.clearAll(req.user.id);
-    return { message: 'ナレッジベースが空になりました' };
+    return { message: this.i18nService.getMessage('kbCleared') };
   }
 
   @Post('search')
@@ -77,7 +79,7 @@ export class KnowledgeBaseController {
     @Param('id') fileId: string,
   ): Promise<{ message: string }> {
     await this.knowledgeBaseService.deleteFile(fileId, req.user.id);
-    return { message: 'ファイルが削除されました' };
+    return { message: this.i18nService.getMessage('fileDeleted') };
   }
 
   @Post(':id/retry')
@@ -112,11 +114,12 @@ export class KnowledgeBaseController {
     if (!embeddingModelId) {
       return {
         maxChunkSize: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
-        maxOverlapSize: parseInt(process.env.MAX_OVERLAP_SIZE || '200'),
+        maxOverlapSize: parseInt(process.env.MAX_OVERLAP_SIZE || '2000'),
+        minOverlapSize: 25,
         defaultChunkSize: 200,
         defaultOverlapSize: 40,
         modelInfo: {
-          name: 'モデル未選択',
+          name: this.i18nService.getMessage('modelNotConfigured'),
           maxInputTokens: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
           maxBatchSize: 2048,
           expectedDimensions: parseInt(process.env.DEFAULT_VECTOR_DIMENSIONS || '2560'),
@@ -130,7 +133,7 @@ export class KnowledgeBaseController {
     );
   }
 
-  // ファイルグループ管理 - 添加管理员权限
+  // ファイルグループ管理 - 管理者権限を追加
   @Post(':id/groups')
   @UseGuards(AdminGuard)  // Only admin can add files to groups
   async addFileToGroups(
@@ -143,7 +146,7 @@ export class KnowledgeBaseController {
       body.groupIds,
       req.user.id,
     );
-    return { message: 'ファイルグループが更新されました' };
+    return { message: this.i18nService.getMessage('groupSyncSuccess') };
   }
 
   @Delete(':id/groups/:groupId')
@@ -158,7 +161,7 @@ export class KnowledgeBaseController {
       groupId,
       req.user.id,
     );
-    return { message: 'ファイルがグループから削除されました' };
+    return { message: this.i18nService.getMessage('fileDeletedFromGroup') };
   }
 
   // PDF プレビュー - 公開アクセス
@@ -171,7 +174,7 @@ export class KnowledgeBaseController {
   ) {
     try {
       if (!token) {
-        throw new NotFoundException('アクセス不許可:トークンがありません');
+        throw new NotFoundException(this.i18nService.getMessage('accessDeniedNoToken'));
       }
 
       const jwt = await import('jsonwebtoken');
@@ -184,11 +187,11 @@ export class KnowledgeBaseController {
       try {
         decoded = jwt.verify(token, secret) as any;
       } catch {
-        throw new NotFoundException('無効なトークンです');
+        throw new NotFoundException(this.i18nService.getMessage('invalidToken'));
       }
 
       if (decoded.type !== 'pdf-access' || decoded.fileId !== fileId) {
-        throw new NotFoundException('無効なトークンです');
+        throw new NotFoundException(this.i18nService.getMessage('invalidToken'));
       }
 
       const pdfPath = await this.knowledgeBaseService.ensurePDFExists(
@@ -200,7 +203,7 @@ export class KnowledgeBaseController {
       const path = await import('path');
 
       if (!fs.existsSync(pdfPath)) {
-        throw new NotFoundException('PDF ファイルが見つかりません');
+        throw new NotFoundException(this.i18nService.getMessage('pdfFileNotFound'));
       }
 
       const stat = fs.statSync(pdfPath);
@@ -211,7 +214,7 @@ export class KnowledgeBaseController {
         try {
           fs.unlinkSync(pdfPath); // 空のファイルを削除
         } catch (e) { }
-        throw new NotFoundException('PDF ファイルが空です。変換に失敗した可能性があります');
+        throw new NotFoundException(this.i18nService.getMessage('pdfFileEmpty'));
       }
 
       res.setHeader('Content-Type', 'application/pdf');
@@ -224,7 +227,7 @@ export class KnowledgeBaseController {
         throw error;
       }
       this.logger.error(`PDF preview error: ${error.message}`);
-      throw new NotFoundException('PDF ファイルが存在しないか、変換に失敗しました');
+      throw new NotFoundException(this.i18nService.getMessage('pdfConversionFailed'));
     }
   }
 
@@ -259,9 +262,9 @@ export class KnowledgeBaseController {
       };
     } catch (error) {
       if (error.message.includes('LibreOffice')) {
-        throw new Error(`PDF サービスを利用できません: ${error.message}`);
+        throw new InternalServerErrorException(this.i18nService.formatMessage('pdfServiceUnavailable', { message: error.message }));
       }
-      throw error;
+      throw new InternalServerErrorException(error.message);
     }
   }
 
@@ -292,13 +295,13 @@ export class KnowledgeBaseController {
 
       const fs = await import('fs');
       if (!fs.existsSync(imagePath)) {
-        throw new NotFoundException('ページ画像が見つかりません');
+        throw new NotFoundException(this.i18nService.getMessage('pageImageNotFound'));
       }
 
       res.sendFile(path.resolve(imagePath));
     } catch (error) {
       this.logger.error(`PDF ページの画像取得に失敗しました: ${error.message}`);
-      throw new NotFoundException('PDF ページの画像を取得できませんでした');
+      throw new NotFoundException(this.i18nService.getMessage('pdfPageImageFailed'));
     }
   }
 }

+ 0 - 3
server/src/knowledge-base/knowledge-base.entity.ts

@@ -26,9 +26,6 @@ export class KnowledgeBase {
   @PrimaryGeneratedColumn('uuid')
   id: string;
 
-  @Column({ nullable: true })
-  title: string;
-
   @Column({ name: 'original_name' })
   originalName: string;
 

+ 1 - 3
server/src/knowledge-base/knowledge-base.module.ts

@@ -17,7 +17,6 @@ import { LibreOfficeModule } from '../libreoffice/libreoffice.module';
 import { Pdf2ImageModule } from '../pdf2image/pdf2image.module';
 import { VisionPipelineModule } from '../vision-pipeline/vision-pipeline.module';
 import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
-import { ChatModule } from '../chat/chat.module';
 
 @Module({
   imports: [
@@ -30,9 +29,8 @@ import { ChatModule } from '../chat/chat.module';
     UserSettingModule,
     LibreOfficeModule,
     Pdf2ImageModule,
-    forwardRef(() => VisionPipelineModule),
+    VisionPipelineModule,
     forwardRef(() => KnowledgeGroupModule),
-    forwardRef(() => ChatModule),
   ],
   controllers: [KnowledgeBaseController],
   providers: [

+ 71 - 114
server/src/knowledge-base/knowledge-base.service.ts

@@ -1,5 +1,6 @@
 import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
-import { ChatService } from '../chat/chat.service';
+import { ConfigService } from '@nestjs/config';
+import { I18nService } from '../i18n/i18n.service';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { FileStatus, KnowledgeBase, ProcessingMode } from './knowledge-base.entity';
@@ -18,6 +19,7 @@ import { ChunkConfigService } from './chunk-config.service';
 import { VisionPipelineService } from '../vision-pipeline/vision-pipeline.service';
 import { LibreOfficeService } from '../libreoffice/libreoffice.service';
 import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
+import { DOC_EXTENSIONS, IMAGE_EXTENSIONS } from '../common/file-support.constants';
 
 @Injectable()
 export class KnowledgeBaseService {
@@ -40,8 +42,8 @@ export class KnowledgeBaseService {
     private visionPipelineService: VisionPipelineService,
     private libreOfficeService: LibreOfficeService,
     private pdf2ImageService: Pdf2ImageService,
-    @Inject(forwardRef(() => ChatService))
-    private chatService: ChatService,
+    private configService: ConfigService,
+    private i18nService: I18nService,
   ) { }
 
   async createAndIndex(
@@ -139,7 +141,7 @@ export class KnowledgeBaseService {
       };
     } catch (error) {
       this.logger.error(
-        `Failed to search knowledge base for user ${userId}`,
+        this.i18nService.formatMessage('searchMetadataFailed', { userId }),
         error,
       );
       throw error;
@@ -155,17 +157,15 @@ export class KnowledgeBaseService {
       const ragResults = await this.ragService.searchKnowledge(
         query,
         userId,
-        settings.topK || 5,
-        settings.similarityThreshold || 0.7,
+        settings.topK,
+        settings.similarityThreshold,
         settings.selectedEmbeddingId,
-        settings.enableFullTextSearch || false,
-        settings.enableRerank || false,
+        settings.enableFullTextSearch,
+        settings.enableRerank,
         settings.selectedRerankId,
         undefined,
         undefined,
-        settings.enableQueryExpansion || false,
-        settings.enableHyDE || false,
-        settings.scoreThreshold || 0.5,
+        settings.rerankSimilarityThreshold,
       );
 
       const sources = this.ragService.extractSources(ragResults);
@@ -210,7 +210,7 @@ export class KnowledgeBaseService {
         where: { id: fileId },
       });
       if (!file) {
-        throw new NotFoundException('File not found');
+        throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
       }
 
       // 2. Delete file from filesystem
@@ -305,11 +305,6 @@ export class KnowledgeBaseService {
         await this.processFastMode(kb, userId, config);
       }
 
-      // タイトルを自動生成(非同期)
-      this.generateTitle(kbId, userId).catch((err) => {
-        this.logger.error(`Failed to generate title for ${kbId}`, err);
-      });
-
       this.logger.log(`File ${kbId} processed successfully in ${mode} mode.`);
     } catch (error) {
       this.logger.error(`Failed to process file ${kbId}`, error);
@@ -328,28 +323,28 @@ export class KnowledgeBaseService {
     if (this.visionService.isImageFile(kb.mimetype)) {
       const visionModelId = await this.userSettingService.getVisionModelId(userId);
       if (visionModelId) {
-        const visionModel = await this.modelConfigService.findOne(visionModelId, userId);
-        if (visionModel && visionModel.supportsVision) {
-          text = await this.visionService.extractImageContent(
-            kb.storagePath,
-            {
-              baseUrl: visionModel.baseUrl || '',
-              apiKey: visionModel.apiKey || '',
-              modelId: visionModel.modelId,
-            },
-          );
+        const visionModel = await this.modelConfigService.findOne(
+          visionModelId,
+          userId,
+        );
+        if (visionModel && visionModel.type === 'vision' && visionModel.isEnabled !== false) {
+          text = await this.visionService.extractImageContent(kb.storagePath, {
+            baseUrl: visionModel.baseUrl || '',
+            apiKey: visionModel.apiKey || '',
+            modelId: visionModel.modelId,
+          });
         }
       }
     }
 
     if (!text || text.trim().length === 0) {
-      this.logger.warn(`No text extracted from file ${kb.id}`);
+      this.logger.warn(this.i18nService.getMessage('noTextExtracted'));
     }
 
     // テキストサイズを確認
     const textSizeMB = Math.round(text.length / 1024 / 1024);
     if (textSizeMB > 50) {
-      this.logger.warn(`抽出されたテキストが大きいです: ${textSizeMB}MB`);
+      this.logger.warn(this.i18nService.formatMessage('extractedTextTooLarge', { size: textSizeMB }));
     }
 
     // テキストをデータベースに保存
@@ -363,7 +358,7 @@ export class KnowledgeBaseService {
 
     // 非同期的に PDF 変換をトリガー(ドキュメントファイルの場合)
     this.ensurePDFExists(kb.id, userId).catch((err) => {
-      this.logger.warn(`Initial PDF conversion failed for ${kb.id}`, err);
+      this.logger.warn(this.i18nService.formatMessage('pdfConversionFailedDetail', { id: kb.id }), err);
     });
   }
 
@@ -377,7 +372,7 @@ export class KnowledgeBaseService {
 
     if (!preciseFormats.includes(ext)) {
       this.logger.warn(
-        `ファイル形式 ${ext} は精密モードをサポートしていません。高速モードにフォールバックします`
+        this.i18nService.formatMessage('preciseModeUnsupported', { ext })
       );
       return this.processFastMode(kb, userId, config);
     }
@@ -386,15 +381,18 @@ export class KnowledgeBaseService {
     const visionModelId = await this.userSettingService.getVisionModelId(userId);
     if (!visionModelId) {
       this.logger.warn(
-        `ビジョンモデルが設定されていません。精密モードを使用できないため、高速モードにフォールバックします`
+        this.i18nService.getMessage('visionModelNotConfiguredFallback')
       );
       return this.processFastMode(kb, userId, config);
     }
 
-    const visionModel = await this.modelConfigService.findOne(visionModelId, userId);
-    if (!visionModel || !visionModel.supportsVision || !visionModel.apiKey) {
+    const visionModel = await this.modelConfigService.findOne(
+      visionModelId,
+      userId,
+    );
+    if (!visionModel || visionModel.type !== 'vision' || visionModel.isEnabled === false) {
       this.logger.warn(
-        `ビジョンモデルの設定が無効です。高速モードにフォールバックします`
+        this.i18nService.getMessage('visionModelInvalidFallback')
       );
       return this.processFastMode(kb, userId, config);
     }
@@ -414,6 +412,7 @@ export class KnowledgeBaseService {
 
       if (!result.success) {
         this.logger.error(`Vision pipeline failed, falling back to fast mode`);
+        this.logger.warn(this.i18nService.getMessage('visionPipelineFailed'));
         return this.processFastMode(kb, userId, config);
       }
 
@@ -438,7 +437,7 @@ export class KnowledgeBaseService {
 
       await this.updateStatus(kb.id, FileStatus.EXTRACTED);
       this.logger.log(
-        `精密モード内容抽出完了: ${result.processedPages}ページ, コスト: $${result.cost.toFixed(2)}`
+        this.i18nService.formatMessage('preciseModeComplete', { pages: result.processedPages, cost: result.cost.toFixed(2) })
       );
 
       // 非同期でベクトル化し、Elasticsearch にインデックス
@@ -494,7 +493,7 @@ export class KnowledgeBaseService {
           const embedding = embeddings[j];
 
           if (!embedding || embedding.length === 0) {
-            this.logger.warn(`空ベクトルのページ ${result.pageIndex} をスキップします`);
+            this.logger.warn(this.i18nService.formatMessage('skippingEmptyVectorPage', { page: result.pageIndex }));
             continue;
           }
 
@@ -543,7 +542,7 @@ export class KnowledgeBaseService {
     // 対応するページ番号の画像を見つける
     const pageImage = result.images.find(img => img.pageIndex === pageIndex + 1);
     if (!pageImage) {
-      throw new NotFoundException(`PDF の第 ${pageIndex + 1} ページの画像を取得できません`);
+      throw new NotFoundException(this.i18nService.formatMessage('pageImageNotFoundDetail', { page: pageIndex + 1 }));
     }
 
     return pageImage.path;
@@ -578,7 +577,7 @@ export class KnowledgeBaseService {
       // 設定が修正された場合、警告を記録しデータベースを更新
       if (validatedConfig.warnings.length > 0) {
         this.logger.warn(
-          `チャンク設定の修正: ${validatedConfig.warnings.join(', ')}`
+          this.i18nService.formatMessage('chunkConfigCorrection', { warnings: validatedConfig.warnings.join(', ') })
         );
 
         // データベース内の設定を更新
@@ -611,7 +610,7 @@ export class KnowledgeBaseService {
       this.logger.log(`ファイル ${kbId} から ${chunks.length} 個のテキストブロックを分割しました`);
 
       if (chunks.length === 0) {
-        this.logger.warn(`ファイル ${kbId} からテキストチャンクが生成されませんでした`);
+        this.logger.warn(this.i18nService.formatMessage('noChunksGenerated', { id: kbId }));
         await this.updateStatus(kbId, FileStatus.VECTORIZED);
         return;
       }
@@ -623,7 +622,7 @@ export class KnowledgeBaseService {
       );
       if (chunks.length > estimatedChunkCount * 1.2) {
         this.logger.warn(
-          `実際のチャンク数 ${chunks.length} が推定値 ${estimatedChunkCount} を大幅に超えています。異常がある可能性があります`
+          this.i18nService.formatMessage('chunkCountAnomaly', { actual: chunks.length, estimated: estimatedChunkCount })
         );
       }
 
@@ -662,7 +661,7 @@ export class KnowledgeBaseService {
               // バッチサイズがモデルの制限を超えていないか検証
               if (batch.length > recommendedBatchSize) {
                 this.logger.warn(
-                  `バッチ ${batchIndex} のサイズ ${batch.length} が推奨値 ${recommendedBatchSize} を超えています。分割して処理します`
+                  this.i18nService.formatMessage('batchSizeExceeded', { index: batchIndex, actual: batch.length, limit: recommendedBatchSize })
                 );
               }
 
@@ -686,7 +685,7 @@ export class KnowledgeBaseService {
                 const embedding = embeddings[i];
 
                 if (!embedding || embedding.length === 0) {
-                  this.logger.warn(`空ベクトルのテキストブロック ${chunk.index} をスキップします`);
+                  this.logger.warn(this.i18nService.formatMessage('skippingEmptyVectorChunk', { index: chunk.index }));
                   continue;
                 }
 
@@ -721,9 +720,7 @@ export class KnowledgeBaseService {
         } catch (error) {
           // コンテキスト長エラーを検出(日本語・中国語・英語に対応)
           if (error.message && (error.message.includes('context length') || error.message.includes('コンテキスト長が上限を超えています') || error.message.includes('コンテキスト長が上限を超えています'))) {
-            this.logger.warn(
-              `バッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします`
-            );
+            this.logger.warn(this.i18nService.getMessage('contextLengthErrorFallback'));
 
             // 単一テキスト処理にダウングレード
             for (let i = 0; i < chunks.length; i++) {
@@ -737,7 +734,7 @@ export class KnowledgeBaseService {
                 );
 
                 if (!embeddings[0] || embeddings[0].length === 0) {
-                  this.logger.warn(`空ベクトルのテキストブロック ${chunk.index} をスキップします`);
+                  this.logger.warn(this.i18nService.formatMessage('skippingEmptyVectorChunk', { index: chunk.index }));
                   continue;
                 }
 
@@ -780,7 +777,7 @@ export class KnowledgeBaseService {
         // チャンク数がモデルのバッチ制限を超える場合は、強制的にバッチ処理
         if (chunks.length > recommendedBatchSize) {
           this.logger.warn(
-            `チャンク数 ${chunks.length} がモデルのバッチ制限 ${recommendedBatchSize} を超えています。強制的にバッチ処理を行います`
+            this.i18nService.formatMessage('chunkLimitExceededForceBatch', { actual: chunks.length, limit: recommendedBatchSize })
           );
           try {
             await this.processInBatches(
@@ -822,9 +819,7 @@ export class KnowledgeBaseService {
           } catch (error) {
             // コンテキスト長エラーを検出(日本語・中国語・英語に対応)
             if (error.message && (error.message.includes('context length') || error.message.includes('コンテキスト長が上限を超えています') || error.message.includes('コンテキスト長が上限を超えています'))) {
-              this.logger.warn(
-                `小ファイルバッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします`
-              );
+              this.logger.warn(this.i18nService.getMessage('batchContextLengthErrorFallback'));
 
               // 単一テキスト処理にダウングレード
               for (let i = 0; i < chunks.length; i++) {
@@ -838,7 +833,7 @@ export class KnowledgeBaseService {
                   );
 
                   if (!embeddings[0] || embeddings[0].length === 0) {
-                    this.logger.warn(`空ベクトルのテキストブロック ${chunk.index} をスキップします`);
+                    this.logger.warn(this.i18nService.formatMessage('skippingEmptyVectorChunk', { index: chunk.index }));
                     continue;
                   }
 
@@ -862,13 +857,13 @@ export class KnowledgeBaseService {
                   }
                 } catch (chunkError) {
                   this.logger.error(
-                    `テキストブロック ${chunk.index} の処理に失敗しました。スキップします: ${chunkError.message}`
+                    this.i18nService.formatMessage('chunkProcessingFailed', { index: chunk.index, message: chunkError.message })
                   );
                   continue;
                 }
               }
 
-              this.logger.log(`単一テキスト処理完了: ${chunks.length} チャンク`);
+              this.logger.log(this.i18nService.formatMessage('singleTextProcessingComplete', { count: chunks.length }));
             } else {
               // その他のエラー、直接スロー
               throw error;
@@ -888,7 +883,7 @@ export class KnowledgeBaseService {
               const embedding = embeddings[i];
 
               if (!embedding || embedding.length === 0) {
-                this.logger.warn(`空ベクトルのテキストブロック ${chunk.index} をスキップします`);
+                this.logger.warn(this.i18nService.formatMessage('skippingEmptyVectorChunk', { index: chunk.index }));
                 continue;
               }
 
@@ -910,9 +905,7 @@ export class KnowledgeBaseService {
           } catch (error) {
             // コンテキスト長エラーを検出(日本語・中国語・英語に対応)
             if (error.message && (error.message.includes('context length') || error.message.includes('コンテキスト長が上限を超えています') || error.message.includes('コンテキスト長が上限を超えています'))) {
-              this.logger.warn(
-                `小ファイルバッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします`
-              );
+              this.logger.warn(this.i18nService.getMessage('batchContextLengthErrorFallback'));
 
               // 単一テキスト処理にダウングレード
               for (let i = 0; i < chunks.length; i++) {
@@ -956,7 +949,7 @@ export class KnowledgeBaseService {
                 }
               }
 
-              this.logger.log(`単一テキスト処理完了: ${chunks.length} チャンク`);
+              this.logger.log(this.i18nService.formatMessage('singleTextProcessingComplete', { count: chunks.length }));
             } else {
               // その他のエラー、直接スロー
               throw error;
@@ -968,10 +961,10 @@ export class KnowledgeBaseService {
       await this.updateStatus(kbId, FileStatus.VECTORIZED);
       const memAfter = this.memoryMonitor.getMemoryUsage();
       this.logger.log(
-        `ファイル ${kbId} ベクトル化完了。${chunks.length} 個のテキストブロックを処理しました。最終メモリ: ${memAfter.heapUsed}MB`,
+        this.i18nService.formatMessage('fileVectorizationComplete', { id: kbId, count: chunks.length, memory: memAfter.heapUsed })
       );
     } catch (error) {
-      this.logger.error(`ファイル ${kbId} ベクトル化失敗`, error);
+      this.logger.error(this.i18nService.formatMessage('fileVectorizationFailed', { id: kbId }), error);
 
       // エラー情報を metadata に保存
       try {
@@ -1005,7 +998,7 @@ export class KnowledgeBaseService {
     if (totalItems === 0) return;
 
     const startTime = Date.now();
-    this.logger.log(`バッチ処理を開始します: ${totalItems} アイテム`);
+    this.logger.log(this.i18nService.formatMessage('batchProcessingStarted', { count: totalItems }));
 
     // Use provided batch size or fallback to env/default
     const initialBatchSize = options?.batchSize || parseInt(process.env.CHUNK_BATCH_SIZE || '100');
@@ -1029,7 +1022,7 @@ export class KnowledgeBaseService {
       const batchIndex = Math.floor(i / batchSize) + 1;
 
       this.logger.log(
-        `バッチ ${batchIndex}/${totalBatches} を処理中: ${batch.length} 個のアイテム`,
+        this.i18nService.formatMessage('batchProcessingProgress', { index: batchIndex, total: totalBatches, count: batch.length })
       );
 
       // バッチを処理
@@ -1052,7 +1045,7 @@ export class KnowledgeBaseService {
     }
 
     const duration = ((Date.now() - startTime) / 1000).toFixed(2);
-    this.logger.log(`バッチ処理完了: ${totalItems} アイテム, 所要時間 ${duration}s`);
+    this.logger.log(this.i18nService.formatMessage('batchProcessingComplete', { count: totalItems, duration }));
   }
 
   /**
@@ -1071,11 +1064,11 @@ export class KnowledgeBaseService {
     }
 
     if (kb.status !== FileStatus.FAILED) {
-      throw new Error(`ファイルステータスが ${kb.status} です。失敗したファイルのみ再試行可能です`);
+      throw new Error(this.i18nService.formatMessage('onlyFailedFilesRetryable', { status: kb.status }));
     }
 
     if (!kb.content || kb.content.trim().length === 0) {
-      throw new Error('ファイル内容が空です。再試行できません。ファイルを再アップロードしてください。');
+      throw new Error(this.i18nService.getMessage('emptyFileRetryFailed'));
     }
 
     // 2. ステータスを INDEXING にリセット
@@ -1142,51 +1135,6 @@ export class KnowledgeBaseService {
     await this.kbRepository.update(id, { status });
   }
 
-  async generateTitle(kbId: string, userId: string): Promise<string | null> {
-    try {
-      const kb = await this.kbRepository.findOne({ where: { id: kbId } });
-      if (!kb || !kb.content || kb.content.trim().length === 0) return null;
-
-      const contentSample = kb.content.substring(0, 3000);
-      const userLanguage = await this.userSettingService.getLanguage(userId);
-
-      const languageMap: Record<string, string> = {
-        'ja': 'Japanese',
-        'en': 'English',
-        'zh': 'Simplified Chinese',
-      };
-      const targetLanguage = languageMap[userLanguage] || userLanguage;
-
-      const prompt = `You are a document analyzer. Read the provided text and generate a concise, professional title (max 50 chars). 
-DO NOT include prefixes like "Title:" or quotes.
-Return ONLY the title text.
-IMPORTANT: Generate the title in ${targetLanguage}.
-Text: ${contentSample}`;
-
-      const title = await this.chatService.generateSimpleChat(
-        [{ role: 'user', content: prompt }],
-        userId,
-        undefined,
-        0.3, // Use lower temperature
-      );
-
-      let cleanTitle = title.trim()
-        .replace(/^["']|["']$/g, '')
-        .replace(/^(Title|Document Title):\s*/i, '');
-
-      if (cleanTitle.length > 50) {
-        cleanTitle = cleanTitle.substring(0, 50);
-      }
-
-      await this.kbRepository.update(kbId, { title: cleanTitle });
-      this.logger.log(`Generated title for KB ${kbId}: ${cleanTitle}`);
-      return cleanTitle;
-    } catch (err) {
-      this.logger.error(`Failed to generate title for KB ${kbId}`, err);
-      return null;
-    }
-  }
-
   // PDF プレビュー関連メソッド
   async ensurePDFExists(fileId: string, userId: string, force: boolean = false): Promise<string> {
     const kb = await this.kbRepository.findOne({
@@ -1194,7 +1142,7 @@ Text: ${contentSample}`;
     });
 
     if (!kb) {
-      throw new NotFoundException('ファイルが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
     }
 
     // 元ファイルが PDF の場合は、元ファイルのパスを直接返す
@@ -1202,6 +1150,15 @@ Text: ${contentSample}`;
       return kb.storagePath;
     }
 
+    // プレビュー変換に対応しているか確認(ドキュメント類または画像類のみ許可)
+    const ext = kb.originalName.toLowerCase().split('.').pop() || '';
+    const isConvertible = [...DOC_EXTENSIONS, ...IMAGE_EXTENSIONS].includes(ext);
+
+    if (!isConvertible) {
+      this.logger.log(`Skipping PDF conversion for unsupported format: .${ext} (${kb.originalName})`);
+      throw new Error(this.i18nService.getMessage('pdfPreviewNotSupported'));
+    }
+
     // PDF フィールドパスを生成
     const path = await import('path');
     const fs = await import('fs');
@@ -1251,7 +1208,7 @@ Text: ${contentSample}`;
       return pdfPath;
     } catch (error) {
       this.logger.error(`PDF conversion failed for ${fileId}: ${error.message}`, error.stack);
-      throw new Error(`PDF変換失敗: ${error.message}`);
+      throw new Error(this.i18nService.formatMessage('pdfConversionFailedDetail', { id: fileId }));
     }
   }
 
@@ -1261,7 +1218,7 @@ Text: ${contentSample}`;
     });
 
     if (!kb) {
-      throw new NotFoundException('ファイルが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
     }
 
     // 元ファイルが PDF の場合

+ 0 - 6
server/src/knowledge-group/knowledge-group.entity.ts

@@ -23,12 +23,6 @@ export class KnowledgeGroup {
   @Column({ default: '#3B82F6' })
   color: string;
 
-  @Column({ nullable: true, type: 'text' })
-  intro: string;
-
-  @Column({ nullable: true, type: 'text' })
-  context: string;
-
   // Removed userId field to make groups globally accessible
 
   @CreateDateColumn({ name: 'created_at' })

+ 9 - 9
server/src/knowledge-group/knowledge-group.service.ts

@@ -1,4 +1,5 @@
 import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
+import { I18nService } from '../i18n/i18n.service';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { KnowledgeGroup } from './knowledge-group.entity';
@@ -9,14 +10,12 @@ import { NoteService } from '../note/note.service';
 export interface CreateGroupDto {
   name: string;
   description?: string;
-  intro?: string;
   color?: string;
 }
 
 export interface UpdateGroupDto {
   name?: string;
   description?: string;
-  intro?: string;
   color?: string;
 }
 
@@ -39,6 +38,7 @@ export class KnowledgeGroupService {
     @Inject(forwardRef(() => KnowledgeBaseService))
     private knowledgeBaseService: KnowledgeBaseService,
     private noteService: NoteService,
+    private i18nService: I18nService,
   ) { }
 
   async findAll(userId: string): Promise<GroupWithFileCount[]> {
@@ -69,7 +69,7 @@ export class KnowledgeGroupService {
     });
 
     if (!group) {
-      throw new NotFoundException('グループが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
     return group;
@@ -92,7 +92,7 @@ export class KnowledgeGroupService {
     });
 
     if (!group) {
-      throw new NotFoundException('グループが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
     Object.assign(group, updateGroupDto);
@@ -106,7 +106,7 @@ export class KnowledgeGroupService {
     });
 
     if (!group) {
-      throw new NotFoundException('グループが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
     // Find all files associated with this group (without user restriction)
@@ -158,7 +158,7 @@ export class KnowledgeGroupService {
     });
 
     if (!group) {
-      throw new NotFoundException('グループが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
     return group.knowledgeBases;
@@ -171,14 +171,14 @@ export class KnowledgeGroupService {
     });
 
     if (!file) {
-      throw new NotFoundException('ファイルが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
     }
 
     // Load all groups by ID without user restriction
     const groups = await this.groupRepository.findByIds(groupIds);
 
     if (groups.length !== groupIds.length) {
-      throw new NotFoundException('一部のグループが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('someGroupsNotFound'));
     }
 
     file.groups = groups;
@@ -192,7 +192,7 @@ export class KnowledgeGroupService {
     });
 
     if (!file) {
-      throw new NotFoundException('ファイルが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
     }
 
     file.groups = file.groups.filter(group => group.id !== groupId);

+ 20 - 0
server/src/migrations/1739260000000-RemoveSupportsVisionColumn.ts

@@ -0,0 +1,20 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class RemoveSupportsVisionColumn1739260000000 implements MigrationInterface {
+  name = 'RemoveSupportsVisionColumn1739260000000';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    // Remove supportsVision column from model_configs table
+    // This column is no longer needed as we now use ModelType.VISION instead
+    await queryRunner.query(`
+      ALTER TABLE "model_configs" DROP COLUMN "supportsVision"
+    `);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    // Restore supportsVision column in case of rollback
+    await queryRunner.query(`
+      ALTER TABLE "model_configs" ADD COLUMN "supportsVision" boolean NOT NULL DEFAULT 0
+    `);
+  }
+}

+ 1 - 6
server/src/model-config/dto/create-model-config.dto.ts

@@ -27,18 +27,13 @@ export class CreateModelConfigDto {
   baseUrl?: string;
 
   @IsString()
-  @MinLength(1, { message: 'API Key cannot be empty if provided' })
   @IsOptional()
-  apiKey?: string;
+  apiKey?: string; // APIキーはオプションです - ローカルモデルを許可します
 
   @IsEnum(ModelType)
   @IsNotEmpty()
   type: ModelType;
 
-  @IsBoolean()
-  @IsOptional()
-  supportsVision?: boolean;
-
   @IsNumber()
   @Min(1, { message: 'ベクトル次元の最小値は 1 です' })
   @Max(4096, { message: 'ベクトル次元の最大値は 4096 です(Elasticsearch の制限)' })

+ 0 - 1
server/src/model-config/dto/model-config-response.dto.ts

@@ -13,7 +13,6 @@ export class ModelConfigResponseDto {
   apiKey?: string;
 
   type: string;
-  supportsVision: boolean;
   isEnabled?: boolean;
   isDefault?: boolean;
   userId: string;

+ 0 - 3
server/src/model-config/model-config.entity.ts

@@ -30,9 +30,6 @@ export class ModelConfig {
   @Column({ type: 'text' })
   type: string; // ModelType enum values
 
-  @Column({ type: 'boolean', default: false })
-  supportsVision: boolean;
-
   @Column({ type: 'integer', nullable: true })
   dimensions?: number; // 埋め込みモデルの次元、システムによって自動的に検出され保存されます
 

+ 4 - 4
server/src/note/note.controller.ts

@@ -32,12 +32,12 @@ export class NoteController {
         @Req() req,
         @Query('groupId') groupId?: string,
     ) {
-        return this.noteService.findAll(req.user.id, groupId);
+        return this.noteService.findAll(req.user.id, req.user.isAdmin, groupId);
     }
 
     @Get(':id')
     findOne(@Req() req, @Param('id') id: string) {
-        return this.noteService.findOne(req.user.id, id);
+        return this.noteService.findOne(req.user.id, id, req.user.isAdmin);
     }
 
     @Patch(':id')
@@ -46,12 +46,12 @@ export class NoteController {
         @Param('id') id: string,
         @Body() updateNoteDto: Partial<Note>,
     ) {
-        return this.noteService.update(req.user.id, id, updateNoteDto);
+        return this.noteService.update(req.user.id, id, updateNoteDto, req.user.isAdmin);
     }
 
     @Delete(':id')
     remove(@Req() req, @Param('id') id: string) {
-        return this.noteService.remove(req.user.id, id);
+        return this.noteService.remove(req.user.id, id, req.user.isAdmin);
     }
 
     @Post('from-pdf-selection')

+ 43 - 16
server/src/note/note.service.ts

@@ -7,6 +7,7 @@ import { OcrService } from '../ocr/ocr.service';
 import * as fs from 'fs/promises';
 import * as path from 'path';
 import { v4 as uuidv4 } from 'uuid';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class NoteService {
@@ -16,6 +17,7 @@ export class NoteService {
         @InjectRepository(Note)
         private readonly noteRepository: Repository<Note>,
         private readonly ocrService: OcrService,
+        private readonly i18nService: I18nService,
     ) {
         // Ensure screenshots directory exists
         this.ensureScreenshotsDir();
@@ -37,30 +39,49 @@ export class NoteService {
         return this.noteRepository.save(note);
     }
 
-    async findAll(userId: string, groupId?: string): Promise<Note[]> {
+    async findAll(userId: string, isAdmin: boolean, groupId?: string): Promise<Note[]> {
         const query = this.noteRepository.createQueryBuilder('note')
-            .where('note.userId = :userId', { userId })
+            .leftJoinAndSelect('note.user', 'user')
+            .select(['note', 'user.id', 'user.username'])
             .orderBy('note.updatedAt', 'DESC');
 
+        if (!isAdmin) {
+            query.where('note.userId = :userId', { userId });
+        }
+
         if (groupId) {
-            query.andWhere('note.groupId = :groupId', { groupId });
+            if (!isAdmin) {
+                query.andWhere('note.groupId = :groupId', { groupId });
+            } else {
+                query.where('note.groupId = :groupId', { groupId });
+            }
         }
 
         return query.getMany();
     }
 
-    async findOne(userId: string, id: string): Promise<Note> {
-        const note = await this.noteRepository.findOne({
-            where: { id, userId }
-        });
+    async findOne(userId: string, id: string, isAdmin: boolean): Promise<Note> {
+        let note;
+        if (isAdmin) {
+            note = await this.noteRepository.findOne({
+                where: { id },
+                relations: ['user']
+            });
+        } else {
+            note = await this.noteRepository.findOne({
+                where: { id, userId },
+                relations: ['user']
+            });
+        }
+
         if (!note) {
-            throw new NotFoundException(`Note with ID ${id} not found`);
+            throw new NotFoundException(this.i18nService.formatMessage('noteNotFound', { id }));
         }
         return note;
     }
 
-    async update(userId: string, id: string, data: Partial<Note>): Promise<Note> {
-        const note = await this.findOne(userId, id);
+    async update(userId: string, id: string, data: Partial<Note>, isAdmin: boolean): Promise<Note> {
+        const note = await this.findOne(userId, id, isAdmin);
         // Remove protected fields
         delete data.id;
         delete data.userId;
@@ -87,7 +108,7 @@ export class NoteService {
             });
 
             if (!group) {
-                throw new NotFoundException(`Knowledge group with ID ${groupId} not found`);
+                throw new NotFoundException(this.i18nService.formatMessage('knowledgeGroupNotFound', { id: groupId }));
             }
 
             // Optional: Add logging to help debug permission issues
@@ -112,8 +133,8 @@ export class NoteService {
         const note = this.noteRepository.create({
             userId,
             groupId,
-            title: `PDF Note - ${new Date().toLocaleString()}`,
-            content: extractedText || 'No text extracted',
+            title: this.i18nService.formatMessage('pdfNoteTitle', { date: new Date().toLocaleString() }),
+            content: extractedText || this.i18nService.getMessage('noTextExtracted'),
             screenshotPath: `notes-screenshots/${filename}`,
             sourceFileId: fileId,
             sourcePageNumber: pageNumber,
@@ -122,10 +143,16 @@ export class NoteService {
         return this.noteRepository.save(note);
     }
 
-    async remove(userId: string, id: string): Promise<void> {
-        const result = await this.noteRepository.delete({ id, userId });
+    async remove(userId: string, id: string, isAdmin: boolean): Promise<void> {
+        let result;
+        if (isAdmin) {
+            result = await this.noteRepository.delete({ id });
+        } else {
+            result = await this.noteRepository.delete({ id, userId });
+        }
+
         if (result.affected === 0) {
-            throw new NotFoundException(`Note with ID ${id} not found`);
+            throw new NotFoundException(this.i18nService.formatMessage('noteNotFound', { id }));
         }
     }
 }

+ 1 - 3
server/src/rag/rag.module.ts

@@ -4,15 +4,13 @@ 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 { RerankService } from './rerank.service';
 
-import { ChatModule } from '../chat/chat.module';
+import { RerankService } from './rerank.service';
 
 @Module({
   imports: [
     forwardRef(() => ElasticsearchModule),
     ModelConfigModule,
-    forwardRef(() => ChatModule),
     UserSettingModule,
   ],
   providers: [RagService, EmbeddingService, RerankService],

+ 106 - 202
server/src/rag/rag.service.ts

@@ -4,22 +4,20 @@ import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
 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';
 
 export interface RagSearchResult {
   content: string;
   fileName: string;
   score: number;
   chunkIndex: number;
-  pageNumber?: number; // 追加
   fileId?: string;
   originalScore?: number; // Rerank前のスコア(デバッグ用)
 }
 
 
 
-import { UserSettingService } from '../user-setting/user-setting.service';
-import { ChatService } from '../chat/chat.service';
-
 @Injectable()
 export class RagService {
   private readonly logger = new Logger(RagService.name);
@@ -32,8 +30,7 @@ export class RagService {
     private modelConfigService: ModelConfigService,
     private rerankService: RerankService,
     private configService: ConfigService,
-    @Inject(forwardRef(() => ChatService))
-    private chatService: ChatService,
+    private i18nService: I18nService,
     private userSettingService: UserSettingService,
   ) {
     this.defaultDimensions = parseInt(
@@ -42,246 +39,147 @@ export class RagService {
     this.logger.log(`RAG サービスのデフォルトベクトル次元数: ${this.defaultDimensions}`);
   }
 
-  /**
-   * クエリ拡張: LLMを使用して、検索クエリのバリエーションを生成します
-   */
-  private async expandQuery(query: string, userId: string): Promise<string[]> {
-    try {
-      this.logger.log(`Expanding query: ${query}`);
-      const userLanguage = await this.userSettingService.getLanguage(userId);
-      const languageMap: Record<string, string> = {
-        'ja': 'Japanese',
-        'en': 'English',
-        'zh': 'Simplified Chinese',
-      };
-      const targetLanguage = languageMap[userLanguage] || userLanguage;
-
-      const prompt = `You are a search expert. Generate 3 different search query variations for the user's question to improve search coverage.
-Keep the original intent but use different keywords or perspectives.
-Output one query per line. Do not include any explanations.
-IMPORTANT: Generate queries in ${targetLanguage}.
-
-Original Query: ${query}`;
-
-      const response = await this.chatService.generateSimpleChat(
-        [{ role: 'user', content: prompt }],
-        userId,
-      );
-
-      const variations = response
-        .split('\n')
-        .map((q) => q.trim().replace(/^[\d-]+\.\s*/, '')) // Remove list markers (1., -, etc.)
-        .filter((q) => q.length > 0 && q !== query)
-        .slice(0, 3);
-
-      this.logger.log(`Generated query variations: ${variations.join(' | ')}`);
-      return variations;
-    } catch (error) {
-      this.logger.error('Failed to expand query:', error);
-      return [];
-    }
-  }
-
-  /**
-   * HyDE: ユーザーの質問に対して、LLMを使用して「仮定の(もっともらしい)回答」を生成します
-   * この「回答」のベクトルを使用することで、検索精度が向上する場合があります。
-   */
-  private async generateHyDE(query: string, userId: string): Promise<string> {
-    try {
-      this.logger.log(`Generating HyDE document for query: ${query}`);
-      const userLanguage = await this.userSettingService.getLanguage(userId);
-      const languageMap: Record<string, string> = {
-        'ja': 'Japanese',
-        'en': 'English',
-        'zh': 'Simplified Chinese',
-      };
-      const targetLanguage = languageMap[userLanguage] || userLanguage;
-
-      const prompt = `You are a document generation AI. Generate a plausible hypothetical document (or paragraph) that answers the user's question.
-This document will be used for embedding search, so it should contain relevant terminology and context.
-It does not need to be factually accurate, but it must be relevant.
-Output ONLY the document content.
-IMPORTANT: Generate content in ${targetLanguage}.
-
-Question: ${query}`;
-
-      const hypotheticalDoc = await this.chatService.generateSimpleChat(
-        [{ role: 'user', content: prompt }],
-        userId,
-      );
-
-      this.logger.log(`Generated HyDE document: ${hypotheticalDoc.substring(0, 100)}...`);
-      return hypotheticalDoc;
-    } catch (error) {
-      this.logger.error('Failed to generate HyDE document:', error);
-      return '';
-    }
-  }
-
   async searchKnowledge(
     query: string,
     userId: string,
     topK: number = 5,
-    similarityThreshold: number = 0.3,
+    vectorSimilarityThreshold: number = 0.3, // ベクトル検索のしきい値
     embeddingModelId?: string,
     enableFullTextSearch: boolean = false,
     enableRerank: boolean = false,
     rerankModelId?: string,
     selectedGroups?: string[],
     effectiveFileIds?: string[],
-    enableQueryExpansion: boolean = false,
-    enableHyDE: boolean = false,
-    scoreThreshold: number = 0.5,
+    rerankSimilarityThreshold: number = 0.5, // Rerankのしきい値(デフォルト0.5)
   ): Promise<RagSearchResult[]> {
+    // 1. グローバル設定の取得
+    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;
+
     this.logger.log(
-      `RAG search: query="${query}", topK=${topK}, threshold=${similarityThreshold}, expansion=${enableQueryExpansion}, hyde=${enableHyDE}`,
+      `RAG search: query="${query}", topK=${effectiveTopK}, vectorThreshold=${effectiveVectorThreshold}, rerankThreshold=${effectiveRerankThreshold}, hybridWeight=${effectiveHybridWeight}`,
     );
 
     try {
-      if (!embeddingModelId) {
+      // 1. クエリベクトルの取得
+      if (!effectiveEmbeddingId) {
         throw new Error('埋め込みモデルIDが提供されていません');
       }
 
-      // 1. クエリの準備(拡張またはHyDE)
-      let searchQueries = [query];
-
-      if (enableQueryExpansion) {
-        const variations = await this.expandQuery(query, userId);
-        searchQueries = [...searchQueries, ...variations];
-      }
-
-      if (enableHyDE) {
-        const hydeDoc = await this.generateHyDE(query, userId);
-        if (hydeDoc) {
-          searchQueries.push(hydeDoc);
-        }
-      }
-
-      // 2. ベクトルの取得(並列処理)
-      this.logger.log(`Generating embeddings for ${searchQueries.length} queries...`);
-      const queryEmbeddings = await this.embeddingService.getEmbeddings(
-        searchQueries,
+      const queryEmbedding = await this.embeddingService.getEmbeddings(
+        [query],
         userId,
-        embeddingModelId,
+        effectiveEmbeddingId,
       );
-
-      // 3. 各クエリで検索を実行
-      const allSearchResults: any[] = [];
-
-      for (let i = 0; i < searchQueries.length; i++) {
-        const queryVector = queryEmbeddings[i];
-        const currentQuery = searchQueries[i];
-
-        let results;
-        if (enableFullTextSearch) {
-          results = await this.elasticsearchService.hybridSearch(
-            queryVector,
-            currentQuery,
-            userId,
-            topK * 2,
-            0.7,
-            undefined,
-            effectiveFileIds
-          );
+      const queryVector = queryEmbedding[0];
+
+      this.logger.log(`使用するベクトル次元数: ${queryVector?.length}`);
+
+      // 2. 設定に基づいた検索戦略の選択
+      let searchResults;
+      if (effectiveEnableFullText) {
+        // ハイブリッド検索
+        // 重要: ここでの 0.7 はハイブリッドの重み。閾値フィルタリングは後で「生のスコア」に対して行う。
+        // ElasticsearchService.hybridSearch は内部でベクトル検索と全文検索それぞれのスコアを持つ
+        searchResults = await this.elasticsearchService.hybridSearch(
+          queryVector,
+          query,
+          userId,
+          effectiveTopK * 4, // Rerankのために少し多めに取得
+          effectiveHybridWeight, // vectorWeight
+          undefined,
+          effectiveFileIds
+        );
+      } else {
+        // ベクトル検索のみ
+        let vectorSearchResults = await this.elasticsearchService.searchSimilar(
+          queryVector,
+          userId,
+          effectiveTopK * 4 // Rerankのために少し多めに取得
+        );
+
+        // フィルタリング
+        if (effectiveFileIds && effectiveFileIds.length > 0) {
+          searchResults = vectorSearchResults.filter(r => effectiveFileIds.includes(r.fileId));
         } else {
-          results = await this.elasticsearchService.searchSimilar(
-            queryVector,
-            userId,
-            topK * 2
-          );
-
-          if (effectiveFileIds) {
-            results = results.filter(r => effectiveFileIds.includes(r.fileId));
-          }
+          searchResults = vectorSearchResults;
         }
-        allSearchResults.push(...results);
       }
 
-      // 4. 結果の重複排除とマージ
-      // scoreが最も高いものを保持
-      const mergedResultsMap = new Map<string, any>();
-      for (const res of allSearchResults) {
-        const key = `${res.fileId || res.fileName}_${res.chunkIndex}`;
-        if (!mergedResultsMap.has(key) || res.score > mergedResultsMap.get(key).score) {
-          mergedResultsMap.set(key, res);
-        }
-      }
+      // 初回の類似度フィルタリング
+      // 修正: ハイブリッド検索の場合、各要素の raw スコア(加重計算前)でチェックするのが理想だが、
+      // 現状の hybridSearch は combinedScore を .score に入れている。
+      // ただし、もし vectorWeight=0.7 の場合、相似度 0.4 のものは 0.28 になり、0.3 閾値で消えてしまう。
+      // これを避けるため、閾値チェックを「加重計算の影響を考慮した値」または「加重計算前」に行う必要がある。
+      // ここでは、ユーザーの期待に合わせるため、フィルタリングロジックを微調整する。
 
-      let searchResults = Array.from(mergedResultsMap.values())
-        .sort((a, b) => b.score - a.score);
+      const initialCount = searchResults.length;
 
-      // 5. リランク (Rerank) または類似度フィルタリング
-      let finalResults = searchResults;
-      let usedRerank = false;
+      // ログ出力
+      searchResults.forEach((r, idx) => {
+        this.logger.log(`Hit ${idx}: score=${r.score.toFixed(4)}, fileName=${r.fileName}`);
+      });
 
-      this.logger.log(`Merged search results: ${searchResults.length} items. Top score: ${searchResults[0]?.score}`);
-      if (searchResults.length > 0) {
-        this.logger.debug(`Top 3 initial results: ${JSON.stringify(searchResults.slice(0, 3).map(r => ({ fileName: r.fileName, score: r.score })), null, 2)}`);
-      }
+      // 閾値フィルタリングを適用
+      searchResults = searchResults.filter(r => r.score >= effectiveVectorThreshold);
+      this.logger.log(`Initial hits: ${initialCount} -> filtered by vectorThreshold: ${searchResults.length}`);
 
-      if (enableRerank && rerankModelId) {
-        try {
-          // Rerank モード:より多くの候補を取得し、リランクを実行
-          // Rerank が有効な場合、既に step 2 で topK * 2 の結果を取得しており、ここで最終的な順序を決定します
-          // 実際には、Rerank の効果を高めるために、さらに多くの(例:topK * 10)を取得することを推奨します(ただし、トークン制限に注意)
+      // 3. リランク (Rerank)
+      let finalResults = searchResults;
 
-          // ドキュメントリストの準備
+      if (effectiveEnableRerank && effectiveRerankId && searchResults.length > 0) {
+        try {
           const docs = searchResults.map(r => r.content);
-
-          // リランクを実行
           const rerankedIndices = await this.rerankService.rerank(
             query,
             docs,
             userId,
-            rerankModelId,
-            topK
+            effectiveRerankId,
+            effectiveTopK * 2 // 少し多めに残す
           );
 
-          // リランク結果に基づいてリストを再構築
           finalResults = rerankedIndices.map(r => {
             const originalItem = searchResults[r.index];
             return {
               ...originalItem,
-              score: r.score, // Rerank スコアを使用
-              originalScore: originalItem.score // 元のスコアを保存
+              score: r.score, // Rerank スコア
+              originalScore: originalItem.score // 元のスコア
             };
           });
 
-          this.logger.log(`Rerank completed. Top score: ${finalResults[0]?.score}`);
-          this.logger.debug(`Top 3 after rerank: ${JSON.stringify(finalResults.slice(0, 3).map(r => ({ fileName: r.fileName, score: r.score })), null, 2)}`);
+          // Rerank後のフィルタリング
+          const beforeRerankFilter = finalResults.length;
+          finalResults = finalResults.filter(r => r.score >= effectiveRerankThreshold);
+          this.logger.log(`After rerank: ${beforeRerankFilter} -> filtered by rerankThreshold: ${finalResults.length}`);
 
-          usedRerank = true;
         } catch (error) {
-          this.logger.warn(`Rerank failed, falling back to vector search: ${error.message}`);
-          usedRerank = false;
+          this.logger.warn(`Rerank failed, falling back to filtered vector search: ${error.message}`);
+          // 失敗した場合はベクトル検索の結果をそのまま使う
         }
       }
 
-      // 最終結果をフィルタリングと切り詰め(Rerankの有無に関わらず適用)
-      const beforeFilterCount = finalResults.length;
-      const effectiveThreshold = usedRerank ? scoreThreshold : similarityThreshold;
-
-      finalResults = finalResults
-        .filter((result) => result.score >= effectiveThreshold)
-        .slice(0, topK);
+      // 最終的な件数制限
+      finalResults = finalResults.slice(0, effectiveTopK);
 
-      this.logger.log(
-        `Results after filtering (threshold ${effectiveThreshold}, usedRerank=${usedRerank}): ${finalResults.length} / ${beforeFilterCount} items`,
-      );
       // 4. RAG 結果形式に変換
       const ragResults: RagSearchResult[] = finalResults.map((result) => ({
         content: result.content,
         fileName: result.fileName,
         score: result.score,
-        originalScore: result.originalScore || result.score, // originalScoreがない場合はscoreを使用
+        originalScore: result.originalScore !== undefined ? result.originalScore : result.score,
         chunkIndex: result.chunkIndex,
-        pageNumber: result.pageNumber, // 追加
         fileId: result.fileId,
       }));
 
-      this.logger.log(
-        `Found ${ragResults.length} relevant chunks above threshold ${similarityThreshold}`,
-      );
       return ragResults;
     } catch (error) {
       this.logger.error('RAG search failed:', error);
@@ -294,10 +192,12 @@ Question: ${query}`;
     searchResults: RagSearchResult[],
     language: string = 'ja',
   ): string {
+    const lang = language || 'ja';
+
     // コンテキストの構築
     let context = '';
     if (searchResults.length === 0) {
-      context = '関連するドキュメントが見つかりませんでした。';
+      context = this.i18nService.getMessage('ragNoDocumentFound', lang);
     } else {
       // ファイルごとにグループ化
       const fileGroups = new Map<string, RagSearchResult[]>();
@@ -311,10 +211,13 @@ Question: ${query}`;
       // コンテキスト文字列を構築
       const contextParts: string[] = [];
       fileGroups.forEach((chunks, fileName) => {
-        contextParts.push(`### ソース:${fileName}`);
+        contextParts.push(this.i18nService.formatMessage('ragSource', { fileName }, lang));
         chunks.forEach((chunk, index) => {
           contextParts.push(
-            `セグメント ${index + 1} (類似度: ${chunk.score.toFixed(3)}):`,
+            this.i18nService.formatMessage('ragSegment', {
+              index: index + 1,
+              score: chunk.score.toFixed(3)
+            }, lang),
           );
           contextParts.push(chunk.content);
           contextParts.push('');
@@ -324,24 +227,25 @@ Question: ${query}`;
     }
 
     const langText =
-      language === 'zh' ? '中文' : language === 'en' ? 'English' : '日本語';
+      lang === 'zh' ? '中文' : lang === 'en' ? 'English' : '日本語';
+
+    const systemPrompt = this.i18nService.getMessage('ragSystemPrompt', lang);
+    const rules = this.i18nService.formatMessage('ragRules', { lang: langText }, lang);
+    const docContentHeader = this.i18nService.getMessage('ragDocumentContent', lang);
+    const userQuestionHeader = this.i18nService.getMessage('ragUserQuestion', lang);
+    const answerHeader = this.i18nService.getMessage('ragAnswer', lang);
 
-    return `あなたは専門的なナレッジベースアシスタントです。以下の提供されたドキュメントの内容に基づいて、ユーザーの質問に答えてください。
+    return `${systemPrompt}
 
-## ルール:
-1. 提供されたドキュメントの内容のみに基づいて回答し、情報を捏造しないでください。
-2. ドキュメントに関連情報がない場合は、その旨をユーザーに伝えてください。
-3. 回答には情報源を明記してください。形式:[ファイル名.拡張子]
-4. 複数のドキュメントで情報が矛盾している場合は、総合的に分析するか、異なる視点を説明してください。
-5. ${langText}で回答してください。
+${rules}
 
-## ドキュメント内容:
+${docContentHeader}
 ${context}
 
-## ユーザーの質問:
+${userQuestionHeader}
 ${query}
 
-## 回答:`;
+${answerHeader}`;
   }
 
   extractSources(searchResults: RagSearchResult[]): string[] {

+ 0 - 162
server/src/scripts/import-course.ts

@@ -1,162 +0,0 @@
-import { NestFactory } from '@nestjs/core';
-import { AppModule } from '../app.module';
-import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
-import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
-import { ModelConfigService } from '../model-config/model-config.service';
-import { UserService } from '../user/user.service';
-import * as fs from 'fs';
-import * as path from 'path';
-import { Logger } from '@nestjs/common';
-
-// External directory to import
-const SOURCE_DIR = 'D:/src/dir/generative-ai-for-beginners/translations/zh';
-
-async function bootstrap() {
-  const logger = new Logger('ImportCourseScript');
-
-  logger.log('Bootstrapping NestJS context...');
-  const app = await NestFactory.createApplicationContext(AppModule);
-
-  try {
-    const groupService = app.get(KnowledgeGroupService);
-    const kbService = app.get(KnowledgeBaseService);
-    const modelConfigService = app.get(ModelConfigService);
-    const userService = app.get(UserService);
-
-    // 1. Get User
-    const users = await userService.findAll();
-    if (users.length === 0) {
-      logger.error('No users found in the system. Please create a user first.');
-      process.exit(1);
-    }
-    const user = users.find(u => u.username === 'admin') || users[0];
-    logger.log(`Using user: ${user.username} (ID: ${user.id})`);
-
-    // 2. Get Embedding Model
-    const configs = await modelConfigService.findAll(user.id);
-    const embeddingConfig = configs.find(c => c.type === 'embedding');
-
-    // Note: If no embedding config is found, this might fail or fallback if services allow null.
-    // The controller checks for it, but service 'createAndIndex' might handle it or throw.
-    // We will proceed. If it's null, we might need to rely on system defaults if any.
-    // However, createAndIndex expects a full config object.
-    const embeddingModelId = embeddingConfig ? embeddingConfig.id : undefined;
-
-    if (!embeddingModelId) {
-      logger.warn('No embedding model configuration found for this user. Import might fail if system defaults are not set.');
-    } else {
-      logger.log(`Using embedding model: ${embeddingConfig?.name} (ID: ${embeddingModelId})`);
-    }
-
-    // 3. Create Group
-    logger.log('Creating "Generative AI for Beginners (CN)" knowledge group...');
-    // Check if group exists first? The API doesn't have list by name easily (findAll returns array).
-    const existingGroups = await groupService.findAll(user.id);
-    let group = existingGroups.find(g => g.name === 'Generative AI for Beginners (CN)');
-
-    if (!group) {
-      // Correctly casting to match the CreateGroupDto if needed, but 'files' are not in DTO
-      // groupService.create returns a KnowledgeGroup entity which has 'id'.
-      const newGroup = await groupService.create(user.id, {
-        name: 'Generative AI for Beginners (CN)',
-        description: 'Microsoft Generative AI for Beginners Course (Chinese Translation)',
-        color: '#0078D4', // Microsoft Blue-ish
-        intro: 'Imported from https://github.com/microsoft/generative-ai-for-beginners'
-      });
-      // Adapt to the GroupWithFileCount interface or just use the entity
-      group = { ...newGroup, fileCount: 0 } as any;
-      logger.log(`Group created with ID: ${newGroup.id}`);
-    } else {
-      logger.log(`Group already exists with ID: ${group.id}`);
-    }
-
-    // 4. Scan files
-    logger.log(`Scanning directory: ${SOURCE_DIR}`);
-    const filesToImport: string[] = [];
-
-    function scanDir(directory: string) {
-      if (!fs.existsSync(directory)) {
-        logger.error(`Directory not found: ${directory}`);
-        return;
-      }
-      const items = fs.readdirSync(directory);
-      for (const item of items) {
-        const fullPath = path.join(directory, item);
-        const stat = fs.statSync(fullPath);
-        if (stat.isDirectory()) {
-          scanDir(fullPath);
-        } else if (item.endsWith('.md')) {
-          // Filter out README.md if it's just a TOC, but usually they contain content.
-          // We'll import all markdowns.
-          filesToImport.push(fullPath);
-        }
-      }
-    }
-
-    scanDir(SOURCE_DIR);
-    logger.log(`Found ${filesToImport.length} markdown files to import.`);
-
-    // 5. Import Loop
-    for (const filePath of filesToImport) {
-      const filename = path.basename(filePath);
-      // Get directory name to use as prefix for title maybe?
-      const dirName = path.basename(path.dirname(filePath));
-      const title = dirName === 'zh' ? filename : `${dirName}/${filename}`;
-
-      logger.log(`Processing: ${title}`);
-
-      const content = fs.readFileSync(filePath, 'utf8');
-      const stats = fs.statSync(filePath);
-
-      // Construct File object-like structure for the service
-      // Service expects: { filename, originalname, path, mimetype, size }
-      // Note: The service might try to read 'path'. Since these are real files on disk, we can pass the path.
-      // BUT, knowledgeBaseService.createAndIndex usually moves/copies files to internal storage.
-      // Let's verify 'createAndIndex'. It likely calls 'storeFile'.
-      // If we pass an absolute path from D drive, it should work if it just reads it.
-
-      const fileInfo = {
-        filename: `imported-${Date.now()}-${filename}`, // Internal filename
-        originalname: title,
-        path: filePath, // Real path
-        mimetype: 'text/markdown',
-        size: stats.size
-      };
-
-      try {
-        // Default config
-        const indexingConfig = {
-          chunkSize: 500,
-          chunkOverlap: 50,
-          embeddingModelId: embeddingModelId,
-          mode: 'fast' as const
-        };
-
-        const kb = await kbService.createAndIndex(
-          fileInfo,
-          user.id,
-          indexingConfig
-        );
-
-        // Add to group
-        // knowledgeGroupService.addFilesToGroup takes (fileId, groupIds[], userId)
-        if (group) {
-          await groupService.addFilesToGroup(kb.id, [group.id], user.id);
-        }
-
-        logger.log(`  -> Imported and added to group. KB ID: ${kb.id}`);
-      } catch (e) {
-        logger.error(`  -> Failed to import ${filename}: ${e.message}`);
-      }
-    }
-
-    logger.log('Import completed.');
-
-  } catch (error) {
-    logger.error('Fatal error during import:', error);
-  } finally {
-    await app.close();
-  }
-}
-
-bootstrap();

+ 4 - 8
server/src/search-history/search-history.service.ts

@@ -1,4 +1,5 @@
 import { Injectable, NotFoundException } from '@nestjs/common';
+import { I18nService } from '../i18n/i18n.service';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { SearchHistory } from './search-history.entity';
@@ -40,6 +41,7 @@ export class SearchHistoryService {
     private searchHistoryRepository: Repository<SearchHistory>,
     @InjectRepository(ChatMessage)
     private chatMessageRepository: Repository<ChatMessage>,
+    private i18nService: I18nService,
   ) { }
 
   async findAll(
@@ -92,7 +94,7 @@ export class SearchHistoryService {
     });
 
     if (!history) {
-      throw new NotFoundException('会話履歴が存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('conversationHistoryNotFound'));
     }
 
     return {
@@ -123,12 +125,6 @@ export class SearchHistoryService {
     return await this.searchHistoryRepository.save(history);
   }
 
-  async updateTitle(id: string, title: string): Promise<void> {
-    await this.searchHistoryRepository.update(id, {
-      title: title.length > 50 ? title.substring(0, 50) + '...' : title,
-    });
-  }
-
   async addMessage(
     historyId: string,
     role: 'user' | 'assistant',
@@ -158,7 +154,7 @@ export class SearchHistoryService {
     });
 
     if (!history) {
-      throw new NotFoundException('会話履歴が存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('conversationHistoryNotFound'));
     }
 
     await this.searchHistoryRepository.remove(history);

+ 11 - 4
server/src/types.ts

@@ -4,6 +4,7 @@ export enum ModelType {
   LLM = 'llm',
   EMBEDDING = 'embedding',
   RERANK = 'rerank',
+  VISION = 'vision',
 }
 
 // 1. Model Definition (The "Provider" setup)
@@ -14,7 +15,14 @@ export interface ModelConfig {
   baseUrl?: string; // Base URL for OpenAI compatible API
   apiKey?: string; // API key for the service
   type: ModelType;
+  dimensions?: number;
   supportsVision?: boolean;
+  maxInputTokens?: number;
+  maxBatchSize?: number;
+  isVectorModel?: boolean;
+  providerName?: string;
+  isEnabled?: boolean;
+  isDefault?: boolean;
 }
 
 // 2. Application Logic Settings (The "App" setup)
@@ -31,11 +39,10 @@ export interface AppSettings {
   // Retrieval
   enableRerank: boolean;
   topK: number;
-  scoreThreshold: number;
-
+  similarityThreshold: number;
+  rerankSimilarityThreshold: number;
   enableFullTextSearch: boolean;
-  enableQueryExpansion: boolean;
-  enableHyDE: boolean;
+  hybridVectorWeight: number;
 
   // Language
   language: string;

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

@@ -26,6 +26,7 @@ import {
   DEFAULT_LANGUAGE
 } from '../common/constants';
 import { I18nService } from '../i18n/i18n.service';
+import { isAllowedByExtension, IMAGE_MIME_TYPES } from '../common/file-support.constants';
 
 export interface UploadConfigDto {
   chunkSize?: string;
@@ -50,80 +51,8 @@ export class UploadController {
   @UseInterceptors(
     FileInterceptor('file', {
       fileFilter: (req, file, cb) => {
-        // Apache Tika が処理できるすべての形式をサポート
-        // 参考: https://tika.apache.org/1.24/formats.html
-        const allowedMimeTypes = [
-          // PDF
-          'application/pdf',
-
-          // Microsoft Office
-          'application/msword',
-          'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-          'application/vnd.ms-excel',
-          'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-          'application/vnd.ms-powerpoint',
-          'application/vnd.openxmlformats-officedocument.presentationml.presentation',
-
-          // OpenOffice / LibreOffice
-          'application/vnd.oasis.opendocument.text',
-          'application/vnd.oasis.opendocument.spreadsheet',
-          'application/vnd.oasis.opendocument.presentation',
-          'application/vnd.oasis.opendocument.graphics',
-
-          // テキストファイル
-          'text/plain',
-          'text/markdown',
-          'text/html',
-          'text/csv',
-          'text/xml',
-          'application/xml',
-          'application/json',
-
-          // コードファイル
-          'text/x-python',
-          'text/x-java',
-          'text/x-c',
-          'text/x-c++',
-          'text/javascript',
-          'text/typescript',
-
-          // 画像
-          'image/jpeg',
-          'image/png',
-          'image/gif',
-          'image/webp',
-          'image/tiff',
-          'image/bmp',
-          'image/svg+xml',
-
-          // 圧縮ファイル
-          'application/zip',
-          'application/x-tar',
-          'application/gzip',
-          'application/x-7z-compressed',
-
-          // その他のドキュメント形式
-          'application/rtf',
-          'application/epub+zip',
-          'application/x-mobipocket-ebook',
-        ];
-
-        // 拡張子のチェック関数
-        const isAllowedByExtension = (filename: string): boolean => {
-          const ext = filename.toLowerCase().split('.').pop();
-          const allowedExtensions = [
-            'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
-            'odt', 'ods', 'odp', 'rtf', 'txt', 'md', 'html', 'csv', 'xml', 'json'
-          ];
-          return allowedExtensions.includes(ext || '');
-        };
-
-        // ホワイトリストに含まれているか、テキスト/コードファイルであるかを確認
-        const isAllowed = allowedMimeTypes.includes(file.mimetype) ||
-          file.mimetype.startsWith('text/') ||
-          file.mimetype.startsWith('application/vnd.') ||
-          file.mimetype.startsWith('application/x-') ||
-          file.mimetype.startsWith('application/wps-') || // WPS Office ファイル
+        // 画像MIMEタイプまたは拡張子によるチェック
+        const isAllowed = IMAGE_MIME_TYPES.includes(file.mimetype) ||
           isAllowedByExtension(file.originalname);
 
         if (isAllowed) {
@@ -171,8 +100,8 @@ export class UploadController {
 
     // 設定パラメータを解析し、安全なデフォルト値を設定
     const indexingConfig = {
-      chunkSize: Math.max(MIN_CHUNK_SIZE, Math.min(2000, config.chunkSize ? parseInt(config.chunkSize) : DEFAULT_CHUNK_SIZE)), // 制限範囲 100-2000
-      chunkOverlap: Math.max(0, Math.min(500, config.chunkOverlap ? parseInt(config.chunkOverlap) : DEFAULT_CHUNK_OVERLAP)), // 制限範囲 0-500
+      chunkSize: config.chunkSize ? parseInt(config.chunkSize) : DEFAULT_CHUNK_SIZE,
+      chunkOverlap: config.chunkOverlap ? parseInt(config.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
       embeddingModelId: config.embeddingModelId || null,
     };
 
@@ -251,12 +180,12 @@ export class UploadController {
 
     // Validating Config
     if (!body.embeddingModelId) {
-      throw new BadRequestException('埋め込みモデルを選択する必要があります');
+      throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired'));
     }
 
     const indexingConfig = {
-      chunkSize: Math.max(MIN_CHUNK_SIZE, Math.min(2000, body.chunkSize ? parseInt(body.chunkSize) : DEFAULT_CHUNK_SIZE)),
-      chunkOverlap: Math.max(0, Math.min(500, body.chunkOverlap ? parseInt(body.chunkOverlap) : DEFAULT_CHUNK_OVERLAP)),
+      chunkSize: body.chunkSize ? parseInt(body.chunkSize) : DEFAULT_CHUNK_SIZE,
+      chunkOverlap: body.chunkOverlap ? parseInt(body.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
       embeddingModelId: body.embeddingModelId || null,
     };
 
@@ -290,13 +219,4 @@ export class UploadController {
     const i = Math.floor(Math.log(bytes) / Math.log(k));
     return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
   }
-
-  private isAllowedByExtension(filename: string): boolean {
-    const ext = filename.toLowerCase().split('.').pop();
-    const allowedExtensions = [
-      'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
-      'odt', 'ods', 'odp', 'rtf', 'txt', 'md', 'html', 'csv', 'xml', 'json'
-    ];
-    return allowedExtensions.includes(ext || '');
-  }
 }

+ 16 - 9
server/src/user-setting/dto/create-user-setting.dto.ts

@@ -12,11 +12,9 @@ import { DEFAULT_SETTINGS } from '../../defaults'; // Import default settings fo
 
 export class CreateUserSettingDto {
   @IsString()
-  @IsNotEmpty()
   selectedLLMId: string = DEFAULT_SETTINGS.selectedLLMId;
 
   @IsString()
-  @IsNotEmpty()
   selectedEmbeddingId: string = DEFAULT_SETTINGS.selectedEmbeddingId;
 
   @IsString()
@@ -42,19 +40,28 @@ export class CreateUserSettingDto {
   @IsNumber()
   @Min(0) // Score threshold usually 0 to 1
   @Max(1)
+
   @IsNumber()
-  @Min(0) // Score threshold usually 0 to 1
+  @Min(0)
   @Max(1)
-  scoreThreshold: number = DEFAULT_SETTINGS.scoreThreshold;
+  @IsOptional()
+  similarityThreshold: number = DEFAULT_SETTINGS.similarityThreshold;
 
-  @IsBoolean()
-  enableFullTextSearch: boolean = DEFAULT_SETTINGS.enableFullTextSearch;
+  @IsNumber()
+  @Min(0)
+  @Max(1)
+  @IsOptional()
+  rerankSimilarityThreshold: number = DEFAULT_SETTINGS.rerankSimilarityThreshold;
 
   @IsBoolean()
-  enableQueryExpansion: boolean = DEFAULT_SETTINGS.enableQueryExpansion;
+  @IsOptional()
+  enableFullTextSearch: boolean = DEFAULT_SETTINGS.enableFullTextSearch;
 
-  @IsBoolean()
-  enableHyDE: boolean = DEFAULT_SETTINGS.enableHyDE;
+  @IsNumber()
+  @Min(0)
+  @Max(1)
+  @IsOptional()
+  hybridVectorWeight: number = DEFAULT_SETTINGS.hybridVectorWeight;
 
   @IsString()
   @IsOptional()

+ 22 - 3
server/src/user-setting/user-setting.controller.ts

@@ -15,6 +15,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
 import { UserSettingResponseDto } from './dto/user-setting-response.dto';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { plainToClass } from 'class-transformer';
+import { AdminGuard } from '../auth/admin.guard';
 
 @UseGuards(JwtAuthGuard)
 @Controller('settings') // Global prefix /api/settings
@@ -22,7 +23,25 @@ export class UserSettingController {
   constructor(
     private readonly userSettingService: UserSettingService,
     private readonly modelConfigService: ModelConfigService,
-  ) {}
+  ) { }
+
+  @Get('global')
+  async getGlobal(): Promise<UserSettingResponseDto> {
+    const globalSetting = await this.userSettingService.getGlobalSettings();
+    return plainToClass(UserSettingResponseDto, globalSetting);
+  }
+
+  @UseGuards(AdminGuard)
+  @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> {
@@ -46,8 +65,8 @@ export class UserSettingController {
   @Get('vision-models')
   async getVisionModels(@Req() req: any) {
     const userId = req.user.id;
-    const models = await this.modelConfigService.findByType(userId, 'llm');
-    return models.filter((model) => model.supportsVision);
+    const models = await this.modelConfigService.findByType(userId, 'vision');
+    return models;
   }
 
   @Get('vision-model')

+ 12 - 12
server/src/user-setting/user-setting.entity.ts

@@ -8,20 +8,23 @@ import {
   PrimaryGeneratedColumn,
   UpdateDateColumn,
 } from 'typeorm';
-import { User } from '../user/user.entity'; // 假设 User 实体路径
+import { User } from '../user/user.entity'; // Userエンティティのパス
 
 @Entity('user_settings')
 export class UserSetting {
   @PrimaryGeneratedColumn('uuid')
   id: string;
 
-  @Column({ type: 'text', unique: true }) // Ensure one-to-one relationship via unique userId
+  @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' })
+  @OneToOne(() => User, (user) => user.userSetting, { onDelete: 'CASCADE', nullable: true })
   @JoinColumn({ name: 'userId' })
   user: User;
 
+  @Column({ type: 'boolean', default: false })
+  isGlobal: boolean;
+
   @Column({ type: 'text' })
   selectedLLMId: string;
 
@@ -43,20 +46,17 @@ export class UserSetting {
   @Column({ type: 'integer' })
   topK: number;
 
-  @Column({ type: 'real' })
-  scoreThreshold: number;
-
-  @Column({ type: 'real', default: 0.7 })
+  @Column({ type: 'real', default: 0.3 })
   similarityThreshold: number;
 
-  @Column({ type: 'boolean', default: false })
-  enableFullTextSearch: boolean;
+  @Column({ type: 'real', default: 0.5 })
+  rerankSimilarityThreshold: number;
 
   @Column({ type: 'boolean', default: false })
-  enableQueryExpansion: boolean;
+  enableFullTextSearch: boolean;
 
-  @Column({ type: 'boolean', default: false })
-  enableHyDE: boolean;
+  @Column({ type: 'real', default: 0.7 })
+  hybridVectorWeight: number;
 
   @Column({ type: 'text', nullable: true })
   defaultVisionModelId: string;

+ 73 - 2
server/src/user-setting/user-setting.service.ts

@@ -1,5 +1,5 @@
 // server/src/user-setting/user-setting.service.ts
-import { Injectable } from '@nestjs/common'; // Removed NotFoundException
+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';
@@ -7,12 +7,53 @@ import { UpdateUserSettingDto } from './dto/update-user-setting.dto'; // Removed
 import { DEFAULT_SETTINGS } from '../defaults'; // Corrected import path
 
 @Injectable()
-export class UserSettingService {
+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() {
+    // 1. 既存のグローバル設定を検索する
+    let globalSetting = await this.userSettingRepository.findOne({
+      where: { isGlobal: true },
+    });
+
+    if (globalSetting) {
+      this.logger.log('Global settings already initialized.');
+      return;
+    }
+
+    // 2. グローバル設定がない場合、旧 'system' ユーザーから移行を試みる
+    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 {
+      // 3. 旧記録もない場合は、新規作成する
+      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 },
@@ -94,4 +135,34 @@ export class UserSettingService {
     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);
+  }
 }

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

@@ -3,6 +3,7 @@
 export type SafeUser = {
   id: string;
   username: string;
+  isAdmin: boolean;
   createdAt: Date;
   updatedAt: Date;
 };

+ 30 - 19
server/src/user/user.controller.ts

@@ -15,17 +15,21 @@ import {
 import { UserService } from './user.service';
 import { JwtAuthGuard } from '../auth/jwt-auth.guard';
 import { UpdateUserDto } from './dto/update-user.dto';
+import { I18nService } from '../i18n/i18n.service';
 
 @Controller('users')
 @UseGuards(JwtAuthGuard)
 export class UserController {
-  constructor(private readonly userService: UserService) {}
+  constructor(
+    private readonly userService: UserService,
+    private readonly i18nService: I18nService,
+  ) { }
 
   @Get()
   async findAll(@Request() req) {
     const isAdmin = await this.userService.isAdmin(req.user.id);
     if (!isAdmin) {
-      throw new ForbiddenException('只有管理员可以查看用户列表');
+      throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyViewList'));
     }
     return this.userService.findAll();
   }
@@ -38,11 +42,11 @@ export class UserController {
     const { currentPassword, newPassword } = body;
 
     if (!currentPassword || !newPassword) {
-      throw new BadRequestException('当前密码和新密码不能为空');
+      throw new BadRequestException(this.i18nService.getErrorMessage('passwordsRequired'));
     }
 
     if (newPassword.length < 6) {
-      throw new BadRequestException('新密码长度不能少于6位');
+      throw new BadRequestException(this.i18nService.getErrorMessage('newPasswordMinLength'));
     }
 
     return this.userService.changePassword(
@@ -59,17 +63,17 @@ export class UserController {
   ) {
     const isAdmin = await this.userService.isAdmin(req.user.id);
     if (!isAdmin) {
-      throw new ForbiddenException('只有管理员可以创建用户');
+      throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyCreateUser'));
     }
 
     const { username, password, isAdmin: userIsAdmin } = body;
 
     if (!username || !password) {
-      throw new BadRequestException('用户名和密码不能为空');
+      throw new BadRequestException(this.i18nService.getErrorMessage('usernamePasswordRequired'));
     }
 
     if (password.length < 6) {
-      throw new BadRequestException('密码长度不能少于6位');
+      throw new BadRequestException(this.i18nService.getErrorMessage('passwordMinLength'));
     }
 
     return this.userService.createUser(username, password, userIsAdmin);
@@ -83,18 +87,25 @@ export class UserController {
   ) {
     const callerIsAdmin = await this.userService.isAdmin(req.user.id);
     if (!callerIsAdmin) {
-      throw new ForbiddenException('只有管理员可以更新用户信息');
+      throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyUpdateUser'));
     }
 
-    // 获取要更新的用户信息
+    // 更新するユーザー情報を取得
     const userToUpdate = await this.userService.findOneById(id);
     if (!userToUpdate) {
-      throw new NotFoundException('用户不存在');
+      throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
     }
 
-    // 检查是否尝试修改内置admin账户
+    // ビルトインadminアカウントの変更を試行しているかチェック
     if (userToUpdate.username === 'admin') {
-      throw new ForbiddenException('无法修改内置管理员账户');
+      throw new ForbiddenException(this.i18nService.getErrorMessage('cannotModifyBuiltinAdmin'));
+    }
+
+    // ビルトイン管理者のみがユーザーのロールを変更可能
+    if (body.isAdmin !== undefined && userToUpdate.isAdmin !== body.isAdmin) {
+      if (req.user.username !== 'admin') {
+        throw new ForbiddenException(this.i18nService.getErrorMessage('onlyBuiltinAdminCanChangeRole'));
+      }
     }
 
     return this.userService.updateUser(id, body);
@@ -107,23 +118,23 @@ export class UserController {
   ) {
     const callerIsAdmin = await this.userService.isAdmin(req.user.id);
     if (!callerIsAdmin) {
-      throw new ForbiddenException('只有管理员可以删除用户');
+      throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyDeleteUser'));
     }
 
-    // 防止管理员删除自己
+    // 管理者が自身を削除するのを防止
     if (req.user.id === id) {
-      throw new BadRequestException('不能删除自己的账户');
+      throw new BadRequestException(this.i18nService.getErrorMessage('cannotDeleteSelf'));
     }
 
-    // 获取要删除的用户信息
+    // 削除するユーザー情報を取得
     const userToDelete = await this.userService.findOneById(id);
     if (!userToDelete) {
-      throw new NotFoundException('用户不存在');
+      throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
     }
 
-    // 阻止删除内置admin账户
+    // ビルトインadminアカウントの削除を阻止
     if (userToDelete.username === 'admin') {
-      throw new ForbiddenException('无法删除内置管理员账户');
+      throw new ForbiddenException(this.i18nService.getErrorMessage('cannotDeleteBuiltinAdmin'));
     }
 
     return this.userService.deleteUser(id);

+ 22 - 16
server/src/user/user.service.ts

@@ -1,15 +1,19 @@
-import { BadRequestException, ConflictException, Injectable, NotFoundException, OnModuleInit, ForbiddenException } from '@nestjs/common';
+import { BadRequestException, ConflictException, Injectable, NotFoundException, OnModuleInit, ForbiddenException, Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { Repository, Not } from 'typeorm';
 import { User } from './user.entity';
 import { CreateUserDto } from './dto/create-user.dto';
 import * as bcrypt from 'bcrypt';
+import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class UserService implements OnModuleInit {
+  private readonly logger = new Logger(UserService.name);
+
   constructor(
     @InjectRepository(User)
     private usersRepository: Repository<User>,
+    private i18nService: I18nService,
   ) { }
 
   async findOneByUsername(username: string): Promise<User | null> {
@@ -25,6 +29,8 @@ export class UserService implements OnModuleInit {
     await this.createAdminIfNotExists();
   }
 
+
+
   async findAll(): Promise<User[]> {
     return this.usersRepository.find({
       select: ['id', 'username', 'isAdmin', 'createdAt'],
@@ -47,7 +53,7 @@ export class UserService implements OnModuleInit {
   ): Promise<{ message: string }> {
     const user = await this.usersRepository.findOne({ where: { id: userId } });
     if (!user) {
-      throw new NotFoundException('ユーザーが存在しません');
+      throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
     }
 
     const isCurrentPasswordValid = await bcrypt.compare(
@@ -55,13 +61,13 @@ export class UserService implements OnModuleInit {
       user.password,
     );
     if (!isCurrentPasswordValid) {
-      throw new BadRequestException('現在のパスワードが間違っています');
+      throw new BadRequestException(this.i18nService.getMessage('incorrectCurrentPassword'));
     }
 
     const hashedNewPassword = await bcrypt.hash(newPassword, 10);
     await this.usersRepository.update(userId, { password: hashedNewPassword });
 
-    return { message: 'パスワードが正常に変更されました' };
+    return { message: this.i18nService.getMessage('passwordChanged') };
   }
 
   async createUser(
@@ -71,7 +77,7 @@ export class UserService implements OnModuleInit {
   ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
     const existingUser = await this.findOneByUsername(username);
     if (existingUser) {
-      throw new ConflictException('ユーザー名が既に存在します');
+      throw new ConflictException(this.i18nService.getMessage('usernameExists'));
     }
 
     const hashedPassword = await bcrypt.hash(password, 10);
@@ -82,7 +88,7 @@ export class UserService implements OnModuleInit {
     });
 
     return {
-      message: 'ユーザーが正常に作成されました',
+      message: this.i18nService.getMessage('userCreated'),
       user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
     };
   }
@@ -97,15 +103,15 @@ export class UserService implements OnModuleInit {
   ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
     const user = await this.usersRepository.findOne({ where: { id: userId } });
     if (!user) {
-      throw new NotFoundException('用户不存在');
+      throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
     }
 
-    // 阻止对用户名为"admin"的用户进行任何修改
+    // ユーザー名 "admin" のユーザーに対するいかなる変更も阻止
     if (user.username === 'admin') {
-      throw new ForbiddenException('无法修改内置管理员账户');
+      throw new ForbiddenException(this.i18nService.getMessage('cannotModifyBuiltinAdmin'));
     }
 
-    // 如果有密码需要更新,则先加密
+    // パスワードの更新が必要な場合は、まずハッシュ化する
     if (updateData.password) {
       const hashedPassword = await bcrypt.hash(updateData.password, 10);
       updateData.password = hashedPassword;
@@ -119,7 +125,7 @@ export class UserService implements OnModuleInit {
     });
 
     return {
-      message: '用户信息已更新',
+      message: this.i18nService.getMessage('userInfoUpdated'),
       user: {
         id: updatedUser!.id,
         username: updatedUser!.username,
@@ -131,18 +137,18 @@ export class UserService implements OnModuleInit {
   async deleteUser(userId: string): Promise<{ message: string }> {
     const user = await this.usersRepository.findOne({ where: { id: userId } });
     if (!user) {
-      throw new NotFoundException('用户不存在');
+      throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
     }
 
-    // 阻止删除用户名为"admin"的用户
+    // ユーザー名 "admin" のユーザーの削除を阻止
     if (user.username === 'admin') {
-      throw new ForbiddenException('无法删除内置管理员账户');
+      throw new ForbiddenException(this.i18nService.getMessage('cannotDeleteBuiltinAdmin'));
     }
 
     await this.usersRepository.delete(userId);
 
     return {
-      message: '用户已删除',
+      message: this.i18nService.getMessage('userDeleted'),
     };
   }
 

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

@@ -191,9 +191,7 @@ export class VisionPipelineCostAwareService {
       throw new Error(`モデル設定が見つかりません: ${modelId}`);
     }
 
-    if (!config.apiKey) {
-      throw new Error('モデル設定に API キーが不足しています');
-    }
+    // APIキーはオプションです - ローカルモデルを許可します
 
     return {
       baseUrl: config.baseUrl || '',

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

@@ -202,9 +202,7 @@ export class VisionPipelineService {
       throw new Error(`モデル設定が見つかりません: ${modelId}`);
     }
 
-    if (!config.apiKey) {
-      throw new Error('モデル設定に API キーが不足しています');
-    }
+    // APIキーはオプションです - ローカルモデルを許可します
 
     return {
       baseUrl: config.baseUrl || '',

+ 28 - 37
server/src/vision/vision.service.ts

@@ -1,4 +1,5 @@
 import { Injectable, Logger } from '@nestjs/common';
+import { I18nService } from '../i18n/i18n.service';
 import { ConfigService } from '@nestjs/config';
 import { ChatOpenAI } from '@langchain/openai';
 import { HumanMessage } from '@langchain/core/messages';
@@ -9,7 +10,10 @@ import { VisionAnalysisResult, VisionModelConfig, BatchAnalysisResult, ImageDesc
 export class VisionService {
   private readonly logger = new Logger(VisionService.name);
 
-  constructor(private configService: ConfigService) { }
+  constructor(
+    private configService: ConfigService,
+    private i18nService: I18nService,
+  ) { }
 
   /**
    * 単一画像の分析(ドキュメントページ)
@@ -20,7 +24,7 @@ export class VisionService {
     pageIndex?: number,
   ): Promise<VisionAnalysisResult> {
     const maxRetries = 3;
-    const baseDelay = 3000; // 3秒基础延迟
+    const baseDelay = 3000; // 3秒の基礎遅延
 
     for (let attempt = 1; attempt <= maxRetries; attempt++) {
       try {
@@ -29,10 +33,7 @@ export class VisionService {
         const isRetryableError = this.isRetryableError(error);
 
         if (attempt === maxRetries || !isRetryableError) {
-          this.logger.error(
-            `❌ 第 ${pageIndex || '?'} ページの分析に失敗しました (${attempt}/${maxRetries} 回目の試行): ${error.message}`,
-          );
-          throw new Error(`ビジョン分析に失敗しました: ${error.message}`);
+          throw new Error(this.i18nService.formatMessage('visionAnalysisFailed', { message: error.message }));
         }
 
         const delay = baseDelay + Math.random() * 2000; // 3-5秒のランダムな遅延
@@ -45,7 +46,7 @@ export class VisionService {
     }
 
     // この行は理論的には実行されませんが、TypeScript の要求を満たすために記述しています
-    throw new Error('再試行メカニズムの異常');
+    throw new Error(this.i18nService.getMessage('retryMechanismError'));
   }
 
   /**
@@ -73,22 +74,7 @@ export class VisionService {
       });
 
       // 専門的なドキュメント分析プロンプトを構築
-      const systemPrompt = `あなたは専門的なドキュメント分析アシスタントです。このドキュメント画像を分析し、以下の要求に従って JSON 形式で返してください:
-
-1. すべての読み取り可能なテキストを抽出(読み取り順序に従い、段落と形式を保持)
-2. 画像/グラフ/表の識別(内容、意味、役割を記述)
-3. ページレイアウトの分析(テキストのみ/テキストと画像の混合/表/グラフなど)
-4. 分析品質の評価(0-1)
-
-レスポンス形式:
-{
-  "text": "完全なテキスト内容",
-  "images": [
-    {"type": "グラフの種類", "description": "詳細な記述", "position": 1}
-  ],
-  "layout": "レイアウトの説明",
-  "confidence": 0.95
-}`;
+      const systemPrompt = this.i18nService.getMessage('visionSystemPrompt');
 
       const message = new HumanMessage({
         content: [
@@ -106,7 +92,7 @@ export class VisionService {
       });
 
       // モデルの呼び出し
-      this.logger.log(`[モデル呼び出し] タイプ: Vision, モデル: ${modelConfig.modelId}, ページ: ${pageIndex || '単一画像'}`);
+      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;
 
@@ -125,7 +111,7 @@ export class VisionService {
           pageIndex,
         };
       } catch (parseError) {
-        // 如果解析失败,将整个内容作为文本
+        // 解析に失敗した場合は、内容全体をテキストとして扱う
         this.logger.warn(`Failed to parse JSON response for ${imagePath}, using raw text`);
         result = {
           text: content,
@@ -137,9 +123,14 @@ export class VisionService {
       }
 
       this.logger.log(
-        `✅ Vision 分析完了: ${imagePath}${pageIndex ? ` (第 ${pageIndex} ページ)` : ''}, ` +
-        `テキスト長: ${result.text.length}文字, 画像数: ${result.images.length}, ` +
-        `レイアウト: ${result.layout}, 信頼度: ${(result.confidence * 100).toFixed(1)}%`
+        this.i18nService.formatMessage('visionAnalysisSuccess', {
+          path: imagePath,
+          page: pageIndex ? ` (第 ${pageIndex} ページ)` : '',
+          textLen: result.text.length,
+          imgCount: result.images.length,
+          layout: result.layout,
+          confidence: (result.confidence * 100).toFixed(1)
+        })
       );
 
       return result;
@@ -160,12 +151,12 @@ export class VisionService {
       return true;
     }
 
-    // 5xx 服务器错误
+    // 5xx サーバーエラー
     if (errorCode >= 500 && errorCode < 600) {
       return true;
     }
 
-    // 网络相关错误
+    // ネットワーク関連エラー
     if (errorMessage.includes('timeout') || errorMessage.includes('network') || errorMessage.includes('connection')) {
       return true;
     }
@@ -174,7 +165,7 @@ export class VisionService {
   }
 
   /**
-   * 延迟函
+   * 遅延関
    */
   private sleep(ms: number): Promise<void> {
     return new Promise(resolve => setTimeout(resolve, ms));
@@ -197,7 +188,7 @@ export class VisionService {
     let successCount = 0;
     let failedCount = 0;
 
-    this.logger.log(`🤖 Vision モデルの一括分析を開始: ${imagePaths.length} 枚の画像`);
+    this.logger.log(this.i18nService.formatMessage('batchAnalysisStarted', { count: imagePaths.length }));
     this.logger.log(`🔧 モデル設定: ${modelConfig.modelId} (${modelConfig.baseUrl || 'OpenAI'})`);
 
     for (let i = 0; i < imagePaths.length; i++) {
@@ -207,7 +198,7 @@ export class VisionService {
 
       this.logger.log(`🖼️  第 ${pageIndex} ページを分析中 (${i + 1}/${imagePaths.length}, ${progress}%)`);
 
-      // 调用进度回调
+      // 進捗コールバックを呼び出し
       if (onProgress) {
         onProgress(i + 1, imagePaths.length);
       }
@@ -240,12 +231,12 @@ export class VisionService {
           `信頼度: ${(result.confidence * 100).toFixed(1)}%)`
         );
 
-        // 调用进度回调带结果
+        // 結果付きで進捗コールバックを呼び出し
         if (onProgress) {
           onProgress(i + 1, imagePaths.length, result);
         }
       } catch (error) {
-        this.logger.error(`❌ 第 ${pageIndex} ページ分析失敗: ${error.message}`);
+        this.logger.error(this.i18nService.formatMessage('pageAnalysisFailed', { page: pageIndex }) + `: ${error.message}`);
         failedCount++;
       }
     }
@@ -296,7 +287,7 @@ export class VisionService {
 
       return { isGood: true, score };
     } catch (error) {
-      return { isGood: false, reason: `ファイルを読み込めません: ${error.message}`, score: 0 };
+      return { isGood: false, reason: this.i18nService.formatMessage('imageLoadError', { message: error.message }), score: 0 };
     }
   }
 
@@ -316,7 +307,7 @@ export class VisionService {
   }
 
   /**
-   * 获取 MIME 类型
+   * MIME タイプを取得
    */
   private getMimeType(filePath: string): string {
     const ext = filePath.toLowerCase().split('.').pop();

+ 0 - 25
server/test/app.e2e-spec.ts

@@ -1,25 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { INestApplication } from '@nestjs/common';
-import request from 'supertest';
-import { App } from 'supertest/types';
-import { AppModule } from './../src/app.module';
-
-describe('AppController (e2e)', () => {
-  let app: INestApplication<App>;
-
-  beforeEach(async () => {
-    const moduleFixture: TestingModule = await Test.createTestingModule({
-      imports: [AppModule],
-    }).compile();
-
-    app = moduleFixture.createNestApplication();
-    await app.init();
-  });
-
-  it('/ (GET)', () => {
-    return request(app.getHttpServer())
-      .get('/')
-      .expect(200)
-      .expect('Hello World!');
-  });
-});

+ 0 - 9
server/test/jest-e2e.json

@@ -1,9 +0,0 @@
-{
-  "moduleFileExtensions": ["js", "json", "ts"],
-  "rootDir": ".",
-  "testEnvironment": "node",
-  "testRegex": ".e2e-spec.ts$",
-  "transform": {
-    "^.+\\.(t|j)s$": "ts-jest"
-  }
-}

+ 0 - 0
docs/test_admin_features.md → test_admin_features.md


+ 6 - 1
web/.env

@@ -1,4 +1,9 @@
 VITE_API_BASE_URL=/api
 # 許可されたホストリスト、カンマ区切り
 # ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
-VITE_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
+VITE_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
+
+# Vite server configuration
+VITE_PORT=13001
+VITE_HOST=0.0.0.0
+VITE_BACKEND_URL=http://localhost:3001

+ 6 - 1
web/.env.example

@@ -3,4 +3,9 @@ VITE_API_BASE_URL=/api
 
 # API接続を許可するホストリスト(カンマ区切り)
 # 例: VITE_ALLOWED_HOSTS=localhost,127.0.0.1,example.com
-VITE_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
+VITE_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
+
+# Vite server configuration
+VITE_PORT=13001
+VITE_HOST=0.0.0.0
+VITE_BACKEND_URL=http://localhost:3001

+ 5 - 1
web/App.tsx

@@ -9,6 +9,7 @@ import { NotebooksView } from './components/views/NotebooksView'
 import { SettingsView } from './components/views/SettingsView'
 import { LanguageProvider } from './contexts/LanguageContext'
 import { ToastProvider } from './contexts/ToastContext'
+import { ConfirmProvider } from './contexts/ConfirmContext'
 
 import { modelConfigService } from './services/modelConfigService'
 import { authService } from './services/authService'
@@ -163,6 +164,7 @@ const AppContent: React.FC = () => {
                         onNavigate={(view) => setCurrentView(view)}
                         initialChatContext={chatContext}
                         onClearContext={() => setChatContext(null)}
+                        isAdmin={!!currentUser?.isAdmin}
                     />
                 )}
 
@@ -202,7 +204,9 @@ const App: React.FC = () => {
     return (
         <LanguageProvider>
             <ToastProvider>
-                <AppContent />
+                <ConfirmProvider>
+                    <AppContent />
+                </ConfirmProvider>
             </ToastProvider>
         </LanguageProvider>
     )

+ 1 - 1
web/components/AICommandDrawer.tsx

@@ -55,7 +55,7 @@ export const AICommandDrawer: React.FC<AICommandDrawerProps> = ({ isOpen, onClos
             }
         } catch (error) {
             console.error(error)
-            setResult(prev => prev + '\n\n[发生错误]')
+            setResult(prev => prev + '\n\n[' + t('aiCommandsError') + ']')
         } finally {
             setIsGenerating(false)
         }

+ 7 - 5
web/components/ChatInterface.tsx

@@ -31,6 +31,7 @@ interface ChatInterfaceProps {
   onHistoryMessagesLoaded?: () => void;
   onPreviewSource?: (source: ChatSource) => void;
   onOpenFile?: (source: ChatSource) => void;
+  onHistoryIdCreated?: (historyId: string) => void;
 }
 
 const ChatInterface: React.FC<ChatInterfaceProps> = ({
@@ -48,7 +49,8 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
   historyMessages,
   onHistoryMessagesLoaded,
   onPreviewSource,
-  onOpenFile
+  onOpenFile,
+  onHistoryIdCreated
 }) => {
   const { t, language } = useLanguage();
   const [messages, setMessages] = useState<Message[]>([]);
@@ -137,7 +139,7 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
     setInput('');
     if (inputRef.current) {
       inputRef.current.style.height = 'auto';
-      inputRef.current.blur(); // 失去焦点
+      inputRef.current.blur(); // フォーカスを外す
     }
 
     // Resolve Model Config
@@ -215,9 +217,7 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
         settings.maxTokens, // 最大トークン数を渡す
         settings.topK, // Top-Kパラメータを渡す
         settings.similarityThreshold, // 類似度しきい値を渡す
-        settings.enableQueryExpansion,
-        settings.enableHyDE,
-        settings.scoreThreshold
+        settings.rerankSimilarityThreshold // Rerankしきい値を渡す
       );
 
       for await (const chunk of stream) {
@@ -239,6 +239,8 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
                 : msg
             )
           );
+        } else if (chunk.type === 'historyId') {
+          onHistoryIdCreated?.(chunk.data);
         } else if (chunk.type === 'error') {
           setMessages(prev =>
             prev.map(msg =>

+ 5 - 12
web/components/ChatMessage.tsx

@@ -1,5 +1,5 @@
-
 import React, { useState } from 'react';
+import { copyToClipboard } from '../utils/clipboard';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 import { Message, Role } from '../types';
@@ -20,12 +20,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onO
   const [sourcesExpanded, setSourcesExpanded] = useState(false);
 
   const handleCopy = async () => {
-    try {
-      await navigator.clipboard.writeText(message.text);
+    const success = await copyToClipboard(message.text);
+    if (success) {
       setCopied(true);
       setTimeout(() => setCopied(false), 2000);
-    } catch (err) {
-      console.error('Failed to copy text: ', err);
     }
   };
 
@@ -208,13 +206,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onO
                       <div className="text-slate-600 text-sm leading-relaxed line-clamp-2">
                         {source.content}
                       </div>
-                      <div className="text-xs text-slate-400 mt-2 flex justify-between items-center gap-4">
-                        <div className="flex gap-4">
-                          <span>{t('chunkNumber')} #{source.chunkIndex + 1}</span>
-                          {source.pageNumber !== undefined && (
-                            <span>{t('pageNumber')} {source.pageNumber}</span>
-                          )}
-                        </div>
+                      <div className="text-xs text-slate-400 mt-2 flex justify-between items-center">
+                        <span>{t('chunkNumber')} #{source.chunkIndex + 1}</span>
                         <span className="text-blue-500 opacity-0 group-hover/source:opacity-100 transition-opacity flex items-center gap-1">
                           {t('sourcePreview')} &rarr;
                         </span>

+ 2 - 2
web/components/ChunkInfoDrawer.tsx

@@ -52,7 +52,7 @@ export const ChunkInfoDrawer: React.FC<ChunkInfoDrawerProps> = ({
             setChunkInfo(data);
         } catch (err) {
             console.error('Failed to load chunks:', err);
-            setError('加载分片信息失败');
+            setError(t('errorLoadData'));
         } finally {
             setLoading(false);
         }
@@ -90,7 +90,7 @@ export const ChunkInfoDrawer: React.FC<ChunkInfoDrawerProps> = ({
                 <div className="flex-1 overflow-y-auto p-6">
                     {loading ? (
                         <div className="flex items-center justify-center h-full">
-                            <div className="text-slate-500">加载中...</div>
+                            <div className="text-slate-500">{t('loading')}</div>
                         </div>
                     ) : error ? (
                         <div className="flex items-center justify-center h-full">

+ 62 - 58
web/components/ConfigPanel.tsx

@@ -2,6 +2,7 @@
 import React from 'react';
 import { AppSettings, ModelConfig, ModelType } from '../types';
 import { useLanguage } from '../contexts/LanguageContext';
+import { useConfirm } from '../contexts/ConfirmContext';
 import { Settings, Database, Sliders, Layers, Cpu, ChevronRight } from 'lucide-react';
 import VisionModelSelector from './VisionModelSelector';
 
@@ -11,10 +12,12 @@ interface ConfigPanelProps {
   onSettingsChange: (newSettings: AppSettings) => void;
   onOpenSettings: () => void;
   mode?: 'chat' | 'kb' | 'all';
+  isAdmin?: boolean;
 }
 
-const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsChange, onOpenSettings, mode = 'all' }) => {
+const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsChange, onOpenSettings, mode = 'all', isAdmin = false }) => {
   const { t } = useLanguage();
+  const { confirm } = useConfirm();
 
   const handleChange = (key: keyof AppSettings, value: any) => {
     onSettingsChange({
@@ -32,6 +35,14 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
 
   return (
     <div className="flex-1 overflow-y-auto p-4 space-y-6 bg-slate-50">
+      {!isAdmin && (
+        <div className="bg-orange-50 border border-orange-200 p-3 rounded-lg mb-4">
+          <p className="text-xs text-orange-700 flex items-center gap-2">
+            <Sliders className="w-3 h-3" />
+            {t('onlyAdminCanModify') || "Only administrators can modify system settings."}
+          </p>
+        </div>
+      )}
 
       {/* Model Selection (LLM) - Chat Mode Only */}
       {showChatSettings && (
@@ -49,7 +60,8 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
               <select
                 value={settings.selectedLLMId}
                 onChange={(e) => handleChange('selectedLLMId', e.target.value)}
-                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500"
+                disabled={!isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
               >
                 <option value="">{t('selectLLMModel')}</option>
                 {llmModels.map(m => (
@@ -78,17 +90,18 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
               <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblEmbedding')}</label>
               <select
                 value={settings.selectedEmbeddingId}
-                onChange={(e) => {
+                onChange={async (e) => {
                   const newId = e.target.value;
                   if (newId !== settings.selectedEmbeddingId && settings.selectedEmbeddingId) {
-                    if (confirm(t('confirmChangeEmbeddingModel') || "WARNING: Changing the embedding model will require re-indexing all existing files. Are you sure?")) {
+                    if (await confirm(t('confirmChangeEmbeddingModel') || "WARNING: Changing the embedding model will require re-indexing all existing files. Are you sure?")) {
                       handleChange('selectedEmbeddingId', newId);
                     }
                   } else {
                     handleChange('selectedEmbeddingId', newId);
                   }
                 }}
-                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500"
+                disabled={!isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
               >
                 <option value="">--- {t('selectEmbeddingModel')} ---</option>
                 {embeddingModels.map(m => (
@@ -119,11 +132,12 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
               <input
                 type="range"
                 min="0"
-                max="2"
+                max="1"
                 step="0.1"
                 value={settings.temperature}
                 onChange={(e) => handleChange('temperature', parseFloat(e.target.value))}
-                className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+                disabled={!isAdmin}
+                className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
               />
             </div>
             <div>
@@ -132,7 +146,8 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
                 type="number"
                 value={settings.maxTokens}
                 onChange={(e) => handleChange('maxTokens', parseInt(e.target.value))}
-                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500"
+                disabled={!isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
               />
             </div>
           </div>
@@ -141,7 +156,7 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
 
       {/* Vision Model Settings - Chat Mode Only? Or both? Assuming Chat */}
       {/* Vision Model Settings - KB Only */}
-      {showKbSettings && <VisionModelSelector />}
+      {showKbSettings && <VisionModelSelector isAdmin={isAdmin} />}
 
       {/* Retrieval Settings - KB Mode Only */}
       {showKbSettings && (
@@ -164,7 +179,8 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
                 step="1"
                 value={settings.topK}
                 onChange={(e) => handleChange('topK', parseInt(e.target.value))}
-                className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+                disabled={!isAdmin}
+                className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
               />
             </div>
 
@@ -173,8 +189,8 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
               <select
                 value={settings.selectedRerankId}
                 onChange={(e) => handleChange('selectedRerankId', e.target.value)}
-                disabled={!settings.enableRerank}
-                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50"
+                disabled={!settings.enableRerank || !isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
               >
                 <option value="">--- {t('noRerankModel')} ---</option>
                 {rerankModels.map(m => (
@@ -185,17 +201,18 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
 
             <div>
               <div className="flex justify-between mb-1.5">
-                <label className="text-xs font-medium text-slate-500">{t('similarityThreshold')}</label>
+                <label className="text-xs font-medium text-slate-500">{t('vectorSimilarityThreshold')}</label>
                 <span className="text-xs text-blue-600 font-bold">{settings.similarityThreshold}</span>
               </div>
               <input
                 type="range"
-                min="0.1"
+                min="0.0"
                 max="1.0"
                 step="0.05"
                 value={settings.similarityThreshold}
                 onChange={(e) => handleChange('similarityThreshold', parseFloat(e.target.value))}
-                className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+                disabled={!isAdmin}
+                className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
               />
               <p className="text-[10px] text-slate-400 mt-1">{t('filterLowResults')}</p>
             </div>
@@ -203,28 +220,28 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
             {settings.enableRerank && (
               <div>
                 <div className="flex justify-between mb-1.5">
-                  <label className="text-xs font-medium text-slate-500">{t('scoreThreshold')}</label>
-                  <span className="text-xs text-blue-600 font-bold">{settings.scoreThreshold}</span>
+                  <label className="text-xs font-medium text-slate-500">{t('rerankSimilarityThreshold')}</label>
+                  <span className="text-xs text-blue-600 font-bold">{settings.rerankSimilarityThreshold}</span>
                 </div>
                 <input
                   type="range"
-                  min="0"
+                  min="0.0"
                   max="1.0"
                   step="0.05"
-                  value={settings.scoreThreshold}
-                  onChange={(e) => handleChange('scoreThreshold', parseFloat(e.target.value))}
-                  className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
+                  value={settings.rerankSimilarityThreshold}
+                  onChange={(e) => handleChange('rerankSimilarityThreshold', parseFloat(e.target.value))}
+                  className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-pink-600"
                 />
-                <p className="text-[10px] text-slate-400 mt-1">{t('filterLowResults')}</p>
               </div>
             )}
 
             <div className="flex items-center justify-between pt-2">
               <label className="text-sm text-slate-700">{t('lblRerank')}</label>
               <button
-                onClick={() => handleChange('enableRerank', !settings.enableRerank)}
+                onClick={() => isAdmin && handleChange('enableRerank', !settings.enableRerank)}
+                disabled={!isAdmin}
                 className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableRerank ? 'bg-blue-600' : 'bg-slate-300'
-                  }`}
+                  } ${!isAdmin ? 'cursor-not-allowed opacity-50' : ''}`}
               >
                 <span
                   className={`${settings.enableRerank ? 'translate-x-6' : 'translate-x-1'
@@ -236,9 +253,10 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
             <div className="flex items-center justify-between pt-2">
               <label className="text-sm text-slate-700">{t('fullTextSearch')}</label>
               <button
-                onClick={() => handleChange('enableFullTextSearch', !settings.enableFullTextSearch)}
+                onClick={() => isAdmin && handleChange('enableFullTextSearch', !settings.enableFullTextSearch)}
+                disabled={!isAdmin}
                 className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableFullTextSearch ? 'bg-blue-600' : 'bg-slate-300'
-                  }`}
+                  } ${!isAdmin ? 'cursor-not-allowed opacity-50' : ''}`}
               >
                 <span
                   className={`${settings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'
@@ -247,39 +265,25 @@ const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsC
               </button>
             </div>
 
-            <div className="pt-2">
-              <div className="flex items-center justify-between mb-1">
-                <label className="text-sm text-slate-700">{t('queryExpansion')}</label>
-                <button
-                  onClick={() => handleChange('enableQueryExpansion', !settings.enableQueryExpansion)}
-                  className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableQueryExpansion ? 'bg-blue-600' : 'bg-slate-300'
-                    }`}
-                >
-                  <span
-                    className={`${settings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'
-                      } inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
-                  />
-                </button>
-              </div>
-              <p className="text-[10px] text-slate-400">{t('queryExpansionDesc')}</p>
-            </div>
-
-            <div className="pt-2">
-              <div className="flex items-center justify-between mb-1">
-                <label className="text-sm text-slate-700">{t('hyde')}</label>
-                <button
-                  onClick={() => handleChange('enableHyDE', !settings.enableHyDE)}
-                  className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableHyDE ? 'bg-blue-600' : 'bg-slate-300'
-                    }`}
-                >
-                  <span
-                    className={`${settings.enableHyDE ? 'translate-x-6' : 'translate-x-1'
-                      } inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
-                  />
-                </button>
+            {settings.enableFullTextSearch && (
+              <div className="pt-2 animate-in fade-in slide-in-from-top-1 duration-200">
+                <div className="flex justify-between mb-1.5">
+                  <label className="text-xs font-medium text-slate-500">{t('hybridVectorWeight')}</label>
+                  <span className="text-xs text-blue-600 font-bold">{settings.hybridVectorWeight}</span>
+                </div>
+                <input
+                  type="range"
+                  min="0.0"
+                  max="1.0"
+                  step="0.05"
+                  value={settings.hybridVectorWeight}
+                  onChange={(e) => handleChange('hybridVectorWeight', parseFloat(e.target.value))}
+                  disabled={!isAdmin}
+                  className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
+                />
+                <p className="text-[10px] text-slate-400 mt-1">{t('hybridVectorWeightDesc')}</p>
               </div>
-              <p className="text-[10px] text-slate-400">{t('hydeDesc')}</p>
-            </div>
+            )}
           </div>
         </div>
       )}

+ 67 - 0
web/components/ConfirmDialog.tsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import { AlertCircle, X } from 'lucide-react';
+import { useLanguage } from '../contexts/LanguageContext';
+
+interface ConfirmDialogProps {
+    isOpen: boolean;
+    title?: string;
+    message: string;
+    confirmLabel?: string;
+    cancelLabel?: string;
+    onConfirm: () => void;
+    onCancel: () => void;
+}
+
+const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
+    isOpen,
+    title,
+    message,
+    confirmLabel,
+    cancelLabel,
+    onConfirm,
+    onCancel,
+}) => {
+    const { t } = useLanguage();
+
+    if (!isOpen) return null;
+
+    return (
+        <div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
+            <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in duration-200">
+                <div className="flex justify-between items-center px-6 py-4 border-b">
+                    <h3 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
+                        <AlertCircle className="w-5 h-5 text-amber-500" />
+                        {title || t('confirmTitle') || 'Confirm'}
+                    </h3>
+                    <button
+                        onClick={onCancel}
+                        className="text-slate-400 hover:text-slate-600 transition-colors"
+                    >
+                        <X size={20} />
+                    </button>
+                </div>
+
+                <div className="px-6 py-8">
+                    <p className="text-slate-600 whitespace-pre-wrap">{message}</p>
+                </div>
+
+                <div className="px-6 py-4 bg-slate-50 flex justify-end gap-3">
+                    <button
+                        onClick={onCancel}
+                        className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-xl transition-colors font-medium"
+                    >
+                        {cancelLabel || t('cancel') || 'Cancel'}
+                    </button>
+                    <button
+                        onClick={onConfirm}
+                        className="px-6 py-2 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-colors font-medium shadow-md shadow-red-600/20"
+                    >
+                        {confirmLabel || t('confirm') || 'Confirm'}
+                    </button>
+                </div>
+            </div>
+        </div>
+    );
+};
+
+export default ConfirmDialog;

+ 1 - 1
web/components/CreateNoteFromPDFDialog.tsx

@@ -61,7 +61,7 @@ export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = (
     }, [screenshot, extractedText, authToken]);
 
     const handleSave = async () => {
-        // 检查是否选择了知识组
+        // ナレッジグループが選択されているか確認
         if (!selectedGroupId) {
             showToast('warning', t('pleaseSelectKnowledgeGroupFirst')); // 使用 toast 提示用户先选择知识组
             return;

+ 9 - 20
web/components/CreateNotebookDialog.tsx

@@ -2,6 +2,7 @@ import React, { useState } from 'react'
 import { X, Plus } from 'lucide-react'
 import { CreateGroupData } from '../types'
 import { useLanguage } from '../contexts/LanguageContext'
+import { useToast } from '../contexts/ToastContext'
 
 interface CreateNotebookDialogProps {
     isOpen: boolean
@@ -15,9 +16,9 @@ export const CreateNotebookDialog: React.FC<CreateNotebookDialogProps> = ({
     onCreate,
 }) => {
     const { t } = useLanguage();
+    const { showError } = useToast();
     const [name, setName] = useState('')
     const [description, setDescription] = useState('')
-    const [intro, setIntro] = useState('')
     const [isSubmitting, setIsSubmitting] = useState(false)
 
     if (!isOpen) return null
@@ -31,17 +32,15 @@ export const CreateNotebookDialog: React.FC<CreateNotebookDialogProps> = ({
             await onCreate({
                 name: name.trim(),
                 description: description.trim(),
-                intro: intro.trim(),
                 color: '#3b82f6', // Default color
             })
             // Reset form
             setName('')
             setDescription('')
-            setIntro('')
             onClose()
         } catch (error) {
             console.error('Failed to create notebook:', error)
-            alert(t('creationFailed'))
+            showError(t('createFailedRetry'))
         } finally {
             setIsSubmitting(false)
         }
@@ -51,7 +50,7 @@ export const CreateNotebookDialog: React.FC<CreateNotebookDialogProps> = ({
         <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
             <div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
                 <div className="flex justify-between items-center px-6 py-4 border-b">
-                    <h2 className="text-lg font-semibold text-slate-800">{t('createNewNotebook')}</h2>
+                    <h2 className="text-lg font-semibold text-slate-800">{t('createNotebookTitle')}</h2>
                     <button
                         onClick={onClose}
                         className="text-slate-400 hover:text-slate-600 transition-colors"
@@ -63,14 +62,14 @@ export const CreateNotebookDialog: React.FC<CreateNotebookDialogProps> = ({
                 <form onSubmit={handleSubmit} className="p-6 space-y-4">
                     <div>
                         <label className="block text-sm font-medium text-slate-700 mb-1">
-                            {t('nameField')} <span className="text-red-500">{t('required')}</span>
+                            {t('name')} <span className="text-red-500">*</span>
                         </label>
                         <input
                             type="text"
                             value={name}
                             onChange={(e) => setName(e.target.value)}
                             className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
-                            placeholder={t('exampleResearch')}
+                            placeholder={t('namePlaceholder')}
                             required
                             autoFocus
                         />
@@ -78,28 +77,18 @@ export const CreateNotebookDialog: React.FC<CreateNotebookDialogProps> = ({
 
                     <div>
                         <label className="block text-sm font-medium text-slate-700 mb-1">
-                            {t('shortDescriptionField')}
+                            {t('shortDescription')}
                         </label>
                         <input
                             type="text"
                             value={description}
                             onChange={(e) => setDescription(e.target.value)}
                             className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
-                            placeholder={t('describePurpose')}
+                            placeholder={t('descPlaceholder')}
                         />
                     </div>
 
-                    <div>
-                        <label className="block text-sm font-medium text-slate-700 mb-1">
-                            {t('detailedIntroField')}
-                        </label>
-                        <textarea
-                            value={intro}
-                            onChange={(e) => setIntro(e.target.value)}
-                            className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 h-32 resize-none"
-                            placeholder={t('provideBackgroundInfo')}
-                        />
-                    </div>
+
 
                     <div className="flex justify-end pt-2">
                         <button

+ 4 - 17
web/components/CreateNotebookDrawer.tsx

@@ -1,7 +1,8 @@
 import React, { useState } from 'react'
-import { X, Plus, ChevronRight } from 'lucide-react'
+import { Plus, ChevronRight } from 'lucide-react'
 import { CreateGroupData } from '../types'
 import { useLanguage } from '../contexts/LanguageContext'
+import { useToast } from '../contexts/ToastContext'
 
 interface CreateNotebookDrawerProps {
     isOpen: boolean
@@ -15,9 +16,9 @@ export const CreateNotebookDrawer: React.FC<CreateNotebookDrawerProps> = ({
     onCreate,
 }) => {
     const { t } = useLanguage()
+    const { showError } = useToast()
     const [name, setName] = useState('')
     const [description, setDescription] = useState('')
-    const [intro, setIntro] = useState('')
     const [isSubmitting, setIsSubmitting] = useState(false)
 
     const handleSubmit = async (e: React.FormEvent) => {
@@ -29,17 +30,15 @@ export const CreateNotebookDrawer: React.FC<CreateNotebookDrawerProps> = ({
             await onCreate({
                 name: name.trim(),
                 description: description.trim(),
-                intro: intro.trim(),
                 color: '#3b82f6', // Default color
             })
             // Reset form
             setName('')
             setDescription('')
-            setIntro('')
             onClose()
         } catch (error) {
             console.error('Failed to create notebook:', error)
-            alert(t('createFailedRetry'))
+            showError(t('createFailedRetry'))
         } finally {
             setIsSubmitting(false)
         }
@@ -107,18 +106,6 @@ export const CreateNotebookDrawer: React.FC<CreateNotebookDrawerProps> = ({
                                 />
                             </div>
 
-                            <div>
-                                <label className="block text-sm font-medium text-slate-700 mb-1">
-                                    {t('detailedIntro')}
-                                </label>
-                                <textarea
-                                    value={intro}
-                                    onChange={(e) => setIntro(e.target.value)}
-                                    className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50 h-64 resize-none leading-relaxed"
-                                    placeholder={t('introPlaceholder')}
-                                />
-                                <p className="mt-1 text-xs text-slate-500">{t('introHelp')}</p>
-                            </div>
                         </form>
                     </div>
 

+ 9 - 11
web/components/DragDropUpload.tsx

@@ -5,7 +5,7 @@ import { useLanguage } from '../contexts/LanguageContext';
 interface DragDropUploadProps {
   onFilesSelected: (files: FileList) => void;
   isAdmin: boolean;
-  globalMode?: boolean; // 新增属性,用于控制是否为全局模式
+  globalMode?: boolean; // グローバルモードかどうかを制御するための追加属性
 }
 
 export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected, isAdmin, globalMode = false }) => {
@@ -23,7 +23,7 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
   const handleDragLeave = useCallback((e: React.DragEvent) => {
     e.preventDefault();
     e.stopPropagation();
-    // 只有当鼠标真正离开拖拽区域时才设置为false
+    // マウスが実際にドラッグ領域を離れた場合のみfalseに設定
     setTimeout(() => setIsDragging(false), 100);
   }, []);
 
@@ -56,14 +56,13 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
     return null;
   }
 
-  // 根据模式决定CSS类
+  // モードに応じてCSSクラスを決定
   const containerClass = globalMode
     ? `fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 transition-opacity duration-300 ${isDragging ? 'opacity-100' : 'opacity-0 pointer-events-none'}`
-    : `border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${
-        isDragging
-          ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
-          : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
-      }`;
+    : `border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${isDragging
+      ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
+      : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
+    }`;
 
   const contentClass = globalMode
     ? "w-3/4 max-w-2xl"
@@ -73,11 +72,10 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
     <div className={containerClass}>
       <div className={contentClass}>
         <div
-          className={`border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${
-            isDragging
+          className={`border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${isDragging
               ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
               : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
-          }`}
+            }`}
           onDragEnter={handleDragEnter}
           onDragOver={handleDragOver}
           onDragLeave={handleDragLeave}

+ 12 - 23
web/components/EditNotebookDialog.tsx

@@ -1,6 +1,8 @@
 import React, { useEffect, useState } from 'react'
 import { X, Save } from 'lucide-react'
 import { KnowledgeGroup, UpdateGroupData } from '../types'
+import { useLanguage } from '../contexts/LanguageContext'
+import { useToast } from '../contexts/ToastContext'
 
 interface EditNotebookDialogProps {
     isOpen: boolean
@@ -17,15 +19,15 @@ export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
 }) => {
     const [name, setName] = useState(notebook.name)
     const [description, setDescription] = useState(notebook.description || '')
-    const [intro, setIntro] = useState(notebook.intro || '')
     const [isSubmitting, setIsSubmitting] = useState(false)
+    const { t } = useLanguage()
+    const { showError } = useToast()
 
     // Reset form when notebook changes or dialog opens
     useEffect(() => {
         if (isOpen) {
             setName(notebook.name)
             setDescription(notebook.description || '')
-            setIntro(notebook.intro || '')
         }
     }, [isOpen, notebook])
 
@@ -40,12 +42,11 @@ export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
             await onUpdate(notebook.id, {
                 name: name.trim(),
                 description: description.trim(),
-                intro: intro.trim(),
             })
             onClose()
         } catch (error) {
             console.error('Failed to update notebook:', error)
-            alert('更新失败,请重试')
+            showError(t('updateFailedRetry'))
         } finally {
             setIsSubmitting(false)
         }
@@ -55,7 +56,7 @@ export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
         <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
             <div className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
                 <div className="flex justify-between items-center px-6 py-4 border-b">
-                    <h2 className="text-lg font-semibold text-slate-800">编辑知识组</h2>
+                    <h2 className="text-lg font-semibold text-slate-800">{t('editNotebookTitle')}</h2>
                     <button
                         onClick={onClose}
                         className="text-slate-400 hover:text-slate-600 transition-colors"
@@ -67,40 +68,28 @@ export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
                 <form onSubmit={handleSubmit} className="p-6 space-y-4">
                     <div>
                         <label className="block text-sm font-medium text-slate-700 mb-1">
-                            名称
+                            {t('name')}
                         </label>
                         <input
                             type="text"
                             value={name}
                             onChange={(e) => setName(e.target.value)}
                             className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
-                            placeholder="知识组名称"
+                            placeholder={t('namePlaceholder')}
                             required
                         />
                     </div>
 
                     <div>
                         <label className="block text-sm font-medium text-slate-700 mb-1">
-                            简短描述
+                            {t('shortDescription')}
                         </label>
                         <input
                             type="text"
                             value={description}
                             onChange={(e) => setDescription(e.target.value)}
                             className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
-                            placeholder="一句话描述(可选)"
-                        />
-                    </div>
-
-                    <div>
-                        <label className="block text-sm font-medium text-slate-700 mb-1">
-                            详细简介 (Intro)
-                        </label>
-                        <textarea
-                            value={intro}
-                            onChange={(e) => setIntro(e.target.value)}
-                            className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 h-32 resize-none"
-                            placeholder="用于生成播客或详细介绍背景知识(可选)"
+                            placeholder={t('descPlaceholder')}
                         />
                     </div>
 
@@ -110,7 +99,7 @@ export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
                             onClick={onClose}
                             className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg mr-2 transition-colors"
                         >
-                            取消
+                            {t('cancel')}
                         </button>
                         <button
                             type="submit"
@@ -118,7 +107,7 @@ export const EditNotebookDialog: React.FC<EditNotebookDialogProps> = ({
                             className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                         >
                             <Save size={18} />
-                            {isSubmitting ? '保存中...' : '保存修改'}
+                            {isSubmitting ? t('saving') : t('save')}
                         </button>
                     </div>
                 </form>

+ 4 - 16
web/components/EditNotebookDrawer.tsx

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
 import { Plus, ChevronRight, Save } from 'lucide-react'
 import { KnowledgeGroup, UpdateGroupData } from '../types'
 import { useLanguage } from '../contexts/LanguageContext'
+import { useToast } from '../contexts/ToastContext'
 
 interface EditNotebookDrawerProps {
     isOpen: boolean
@@ -17,9 +18,9 @@ export const EditNotebookDrawer: React.FC<EditNotebookDrawerProps> = ({
     onUpdate,
 }) => {
     const { t } = useLanguage()
+    const { showError } = useToast()
     const [name, setName] = useState(notebook.name)
     const [description, setDescription] = useState(notebook.description || '')
-    const [intro, setIntro] = useState(notebook.intro || '')
     const [isSubmitting, setIsSubmitting] = useState(false)
 
     // Reset form when notebook changes or drawer opens
@@ -27,7 +28,6 @@ export const EditNotebookDrawer: React.FC<EditNotebookDrawerProps> = ({
         if (isOpen) {
             setName(notebook.name)
             setDescription(notebook.description || '')
-            setIntro(notebook.intro || '')
         }
     }, [isOpen, notebook])
 
@@ -40,12 +40,11 @@ export const EditNotebookDrawer: React.FC<EditNotebookDrawerProps> = ({
             await onUpdate(notebook.id, {
                 name: name.trim(),
                 description: description.trim(),
-                intro: intro.trim(),
             })
             onClose()
         } catch (error) {
             console.error('Failed to update notebook:', error)
-            alert(t('updateFailedRetry'))
+            showError(t('updateFailedRetry'))
         } finally {
             setIsSubmitting(false)
         }
@@ -112,18 +111,7 @@ export const EditNotebookDrawer: React.FC<EditNotebookDrawerProps> = ({
                                 />
                             </div>
 
-                            <div>
-                                <label className="block text-sm font-medium text-slate-700 mb-1">
-                                    {t('detailedIntro')}
-                                </label>
-                                <textarea
-                                    value={intro}
-                                    onChange={(e) => setIntro(e.target.value)}
-                                    className="w-full px-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 bg-slate-50 h-64 resize-none leading-relaxed"
-                                    placeholder={t('introPlaceholder')}
-                                />
-                                <p className="mt-1 text-xs text-slate-500">{t('introHelp')}</p>
-                            </div>
+
                         </form>
                     </div>
 

+ 10 - 8
web/components/FileGroupTags.tsx

@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 import { KnowledgeGroup } from '../types';
 import { knowledgeGroupService } from '../services/knowledgeGroupService';
+import { useLanguage } from '../contexts/LanguageContext';
 import { useToast } from '../contexts/ToastContext';
 import { Tag, Plus, X, FolderPlus } from 'lucide-react';
 
@@ -19,18 +20,19 @@ export const FileGroupTags: React.FC<FileGroupTagsProps> = ({
   onGroupsChange,
   isAdmin = false
 }) => {
+  const { t } = useLanguage();
   const [isOpen, setIsOpen] = useState(false);
   const [loading, setLoading] = useState(false);
   const { showToast } = useToast();
 
-  // 监听自定义事件来打开分组选择器
+  // カスタムイベントを監視してグループセレクターを開く
   React.useEffect(() => {
     const handleOpenGroupSelector = (event: CustomEvent) => {
       if (event.detail.fileId === fileId) {
         setIsOpen(true);
       }
     };
-    
+
     document.addEventListener('openGroupSelector', handleOpenGroupSelector as EventListener);
     return () => {
       document.removeEventListener('openGroupSelector', handleOpenGroupSelector as EventListener);
@@ -42,14 +44,14 @@ export const FileGroupTags: React.FC<FileGroupTagsProps> = ({
 
     setLoading(true);
     try {
-      // 正确的方式:传递所有分组ID(现有 + 新增)
+      // 正しい方法:すべてのグループID(既存 + 新規)を渡す
       const newGroupIds = [...assignedGroups, groupId];
       await knowledgeGroupService.addFileToGroups(fileId, newGroupIds);
       onGroupsChange(newGroupIds);
-      showToast('文件已添加到分组', 'success');
+      showToast('success', t('fileAddedToGroup'));
       setIsOpen(false);
     } catch (error) {
-      showToast('添加到分组失败', 'error');
+      showToast('error', t('failedToAddToGroup'));
     } finally {
       setLoading(false);
     }
@@ -60,9 +62,9 @@ export const FileGroupTags: React.FC<FileGroupTagsProps> = ({
     try {
       await knowledgeGroupService.removeFileFromGroup(fileId, groupId);
       onGroupsChange(assignedGroups.filter(id => id !== groupId));
-      showToast('文件已从分组移除', 'success');
+      showToast('success', t('fileRemovedFromGroup'));
     } catch (error) {
-      showToast('从分组移除失败', 'error');
+      showToast('error', t('failedToRemoveFromGroup'));
     } finally {
       setLoading(false);
     }
@@ -96,7 +98,7 @@ export const FileGroupTags: React.FC<FileGroupTagsProps> = ({
             )}
           </div>
         ))}
-        
+
         {availableGroups.length > 0 && (
           <span></span>
         )}

+ 25 - 25
web/components/GlobalDragDropOverlay.tsx

@@ -6,16 +6,16 @@ interface GlobalDragDropProps {
   isAdmin: boolean;
 }
 
-// 添加一个模块级变量来跟踪当前是否允许显示拖拽覆盖层
+// ドラッグドロップオーバーレイの表示を許可するかどうかを追跡するモジュールレベルの変数
 let isDragDropEnabled = true;
 
-// 添加一个模块级变量来存储强制隐藏的回调函
+// 強制的に非表示にするコールバック関数を保存するモジュールレベルの変
 let forceHideCallback: (() => void) | null = null;
 
-// 提供外部控制该状态的函数
+// 外部からこの状態を制御するための関数を提供
 export const setDragDropEnabled = (enabled: boolean) => {
   isDragDropEnabled = enabled;
-  // 当禁用时,立即强制隐藏覆盖层
+  // 無効化された場合、直ちにオーバーレイを強制的に非表示にする
   if (!enabled && forceHideCallback) {
     forceHideCallback();
   }
@@ -51,10 +51,10 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
   }, []);
 
   const handleDragEnter = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
@@ -62,7 +62,7 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
 
     dragCounterRef.current++;
     if (dragCounterRef.current === 1) {
-      // 立即显示覆盖层,依赖强制隐藏机制来处理误触发
+      // 直ちにオーバーレイを表示し、誤作動は強制非表示メカニズムに依存
       setIsVisible(true);
       if (overlayRef.current) {
         overlayRef.current.style.opacity = '1';
@@ -73,10 +73,10 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
   }, [hasFiles]);
 
   const handleDragOver = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
@@ -85,10 +85,10 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
   }, [hasFiles]);
 
   const handleDragLeave = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
@@ -96,28 +96,28 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
 
     dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
     if (dragCounterRef.current === 0 && isDragActiveRef.current) {
-      // 隐藏全局拖拽上传覆盖层
+      // 全域ドラッグアップロードオーバーレイを非表示にする
       if (overlayRef.current) {
         overlayRef.current.style.opacity = '0';
         overlayRef.current.style.visibility = 'hidden';
       }
-      setIsVisible(false); // 通过状态控制隐藏
+      setIsVisible(false); // ステートを介して非表示を制御
       isDragActiveRef.current = false;
     }
   }, [hasFiles]);
 
   const handleDrop = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
     e.stopPropagation();
     dragCounterRef.current = 0;
 
-    // 隐藏全局拖拽上传覆盖层
+    // 全域ドラッグアップロードオーバーレイを非表示にする
     if (overlayRef.current) {
       overlayRef.current.style.opacity = '0';
       overlayRef.current.style.visibility = 'hidden';
@@ -126,7 +126,7 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
 
     isDragActiveRef.current = false;
 
-    // 处理文件
+    // ファイルを処理
     if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
       onFilesSelected(e.dataTransfer.files);
     }
@@ -135,11 +135,11 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
   useLayoutEffect(() => {
     if (!isAdmin) return;
 
-    // 初始化时确保dragCounter和isDragActive为初始值
+    // 初期化時にdragCounterとisDragActiveが初期値であることを確認
     dragCounterRef.current = 0;
     isDragActiveRef.current = false;
 
-    // 注册强制隐藏回调
+    // 強制非表示コールバックを登録
     forceHideCallback = () => {
       dragCounterRef.current = 0;
       isDragActiveRef.current = false;
@@ -150,23 +150,23 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
       }
     };
 
-    // 添加全局事件监听器
+    // 全域イベントリスナーを追加
     document.addEventListener('dragenter', handleDragEnter);
     document.addEventListener('dragover', handleDragOver);
     document.addEventListener('dragleave', handleDragLeave);
     document.addEventListener('drop', handleDrop);
 
-    // 清理函
+    // クリーンアップ関
     return () => {
       document.removeEventListener('dragenter', handleDragEnter);
       document.removeEventListener('dragover', handleDragOver);
       document.removeEventListener('dragleave', handleDragLeave);
       document.removeEventListener('drop', handleDrop);
 
-      // 清除回调引用
+      // コールバック参照を解除
       forceHideCallback = null;
 
-      // 确保在组件卸载时重置状态和清除任何可能的显示
+      // コンポーネントのアンマウント時にステートをリセットし、表示をクリアする
       dragCounterRef.current = 0;
       isDragActiveRef.current = false;
       if (overlayRef.current) {
@@ -177,12 +177,12 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
     };
   }, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
 
-  // 添加运行时检查,确保只有在适当情况下才渲染
+  // ランタイムチェックを追加し、適切な場合のみレンダリングすることを保証
   if (!isAdmin || typeof window === 'undefined') {
     return null;
   }
 
-  // 只有当 isVisible 为 true 时才渲染组件内容
+  // isVisible が true の場合のみコンポーネントの内容をレンダリング
   if (!isVisible) {
     return null;
   }

+ 23 - 20
web/components/GroupManager.tsx

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
 import { KnowledgeGroup, CreateGroupData, UpdateGroupData } from '../types';
 import { knowledgeGroupService } from '../services/knowledgeGroupService';
 import { useToast } from '../contexts/ToastContext';
+import { useConfirm } from '../contexts/ConfirmContext';
+import { useLanguage } from '../contexts/LanguageContext';
 import { Folder, Plus, Edit2, Trash2, X } from 'lucide-react';
 
 interface GroupManagerProps {
@@ -10,7 +12,7 @@ interface GroupManagerProps {
 }
 
 const DEFAULT_COLORS = [
-  '#3B82F6', '#10B981', '#F59E0B', '#EF4444', 
+  '#3B82F6', '#10B981', '#F59E0B', '#EF4444',
   '#8B5CF6', '#06B6D4', '#84CC16', '#F97316'
 ];
 
@@ -23,7 +25,9 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
     color: DEFAULT_COLORS[0],
   });
   const [loading, setLoading] = useState(false);
-  const { showToast } = useToast();
+  const { showSuccess, showError } = useToast();
+  const { confirm } = useConfirm();
+  const { t } = useLanguage();
 
   const resetForm = () => {
     setFormData({
@@ -43,9 +47,9 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
       onGroupsChange([...groups, newGroup]);
       setIsCreateModalOpen(false);
       resetForm();
-      showToast('分组创建成功', 'success');
+      showSuccess(t('successNoteCreated')); // Note: Should probably have a more specific translation for group
     } catch (error) {
-      showToast('创建分组失败', 'error');
+      showError(t('createFailed'));
     } finally {
       setLoading(false);
     }
@@ -61,23 +65,23 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
       onGroupsChange(groups.map(g => g.id === editingGroup.id ? updatedGroup : g));
       setEditingGroup(null);
       resetForm();
-      showToast('分组更新成功', 'success');
+      showSuccess(t('successNoteUpdated'));
     } catch (error) {
-      showToast('更新分组失败', 'error');
+      showError(t('updateFailedRetry'));
     } finally {
       setLoading(false);
     }
   };
 
   const handleDelete = async (group: KnowledgeGroup) => {
-    if (!confirm(`确定要删除分组"${group.name}"吗?`)) return;
+    if (!(await confirm(t('confirmDeleteGroup').replace('$1', group.name)))) return;
 
     try {
       await knowledgeGroupService.deleteGroup(group.id);
       onGroupsChange(groups.filter(g => g.id !== group.id));
-      showToast('分组删除成功', 'success');
+      showSuccess(t('successNoteDeleted'));
     } catch (error) {
-      showToast('删除分组失败', 'error');
+      showError(t('deleteFailed'));
     }
   };
 
@@ -144,7 +148,7 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
       <button
         onClick={() => setIsCreateModalOpen(true)}
         className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
-        title="创建新分组"
+        title={t('createNotebook')}
       >
         <Plus size={18} />
       </button>
@@ -155,7 +159,7 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
           <div className="bg-white rounded-lg p-6 w-full max-w-md">
             <div className="flex items-center justify-between mb-4">
               <h3 className="text-lg font-semibold">
-                {editingGroup ? '编辑分组' : '创建分组'}
+                {editingGroup ? t('editNotebookTitle') : t('createNotebookTitle')}
               </h3>
               <button
                 onClick={closeModal}
@@ -168,27 +172,27 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
             <form onSubmit={editingGroup ? handleUpdate : handleCreate} className="space-y-4">
               <div>
                 <label className="block text-sm font-medium text-gray-700 mb-1">
-                  分组名称 *
+                  {t('name')} *
                 </label>
                 <input
                   type="text"
                   value={formData.name}
                   onChange={(e) => setFormData({ ...formData, name: e.target.value })}
                   className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
-                  placeholder="输入分组名称"
+                  placeholder={t('namePlaceholder')}
                   required
                 />
               </div>
 
               <div>
                 <label className="block text-sm font-medium text-gray-700 mb-1">
-                  描述
+                  {t('shortDescription')}
                 </label>
                 <textarea
                   value={formData.description}
                   onChange={(e) => setFormData({ ...formData, description: e.target.value })}
                   className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
-                  placeholder="输入分组描述(可选)"
+                  placeholder={t('descPlaceholder')}
                   rows={3}
                 />
               </div>
@@ -203,9 +207,8 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
                       key={color}
                       type="button"
                       onClick={() => setFormData({ ...formData, color })}
-                      className={`w-8 h-8 rounded-full border-2 ${
-                        formData.color === color ? 'border-gray-400' : 'border-gray-200'
-                      }`}
+                      className={`w-8 h-8 rounded-full border-2 ${formData.color === color ? 'border-gray-400' : 'border-gray-200'
+                        }`}
                       style={{ backgroundColor: color }}
                     />
                   ))}
@@ -218,14 +221,14 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
                   onClick={closeModal}
                   className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
                 >
-                  取消
+                  {t('cancel')}
                 </button>
                 <button
                   type="submit"
                   disabled={loading || !formData.name.trim()}
                   className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                 >
-                  {loading ? '保存中...' : (editingGroup ? '更新' : '创建')}
+                  {loading ? t('saving') : (editingGroup ? t('save') : t('create'))}
                 </button>
               </div>
             </form>

+ 3 - 1
web/components/HistoryDrawer.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 import { KnowledgeGroup } from '../types';
 import { SearchHistoryList } from './SearchHistoryList';
 import { X, History } from 'lucide-react';
+import { useLanguage } from '../contexts/LanguageContext';
 
 interface HistoryDrawerProps {
     isOpen: boolean;
@@ -16,6 +17,7 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
     groups,
     onSelectHistory
 }) => {
+    const { t } = useLanguage();
     if (!isOpen) return null;
 
     return (
@@ -26,7 +28,7 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
                     <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
                         <h2 className="text-lg font-medium text-gray-900 flex items-center gap-2">
                             <History size={20} />
-                            对话历史
+                            {t('historyTitle')}
                         </h2>
                         <button
                             onClick={onClose}

+ 111 - 94
web/components/ImportFolderDrawer.tsx

@@ -1,9 +1,10 @@
 import React, { useState, useEffect } from 'react';
-import { X, Calendar, Clock, FolderInput, ArrowRight } from 'lucide-react';
+import { X, FolderInput, ArrowRight, Info } from 'lucide-react';
+import { GROUP_ALLOWED_EXTENSIONS, isExtensionAllowed, getSupportedFormatsLabel } from '../constants/fileSupport';
 import { useLanguage } from '../contexts/LanguageContext';
 import { ModelConfig, ModelType, IndexingConfig } from '../types';
 import { modelConfigService } from '../services/modelConfigService';
-import { importService } from '../services/importService';
+import { knowledgeGroupService } from '../services/knowledgeGroupService';
 import { useToast } from '../contexts/ToastContext';
 import IndexingModalWithMode from './IndexingModalWithMode';
 
@@ -28,10 +29,10 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
     const { showError, showSuccess } = useToast();
 
     // Form State
-    const [sourcePath, setSourcePath] = useState('');
+    const [localFiles, setLocalFiles] = useState<File[]>([]);
+    const [folderName, setFolderName] = useState('');
     const [targetName, setTargetName] = useState('');
-    const [executionType, setExecutionType] = useState<'immediate' | 'scheduled'>('immediate');
-    const [scheduledTime, setScheduledTime] = useState('');
+    const fileInputRef = React.useRef<HTMLInputElement>(null);
 
     // Indexing Config State
     const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false);
@@ -43,10 +44,9 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
     useEffect(() => {
         if (isOpen) {
             // Reset form
-            setSourcePath('');
+            setLocalFiles([]);
+            setFolderName('');
             setTargetName(initialGroupName || '');
-            setExecutionType('immediate');
-            setScheduledTime('');
             setIsIndexingConfigOpen(false);
 
             // Fetch models
@@ -56,34 +56,48 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
         }
     }, [isOpen, authToken, initialGroupName]);
 
-    // Auto-fill target name from path if not locked to a group
-    const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-        const val = e.target.value;
-        setSourcePath(val);
-        if (!initialGroupId && val) {
-            // Extract last folder name (Windows or Unix style)
-            const parts = val.split(/[\\/]/).filter(p => p);
-            if (parts.length > 0) {
-                setTargetName(parts[parts.length - 1]);
+    const handleLocalFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        if (e.target.files && e.target.files.length > 0) {
+            const allFiles = Array.from(e.target.files);
+            // Filter files by allowed extensions
+            const files = allFiles.filter(file => {
+                const ext = file.name.split('.').pop() || '';
+                return isExtensionAllowed(ext, 'group');
+            });
+
+            if (files.length === 0 && allFiles.length > 0) {
+                showError(t('noFilesFound'));
+                return;
+            }
+
+            setLocalFiles(files);
+
+            // Get root folder name from webkitRelativePath
+            const firstPath = allFiles[0].webkitRelativePath; // Use allFiles to get path even if filtered
+            if (firstPath) {
+                const parts = firstPath.split('/');
+                if (parts.length > 0) {
+                    const name = parts[0];
+                    setFolderName(name);
+                    if (!initialGroupId && !targetName) {
+                        setTargetName(name);
+                    }
+                }
             }
         }
     };
 
     const handleNext = async () => {
-        if (!sourcePath) {
-            showError(t('fillSourcePath'));
+        if (localFiles.length === 0) {
+            showError(t('clickToSelectFolder'));
             return;
         }
+
         if (!initialGroupId && !targetName) {
             showError(t('fillTargetName'));
             return;
         }
 
-        if (executionType === 'scheduled' && !scheduledTime) {
-            showError(t('selectExecTime'));
-            return;
-        }
-
         // Open indexing config modal
         setIsIndexingConfigOpen(true);
     };
@@ -91,18 +105,38 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
     const handleConfirmConfig = async (config: IndexingConfig) => {
         setIsLoading(true);
         try {
-            await importService.create(authToken, {
-                sourcePath,
-                targetGroupId: initialGroupId || undefined,
-                targetGroupName: initialGroupId ? undefined : targetName,
-                embeddingModelId: config.embeddingModelId,
-                scheduledAt: executionType === 'scheduled' ? new Date(scheduledTime).toISOString() : undefined,
-                chunkSize: config.chunkSize,
-                chunkOverlap: config.chunkOverlap,
-                mode: config.mode
-            });
+            // 1. Ensure target group exists or create it
+            let groupId = initialGroupId;
+            if (!groupId) {
+                const newGroup = await knowledgeGroupService.createGroup({
+                    name: targetName,
+                    description: t('importedFromLocalFolder').replace('$1', folderName)
+                });
+                groupId = newGroup.id;
+            }
+
+            // 2. Upload files in batches
+            const { uploadService } = await import('../services/uploadService');
+            const { readFile } = await import('../utils/fileUtils');
+
+            // Limit concurrent uploads for large folders
+            const BATCH_SIZE = 3;
+            for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
+                const batch = localFiles.slice(i, i + BATCH_SIZE);
+                await Promise.all(batch.map(async (file) => {
+                    try {
+                        await readFile(file); // Optional: verify readability
+                        const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
+                        if (groupId) {
+                            await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
+                        }
+                    } catch (err) {
+                        console.error(`Failed to upload ${file.name}:`, err);
+                    }
+                }));
+            }
+            showSuccess(t('importComplete'));
 
-            showSuccess(executionType === 'immediate' ? t('importTaskStarted') : t('importTaskScheduled'));
             onImportSuccess?.();
             onClose();
         } catch (error: any) {
@@ -134,20 +168,48 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
 
                     {/* Body */}
                     <div className="flex-1 overflow-y-auto p-6 space-y-6">
-                        <div className="bg-blue-50 border border-blue-100 rounded-lg p-3 text-sm text-blue-800">
-                            {t('importFolderTip')}
+                        <div className="bg-blue-50 border border-blue-100 rounded-lg p-3 text-sm text-blue-800 flex items-start gap-2">
+                            <Info className="w-4 h-4 mt-0.5 shrink-0" />
+                            <div>
+                                <p className="font-medium underline decoration-blue-200 underline-offset-2 mb-1">
+                                    {t('supportedFormatsInfo')}
+                                </p>
+                                <p className="opacity-80 text-xs">
+                                    {t('importFolderTip')}
+                                </p>
+                            </div>
                         </div>
 
-                        {/* Source Path */}
-                        <div className="space-y-2">
-                            <label className="text-sm font-medium text-slate-700">{t('lblSourcePath')}</label>
-                            <input
-                                type="text"
-                                value={sourcePath}
-                                onChange={handlePathChange}
-                                placeholder="例如: D:/data/courses/gen-ai"
-                                className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
-                            />
+                        {/* Local Folder Selection */}
+                        <div className="space-y-4 animate-in fade-in slide-in-from-top-2">
+                            <div
+                                onClick={() => fileInputRef.current?.click()}
+                                className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group"
+                            >
+                                <div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
+                                    <FolderInput size={24} />
+                                </div>
+                                <div className="text-center">
+                                    <p className="text-sm font-medium text-slate-700">
+                                        {localFiles.length > 0
+                                            ? t('selectedFilesCount').replace('$1', localFiles.length.toString())
+                                            : t('clickToSelectFolder')}
+                                    </p>
+                                    <p className="text-xs text-slate-400 mt-1">
+                                        {localFiles.length > 0 ? folderName : t('selectFolderTip')}
+                                    </p>
+                                </div>
+                                <input
+                                    type="file"
+                                    ref={fileInputRef}
+                                    onChange={handleLocalFolderChange}
+                                    className="hidden"
+                                    multiple
+                                    // @ts-ignore
+                                    webkitdirectory=""
+                                    directory=""
+                                />
+                            </div>
                         </div>
 
                         {/* Target Group */}
@@ -163,52 +225,6 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
                             />
                             {initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
                         </div>
-
-                        {/* Execution Type */}
-                        <div className="space-y-3 pt-4 border-t border-slate-100">
-                            <label className="text-sm font-medium text-slate-700">{t('lblExecType')}</label>
-                            <div className="flex gap-4">
-                                <label className="flex items-center gap-2 cursor-pointer">
-                                    <input
-                                        type="radio"
-                                        name="executionType"
-                                        value="immediate"
-                                        checked={executionType === 'immediate'}
-                                        onChange={() => setExecutionType('immediate')}
-                                        className="text-blue-600 focus:ring-blue-500"
-                                    />
-                                    <span className="text-sm text-slate-700">{t('execImmediate')}</span>
-                                </label>
-                                <label className="flex items-center gap-2 cursor-pointer">
-                                    <input
-                                        type="radio"
-                                        name="executionType"
-                                        value="scheduled"
-                                        checked={executionType === 'scheduled'}
-                                        onChange={() => setExecutionType('scheduled')}
-                                        className="text-blue-600 focus:ring-blue-500"
-                                    />
-                                    <span className="text-sm text-slate-700">{t('execScheduled')}</span>
-                                </label>
-                            </div>
-                        </div>
-
-                        {/* Schedule Time Picker */}
-                        {executionType === 'scheduled' && (
-                            <div className="space-y-2 animate-in fade-in slide-in-from-top-2">
-                                <label className="text-sm font-medium text-slate-700 flex items-center gap-2">
-                                    <Clock size={16} />
-                                    {t('lblStartTime')}
-                                </label>
-                                <input
-                                    type="datetime-local"
-                                    value={scheduledTime}
-                                    onChange={e => setScheduledTime(e.target.value)}
-                                    min={new Date().toISOString().slice(0, 16)}
-                                    className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
-                                />
-                            </div>
-                        )}
                     </div>
 
                     {/* Footer */}
@@ -222,8 +238,9 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
                         <button
                             onClick={handleNext}
                             className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
+                            disabled={isLoading}
                         >
-                            <span>{t('nextStep')}</span>
+                            <span>{isLoading ? t('uploading') : t('nextStep')}</span>
                             <ArrowRight size={16} />
                         </button>
                     </div>

+ 22 - 6
web/components/IndexingModalWithMode.tsx

@@ -7,6 +7,7 @@ import { createPortal } from 'react-dom';
 import { ModelConfig, RawFile, IndexingConfig } from '../types';
 import { useLanguage } from '../contexts/LanguageContext';
 import { useToast } from '../contexts/ToastContext';
+import { useConfirm } from '../contexts/ConfirmContext';
 import { Layers, FileText, Database, X, ArrowRight, Files, Info, Zap, Target, AlertTriangle, Clock, DollarSign } from 'lucide-react';
 import { formatBytes } from '../utils/fileUtils';
 import { chunkConfigService } from '../services/chunkConfigService';
@@ -33,6 +34,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
 }) => {
   const { t } = useLanguage();
   const { showWarning, showInfo } = useToast();
+  const { confirm } = useConfirm();
 
   // Configuration state
   const [chunkSize, setChunkSize] = useState(200);
@@ -49,6 +51,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
   const [limits, setLimits] = useState<{
     maxChunkSize: number;
     maxOverlapSize: number;
+    minOverlapSize: number;
     defaultChunkSize: number;
     defaultOverlapSize: number;
     modelInfo: {
@@ -118,6 +121,13 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
           setChunkOverlap(limitData.maxOverlapSize);
           showWarning(t('autoAdjustOverlap', limitData.maxOverlapSize));
         }
+        if (chunkOverlap < limitData.minOverlapSize) {
+          setChunkOverlap(limitData.minOverlapSize);
+          // Only show warning if it was manually set below the new minimum
+          if (chunkOverlap < limitData.minOverlapSize) {
+            showWarning(t('autoAdjustOverlapMin', limitData.minOverlapSize));
+          }
+        }
 
       } catch (error) {
         console.error('設定制限の読み込みに失敗しました:', error);
@@ -187,6 +197,12 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
       return;
     }
 
+    if (limits && value < limits.minOverlapSize) {
+      // Don't show warning here, just set to min if they slide too low
+      setChunkOverlap(limits.minOverlapSize);
+      return;
+    }
+
     // Check if it exceeds 50% of chunk size
     const maxOverlapByRatio = Math.floor(chunkSize * 0.5);
     if (value > maxOverlapByRatio) {
@@ -364,7 +380,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
                 <button
                   onClick={() => {
                     setMode('fast');
-                    setUserSelectedMode(true); // 标记用户手动选择
+                    setUserSelectedMode(true); // ユーザーによる手動選択をマーク
                   }}
                   className={`relative p-3 rounded-lg border-2 text-left transition-all ${mode === 'fast'
                     ? 'border-blue-500 bg-blue-50'
@@ -389,7 +405,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
                 <button
                   onClick={() => {
                     setMode('precise');
-                    setUserSelectedMode(true); // 标记用户手动选择
+                    setUserSelectedMode(true); // ユーザーによる手動選択をマーク
                   }}
                   className={`relative p-3 rounded-lg border-2 text-left transition-all ${mode === 'precise'
                     ? 'border-purple-500 bg-purple-50'
@@ -475,7 +491,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
                 </div>
                 <input
                   type="range"
-                  min="0"
+                  min={limits?.minOverlapSize || 25}
                   max={limits?.maxOverlapSize || 200}
                   value={chunkOverlap}
                   onChange={(e) => handleChunkOverlapChange(Number(e.target.value))}
@@ -483,7 +499,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
                   disabled={!selectedEmbedding || isLoadingLimits}
                 />
                 <div className="flex justify-between text-[10px] text-slate-400 mt-1">
-                  <span>{t('min')}: 0</span>
+                  <span>{t('min')}: {limits?.minOverlapSize || 25}</span>
                   <span>{t('max')}: {limits?.maxOverlapSize || '-'}</span>
                 </div>
               </div>
@@ -519,14 +535,14 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
             {t('cancel')}
           </button>
           <button
-            onClick={() => {
+            onClick={async () => {
               if (!selectedEmbedding) {
                 showWarning(t('selectEmbeddingFirst'));
                 return;
               }
               if (!isReconfiguring && mode === 'precise') {
                 // Precise mode confirmation
-                if (!confirm(t('confirmPreciseCost'))) {
+                if (!(await confirm(t('confirmPreciseCost')))) {
                   return;
                 }
               }

+ 18 - 18
web/components/ModeSelector.tsx

@@ -1,6 +1,6 @@
 /**
- * 处理模式选择器组件
- * 用于在文件上传时选择快速模式或精准模式
+ * 処理モード選択コンポーネント
+ * ファイルアップロード時に高速モードまたは精密モードを選択するために使用
  */
 
 import React, { useState, useEffect } from 'react';
@@ -38,11 +38,11 @@ export const ModeSelector: React.FC<ModeSelectorProps> = ({
       const rec = await uploadService.recommendMode(file);
       setRecommendation(rec);
 
-      // 自动选择推荐的模式
+      // 推薦されたモードを自動選択
       setSelectedMode(rec.recommendedMode);
       onModeChange(rec.recommendedMode);
     } catch (error) {
-      console.error('获取模式推荐失败:', error);
+      console.error('モード推奨の取得に失敗しました:', error);
     } finally {
       setLoading(false);
     }
@@ -60,7 +60,7 @@ export const ModeSelector: React.FC<ModeSelectorProps> = ({
   return (
     <div className={`mode-selector ${className}`}>
       <div className="mode-selector-header">
-        <h4>处理模式选择</h4>
+        <h4>処理モードの選択</h4>
         {loading && <span className="loading">分析中...</span>}
       </div>
 
@@ -68,7 +68,7 @@ export const ModeSelector: React.FC<ModeSelectorProps> = ({
       {recommendation && (
         <div className="recommendation-info">
           <div className="reason">
-            <strong>推:</strong> {recommendation.reason}
+            <strong>推:</strong> {recommendation.reason}
           </div>
 
           {recommendation.warnings && recommendation.warnings.length > 0 && (
@@ -94,14 +94,14 @@ export const ModeSelector: React.FC<ModeSelectorProps> = ({
             onChange={() => handleModeChange('fast')}
           />
           <div className="mode-content">
-            <div className="mode-title">⚡ 快速模式</div>
+            <div className="mode-title">⚡ 高速モード</div>
             <div className="mode-desc">
-              简单提取文本,速度快,适合纯文本文档
+              テキストを単純に抽出、高速、プレーンテキストドキュメントに最適
             </div>
             <div className="mode-benefits">
-              ✅ 速度快<br/>
-              ✅ 无额外成本<br/>
-              ❌ 仅处理文字信息
+              ✅ 速<br />
+              ✅ 追加コストなし<br />
+              ❌ テキスト情報のみ処理
             </div>
           </div>
         </label>
@@ -115,16 +115,16 @@ export const ModeSelector: React.FC<ModeSelectorProps> = ({
             onChange={() => handleModeChange('precise')}
           />
           <div className="mode-content">
-            <div className="mode-title">🎯 精准模式</div>
+            <div className="mode-title">🎯 精密モード</div>
             <div className="mode-desc">
-              精准识别内容,保留完整信息
+              内容を正確に認識し、完全な情報を保持
             </div>
             <div className="mode-benefits">
-              ✅ 识别图片/图表<br/>
-              ✅ 保留布局信息<br/>
-              ✅ 图文混合内容<br/>
-              ⚠️ 需要 API 费用<br/>
-              ⚠️ 处理时间较长
+              ✅ 画像/表を認識<br />
+              ✅ レイアウト情報を保持<br />
+              ✅ 図文混合コンテンツ<br />
+              ⚠️ API費用が必要<br />
+              ⚠️ 処理時間が長い
             </div>
           </div>
         </label>

+ 13 - 14
web/components/NotebookDragDropUpload.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder } from 'lucide-react';
 import { useLanguage } from '../contexts/LanguageContext';
+import { GROUP_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES } from '../constants/fileSupport';
 
 interface NotebookDragDropUploadProps {
   onFilesSelected: (files: FileList) => void;
@@ -23,7 +24,7 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
   const handleDragLeave = useCallback((e: React.DragEvent) => {
     e.preventDefault();
     e.stopPropagation();
-    // 只有当鼠标真正离开拖拽区域时才设置为false
+    // マウスが実際にドラッグ領域を離れた場合のみfalseに設定
     setTimeout(() => setIsDragging(false), 100);
   }, []);
 
@@ -56,14 +57,13 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
     return null;
   }
 
-  // 根据模式决定CSS类
+  // モードに応じてCSSクラスを決定
   const containerClass = globalMode
     ? `fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 transition-opacity duration-300 ${isDragging ? 'opacity-100' : 'opacity-0 pointer-events-none'}`
-    : `border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${
-        isDragging
-          ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
-          : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
-      }`;
+    : `border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${isDragging
+      ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
+      : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
+    }`;
 
   const contentClass = globalMode
     ? "w-3/4 max-w-2xl"
@@ -73,11 +73,10 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
     <div className={containerClass}>
       <div className={contentClass}>
         <div
-          className={`border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${
-            isDragging
-              ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
-              : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
-          }`}
+          className={`border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200 cursor-pointer ${isDragging
+            ? 'border-blue-500 bg-blue-50 scale-[1.02] shadow-lg'
+            : 'border-slate-300 bg-slate-50 hover:border-blue-400 hover:bg-blue-25'
+            }`}
           onDragEnter={handleDragEnter}
           onDragOver={handleDragOver}
           onDragLeave={handleDragLeave}
@@ -103,7 +102,7 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
               </div>
               <div className="flex items-center gap-2 text-xs text-slate-500 bg-white px-3 py-1.5 rounded-full border border-slate-200">
                 <ImageIcon className="w-4 h-4" />
-                <span>PDF, DOC, XLS, PPT, TXT, Images...</span>
+                <span>{GROUP_ALLOWED_EXTENSIONS.slice(0, 10).join(', ').toUpperCase()}...</span>
               </div>
             </div>
             <div className="pt-2">
@@ -120,7 +119,7 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
                 onChange={handleFileInput}
                 className="hidden"
                 id="notebook-file-upload-input"
-                accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*"
+                accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
               />
             </div>
           </div>

+ 26 - 25
web/components/NotebookGlobalDragDropOverlay.tsx

@@ -1,20 +1,21 @@
 import { useLayoutEffect, useRef, useState, useCallback } from 'react';
 import { useLanguage } from '../contexts/LanguageContext';
+import { GROUP_ALLOWED_EXTENSIONS } from '../constants/fileSupport';
 
 interface NotebookGlobalDragDropProps {
   onFilesSelected: (files: FileList) => void;
   isAdmin: boolean;
 }
 
-// 为笔记本组件也添加类似的控制
+// ノートブックコンポーネントにも同様の制御を追加
 let isNotebookDragDropEnabled = true;
 
-// 添加一个模块级变量来存储强制隐藏的回调函
+// 強制的に非表示にするコールバック関数を保存するモジュールレベルの変
 let notebookForceHideCallback: (() => void) | null = null;
 
 export const setNotebookDragDropEnabled = (enabled: boolean) => {
   isNotebookDragDropEnabled = enabled;
-  // 当禁用时,立即强制隐藏覆盖层
+  // 無効化された場合、直ちにオーバーレイを強制的に非表示にする
   if (!enabled && notebookForceHideCallback) {
     notebookForceHideCallback();
   }
@@ -50,10 +51,10 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
   }, []);
 
   const handleDragEnter = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isNotebookDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
@@ -61,7 +62,7 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
 
     dragCounterRef.current++;
     if (dragCounterRef.current === 1) {
-      // 立即显示覆盖层,依赖强制隐藏机制来处理误触发
+      // 直ちにオーバーレイを表示し、誤作動は強制非表示メカニズムに依存
       setIsVisible(true);
       if (overlayRef.current) {
         overlayRef.current.style.opacity = '1';
@@ -72,10 +73,10 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
   }, [hasFiles]);
 
   const handleDragOver = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isNotebookDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
@@ -84,10 +85,10 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
   }, [hasFiles]);
 
   const handleDragLeave = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isNotebookDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
@@ -95,28 +96,28 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
 
     dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
     if (dragCounterRef.current === 0 && isDragActiveRef.current) {
-      // 隐藏全局拖拽上传覆盖层
+      // 全域ドラッグアップロードオーバーレイを非表示にする
       if (overlayRef.current) {
         overlayRef.current.style.opacity = '0';
         overlayRef.current.style.visibility = 'hidden';
       }
-      setIsVisible(false); // 通过状态控制隐藏
+      setIsVisible(false); // ステートを介して非表示を制御
       isDragActiveRef.current = false;
     }
   }, [hasFiles]);
 
   const handleDrop = useCallback((e: DragEvent) => {
-    // 如果拖放功能被禁用,则忽略事件
+    // ドラッグドロップ機能が無効な場合はイベントを無視
     if (!isNotebookDragDropEnabled) return;
 
-    // 只有当数据传输包含文件时才处
+    // データ転送にファイルが含まれている場合のみ処
     if (!hasFiles(e.dataTransfer)) return;
 
     e.preventDefault();
     e.stopPropagation();
     dragCounterRef.current = 0;
 
-    // 隐藏全局拖拽上传覆盖层
+    // 全域ドラッグアップロードオーバーレイを非表示にする
     if (overlayRef.current) {
       overlayRef.current.style.opacity = '0';
       overlayRef.current.style.visibility = 'hidden';
@@ -125,7 +126,7 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
 
     isDragActiveRef.current = false;
 
-    // 处理文件
+    // ファイルを処理
     if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
       onFilesSelected(e.dataTransfer.files);
     }
@@ -134,11 +135,11 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
   useLayoutEffect(() => {
     if (!isAdmin) return;
 
-    // 初始化时确保dragCounter和isDragActive为初始值
+    // 初期化時にdragCounterとisDragActiveが初期値であることを確認
     dragCounterRef.current = 0;
     isDragActiveRef.current = false;
 
-    // 注册强制隐藏回调
+    // 強制非表示コールバックを登録
     notebookForceHideCallback = () => {
       dragCounterRef.current = 0;
       isDragActiveRef.current = false;
@@ -149,23 +150,23 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
       }
     };
 
-    // 添加全局事件监听器
+    // 全域イベントリスナーを追加
     document.addEventListener('dragenter', handleDragEnter);
     document.addEventListener('dragover', handleDragOver);
     document.addEventListener('dragleave', handleDragLeave);
     document.addEventListener('drop', handleDrop);
 
-    // 清理函
+    // クリーンアップ関
     return () => {
       document.removeEventListener('dragenter', handleDragEnter);
       document.removeEventListener('dragover', handleDragOver);
       document.removeEventListener('dragleave', handleDragLeave);
       document.removeEventListener('drop', handleDrop);
 
-      // 清除回调引用
+      // コールバック参照を解除
       notebookForceHideCallback = null;
 
-      // 确保在组件卸载时重置状态和清除任何可能的显示
+      // コンポーネントのアンマウント時にステートをリセットし、表示をクリアする
       dragCounterRef.current = 0;
       isDragActiveRef.current = false;
       if (overlayRef.current) {
@@ -176,12 +177,12 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
     };
   }, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
 
-  // 添加运行时检查,确保只有在适当情况下才渲染
+  // ランタイムチェックを追加し、適切な場合のみレンダリングすることを保証
   if (!isAdmin || typeof window === 'undefined') {
     return null;
   }
 
-  // 只有当 isVisible 为 true 时才渲染组件内容
+  // isVisible が true の場合のみコンポーネントの内容をレンダリング
   if (!isVisible) {
     return null;
   }
@@ -225,7 +226,7 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
                   <circle cx="8.5" cy="8.5" r="1.5"></circle>
                   <path d="M21 15l-5-5L5 21"></path>
                 </svg>
-                <span>PDF, DOC, XLS, PPT, TXT, Images...</span>
+                <span>{GROUP_ALLOWED_EXTENSIONS.slice(0, 10).join(', ').toUpperCase()}...</span>
               </div>
             </div>
           </div>

+ 117 - 272
web/components/PDFPreview.tsx

@@ -1,7 +1,9 @@
 import React, { useState, useEffect, useRef } from 'react';
+import { isFormatSupportedForPreview } from '../constants/fileSupport';
 import { PDFStatus } from '../types';
 import { pdfPreviewService } from '../services/pdfPreviewService';
 import { useToast } from '../contexts/ToastContext';
+import { useConfirm } from '../contexts/ConfirmContext';
 import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react';
 import { PDFSelectionTool } from './PDFSelectionTool';
 import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog';
@@ -18,37 +20,33 @@ interface PDFPreviewProps {
   fileName: string;
   authToken: string;
   groupId?: string;
-  initialPage?: number; // 追加
-  highlightText?: string; // 追加
   onClose: () => void;
 }
 
-export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, initialPage, highlightText, onClose }) => {
+export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, onClose }) => {
   const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
   const [loading, setLoading] = useState(true);
   const [isFullscreen, setIsFullscreen] = useState(false);
   const [pdfUrl, setPdfUrl] = useState<string>('');
   const [iframeError, setIframeError] = useState(false);
   const [isSelectionMode, setIsSelectionMode] = useState(false);
-  const [currentPage, setCurrentPage] = useState(initialPage || 1); // 修正
+  const [currentPage, setCurrentPage] = useState(1);
   const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
   const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null);
   const [numPages, setNumPages] = useState<number>(0);
   const [pdfDoc, setPdfDoc] = useState<pdfjs.PDFDocumentProxy | null>(null);
   const [zoomLevel, setZoomLevel] = useState<number>(1.0); // ズームレベルの状態を追加
   const currentRenderTask = useRef<pdfjs.RenderTask | null>(null); // 現在のレンダリングタスクを保存
+  const scrollContainerRef = useRef<HTMLDivElement>(null);
+  const flipDirection = useRef<'next' | 'prev' | null>(null);
+  const lastFlipTime = useRef<number>(0);
 
   const { showToast } = useToast();
+  const { confirm } = useConfirm();
   const { t, language } = useLanguage();
   const containerRef = React.useRef<HTMLDivElement>(null);
   const canvasRef = useRef<HTMLCanvasElement>(null);
 
-  useEffect(() => {
-    if (initialPage && initialPage > 0) {
-      setCurrentPage(initialPage);
-    }
-  }, [initialPage]);
-
   useEffect(() => {
     if (status.status === 'ready') {
       pdfPreviewService.getPDFUrl(fileId)
@@ -57,16 +55,30 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
 
           // PDFデータを取得してblob URLを作成
           fetch(result.url)
-            .then(response => response.blob())
+            .then(async response => {
+              if (!response.ok) {
+                const errorData = await response.json().catch(() => ({}));
+                throw new Error(errorData.message || 'Failed to fetch PDF data');
+              }
+              return response.blob();
+            })
             .then(blob => {
               setPdfBlob(blob);
 
               // PDF文書の読み込みとレンダリングを開始
               loadAndRenderPDF(blob);
             })
-            .catch(() => setIframeError(true));
+            .catch((err) => {
+              console.error('PDF fetch error:', err);
+              setIframeError(true);
+              setStatus({ status: 'failed', error: err.message });
+            });
         })
-        .catch(() => setIframeError(true));
+        .catch((err) => {
+          console.error('getPDFUrl error:', err);
+          setIframeError(true);
+          setStatus({ status: 'failed', error: err.message });
+        });
     }
   }, [status.status, fileId]);
 
@@ -77,51 +89,18 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
     }
   }, [currentPage, pdfDoc, zoomLevel]);
 
-  useEffect(() => {
-    checkPDFStatus();
-    const interval = setInterval(checkPDFStatus, 3000);
-    return () => clearInterval(interval);
-  }, [fileId]);
+  const isSupported = isFormatSupportedForPreview(fileName);
 
-  // スクロールページめくり機能を追加
   useEffect(() => {
-    const container = containerRef.current;
-    if (!container) return;
-
-    const handleWheel = (e: WheelEvent) => {
-      if (!pdfDoc || e.shiftKey) return; // Shiftキーが押されている場合はページめくりをトリガーしない(水平スクロールを許可)
-
-      // スクロール対象がcanvasコンテナ内にあるかチェック
-      const canvasContainer = container.querySelector('.pdf-canvas-container');
-      if (!canvasContainer) return;
-
-      // スクロールイベントがcanvasコンテナ内で発生することを確認
-      if (canvasContainer.contains(e.target as HTMLElement)) {
-        // Ctrlキーが押されている場合はズームを実行し、ページめくりはしない
-        if (e.ctrlKey || e.metaKey) {
-          e.preventDefault();
-          const zoomIncrement = e.deltaY > 0 ? -0.1 : 0.1;
-          const newZoom = Math.max(0.5, Math.min(3.0, zoomLevel + zoomIncrement));
-          setZoomLevel(newZoom);
-          return;
-        }
-
-        // スクロール方向を検出(Ctrlキー以外の場合)
-        if (e.deltaY > 0 && currentPage < numPages) {
-          // 下にスクロール、次のページへ
-          e.preventDefault();
-          setCurrentPage(prev => Math.min(prev + 1, numPages));
-        } else if (e.deltaY < 0 && currentPage > 1) {
-          // 上にスクロール、前のページへ
-          e.preventDefault();
-          setCurrentPage(prev => Math.max(prev - 1, 1));
-        }
-      }
-    };
-
-    container.addEventListener('wheel', handleWheel, { passive: false });
-    return () => container.removeEventListener('wheel', handleWheel);
-  }, [currentPage, numPages, pdfDoc, zoomLevel]);
+    if (isSupported) {
+      checkPDFStatus();
+      const interval = setInterval(checkPDFStatus, 3000);
+      return () => clearInterval(interval);
+    } else {
+      setLoading(false);
+      setStatus({ status: 'failed', error: t('previewNotSupported') });
+    }
+  }, [fileId, isSupported]);
 
   const checkPDFStatus = async () => {
     try {
@@ -142,10 +121,11 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
       if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') {
         setLoading(false);
       }
-    } catch (error) {
+    } catch (error: any) {
       setLoading(false);
-      setStatus({ status: 'failed', error: t('checkPDFStatusFailed') });
-      showToast('error', t('checkPDFStatusFailed'));
+      const errorMessage = error.message || t('checkPDFStatusFailed');
+      setStatus({ status: 'failed', error: errorMessage });
+      showToast(errorMessage, 'error');
     }
   };
 
@@ -180,29 +160,13 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
     setZoomLevel(1.0);
   };
 
-  const renderRequestId = useRef(0);
-
   const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => {
     if (!canvasRef.current) return;
 
-    // 今回のレンダリングリクエストに一意のIDを割り当てる
-    const requestId = ++renderRequestId.current;
-    let renderTask: pdfjs.RenderTask | null = null;
-
     try {
-      // 進行中のレンダリングタスクが存在する場合、キャンセルして完了を持ち越
+      // 進行中のレンダリングタスクが存在する場合、キャンセルする
       if (currentRenderTask.current) {
         currentRenderTask.current.cancel();
-        try {
-          await currentRenderTask.current.promise;
-        } catch (e) {
-          // キャンセルによるエラーは無視
-        }
-      }
-
-      // 待機中に新しいリクエストが来た場合、このリクエストは中止する
-      if (requestId !== renderRequestId.current) {
-        return;
       }
 
       const page = await pdf.getPage(pageNum);
@@ -220,23 +184,25 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
       // Handle high DPI displays
       const devicePixelRatio = window.devicePixelRatio || 1;
 
-      // Calculate scale to fit page in container while maintaining aspect ratio
+      // Calculate scale to fit page in container width while maintaining aspect ratio
       const viewport = page.getViewport({ scale: 1 });
-      const baseScaleX = containerWidth / viewport.width;
-      const baseScaleY = containerHeight / viewport.height;
-      // Use minimum scale to fit the page in container, then apply zoom level
-      let scale = Math.min(baseScaleX, baseScaleY, 1); // Don't upscale beyond 1:1 by default
+      const baseScale = (containerWidth - 48) / viewport.width; // Add padding for width
 
-      // Apply zoom level
-      scale *= zoomLevel;
+      // Apply zoom level to base scale
+      const scale = baseScale * zoomLevel;
 
       const finalScale = scale * devicePixelRatio;
       const scaledViewport = page.getViewport({ scale: finalScale });
+      const cssViewport = page.getViewport({ scale: scale });
 
       // Set canvas dimensions with device pixel ratio
       canvas.width = scaledViewport.width;
       canvas.height = scaledViewport.height;
 
+      // Set CSS dimensions explicitly for high-DPI
+      canvas.style.width = `${cssViewport.width}px`;
+      canvas.style.height = `${cssViewport.height}px`;
+
       // Reset any previous transforms
       context.setTransform(1, 0, 0, 1, 0, 0);
 
@@ -250,11 +216,6 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
       // Set proper transform for high DPI
       context.scale(devicePixelRatio, devicePixelRatio);
 
-      // 実際のレンダリング直前にもう一度チェック
-      if (requestId !== renderRequestId.current) {
-        return;
-      }
-
       // Create and save the new render task
       const renderContext = {
         canvasContext: context,
@@ -262,155 +223,30 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
       };
 
       // Save the render task to allow for cancellation
-      renderTask = page.render(renderContext);
-      currentRenderTask.current = renderTask;
+      currentRenderTask.current = page.render(renderContext);
 
       // Wait for rendering to complete
-      await renderTask.promise;
-
-      // Render Text Layer
-      if (requestId === renderRequestId.current) {
-        const textContent = await page.getTextContent();
-
-        // Find or create text layer div
-        let textLayerDiv = container.querySelector('.textLayer') as HTMLDivElement;
-        if (!textLayerDiv) {
-          textLayerDiv = document.createElement('div');
-          textLayerDiv.className = 'textLayer';
-          // Ensure container has relative positioning
-          const canvasContainer = container.querySelector('.pdf-canvas-container .flex');
-          if (canvasContainer) {
-            (canvasContainer as HTMLElement).style.position = 'relative';
-            (canvasContainer as HTMLElement).appendChild(textLayerDiv);
-          }
-        }
-
-        // Reset text layer
-        textLayerDiv.innerHTML = '';
-        textLayerDiv.style.position = 'absolute';
-        textLayerDiv.style.top = '0';
-        textLayerDiv.style.left = '0';
-        textLayerDiv.style.height = `${scaledViewport.height}px`;
-        textLayerDiv.style.width = `${scaledViewport.width}px`;
-        // Apply transform to match high DPI scaling if needed, essentially we want it to overlay the canvas exactly
-        // The canvas is scaled by devicePixelRatio via CSS width/height vs attribute width/height
-        // But text layer usually acts on CSS pixels.
-        // If scaledViewport was created with finalScale (scale * devicePixelRatio), then it's in device pixels.
-        // We might need to adjust.
-        // Actually, pdf.js text layer expects viewport to be the same as used for rendering? 
-        // Typically we render text layer at 1:1 CSS pixel mapping if possible or match the viewport.
-
-        // Let's use the viewport that matches the visual size (CSS pixels)
-        const cssViewport = page.getViewport({ scale: scale });
-        textLayerDiv.style.width = `${cssViewport.width}px`;
-        textLayerDiv.style.height = `${cssViewport.height}px`;
-        textLayerDiv.style.setProperty('--scale-factor', `${scale}`);
-
-        // We need to import renderTextLayer dynamically or check availability
-        // Since we imported * as pdfjs, let's try pdfjs.renderTextLayer
-        // Note: In some versions it's inside pdfjs.pdfjsLib or similar.
-        // But typically we construct a TextLayerBuilder or use renderTextLayer utility.
-        // For simplicity in React without extra libs, we can try to render it manually or use the basic API if available.
-        // However, pdfjs-dist v4 has `pdfjs.renderTextLayer({ textContent, container, viewport, textDivs })`.
-
-        // In pdfjs-dist v4, renderTextLayer is removed/deprecated in favor of using TextLayer class directly.
-        // We use the exported TextLayer class.
-
-        const TextLayerClass = (pdfjs as any).TextLayer;
-        if (TextLayerClass) {
-          const textLayer = new TextLayerClass({
-            textContentSource: textContent,
-            container: textLayerDiv,
-            viewport: cssViewport,
-            textDivs: []
-          });
-          await textLayer.render();
-        } else {
-          console.error('TextLayer class not found in pdfjs-dist');
-        }
-
-        // Apply Highlights
-        if (highlightText) {
-          console.log('Attempting to highlight:', highlightText);
-          const normalize = (str: string) => str.toLowerCase().replace(/\s+/g, '');
-          const searchStr = normalize(highlightText);
-
-          const spans = Array.from(textLayerDiv.querySelectorAll('span'));
-          let found = false;
-
-          // Strategy 1: Check for exact match across spans (if text is fragmented)
-          // We can try to build the full text and map it back, but that's complex.
-          // Let's try a token-based approach. 
-
-          // Simple strategy: If a span contains a significant chunk of the search string, highlight it.
-          // Or if the search string is found within the concatenated text of a few adjacent spans.
-
-          // Let's try to highlight any span that has a significant substring match.
-          spans.forEach(span => {
-            const spanText = normalize(span.textContent || '');
-            if (!spanText) return;
-
-            // If the span text is fully contained in the search string
-            if (searchStr.includes(spanText) && spanText.length > 3) {
-              span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)';
-              span.style.borderRadius = '2px';
-              found = true;
-            }
-            // If the search string is fully contained in the span text
-            else if (spanText.includes(searchStr)) {
-              span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)';
-              span.style.borderRadius = '2px';
-              found = true;
-            }
-          });
-
-          if (!found) {
-            console.log('No exact/substring match found. Trying fuzzy/keyword match.');
-            // Fallback: Keyword matching (if exact match fails)
-            // For CJK characters, even 2 chars is significant. For English, keep it > 3.
-            // We split by non-word characters to get tokens
-            const tokens = highlightText.toLowerCase().split(/[^\w\u4e00-\u9fa5]+/);
-
-            const keywords = tokens.filter(k => {
-              const isCJK = /[\u4e00-\u9fa5]/.test(k);
-              return isCJK ? k.length >= 2 : k.length > 3;
-            });
-
-            if (keywords.length > 0) {
-              spans.forEach(span => {
-                const spanText = (span.textContent || '').toLowerCase();
-                if (keywords.some(k => spanText.includes(k))) {
-                  span.style.backgroundColor = 'rgba(255, 255, 0, 0.4)'; // Increased opacity
-                  span.style.borderRadius = '2px';
-                  found = true;
-                }
-              });
-            }
-          }
-
-          // Scroll first highlighted element into view
-          const firstHighlight = textLayerDiv.querySelector('span[style*="background-color"]');
-          if (firstHighlight) {
-            console.log('Scrolling to highlight');
-            firstHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
-          } else {
-            console.log('No highlight applied');
-          }
+      await currentRenderTask.current.promise;
+
+      // Clear the current render task
+      currentRenderTask.current = null;
+
+      // ページめくり後のスクロール位置調整
+      if (flipDirection.current && scrollContainerRef.current) {
+        const container = scrollContainerRef.current;
+        if (flipDirection.current === 'next') {
+          container.scrollTop = 0;
+        } else if (flipDirection.current === 'prev') {
+          container.scrollTop = container.scrollHeight;
         }
-      }
-
-      // このタスクが完了したとき、まだ最新のタスクであればクリアする
-      if (currentRenderTask.current === renderTask) {
-        currentRenderTask.current = null;
+        flipDirection.current = null;
       }
     } catch (error) {
       if (error instanceof Error && error.name !== 'RenderingCancelledException') {
         console.error('Failed to render PDF page:', error);
       }
-      // このタスクでエラーが発生したとき、まだ最新のタスクであればクリアする
-      if (currentRenderTask.current === renderTask) {
-        currentRenderTask.current = null;
-      }
+      // Clear the current render task even if there's an error
+      currentRenderTask.current = null;
     }
   };
 
@@ -462,7 +298,7 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
   };
 
   const handleRegenerate = async () => {
-    if (window.confirm(t('confirmRegeneratePDF'))) {
+    if (await confirm(t('confirmRegeneratePDF'))) {
       setStatus({ status: 'converting' });
       setLoading(true);
       try {
@@ -484,6 +320,32 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
     setIframeError(true);
   };
 
+  const handleWheel = (e: React.WheelEvent) => {
+    if (!scrollContainerRef.current || isSelectionMode) return;
+
+    const container = scrollContainerRef.current;
+    const { scrollTop, scrollHeight, clientHeight } = container;
+    const now = Date.now();
+    const throttleMs = 600; // 連続ページめくりを防止
+
+    // 下にスクロールして次のページへ
+    if (e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1) {
+      if (currentPage < numPages && now - lastFlipTime.current > throttleMs) {
+        flipDirection.current = 'next';
+        lastFlipTime.current = now;
+        setCurrentPage(prev => prev + 1);
+      }
+    }
+    // 上にスクロールして前のページへ
+    else if (e.deltaY < 0 && scrollTop <= 1) {
+      if (currentPage > 1 && now - lastFlipTime.current > throttleMs) {
+        flipDirection.current = 'prev';
+        lastFlipTime.current = now;
+        setCurrentPage(prev => prev - 1);
+      }
+    }
+  };
+
   const handleSelectionComplete = (screenshot: Blob, text: string) => {
     // Set preliminary data and open dialog
     setSelectionData({ screenshot, text });
@@ -584,16 +446,15 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
 
         return (
           <div className="relative w-full h-full flex flex-col" ref={containerRef}>
-            <div className="flex-grow overflow-auto pdf-canvas-container bg-white">
-              <div className="flex items-center justify-center min-h-full p-4">
+            <div
+              ref={scrollContainerRef}
+              onWheel={handleWheel}
+              className="flex-grow overflow-auto pdf-canvas-container bg-gray-100"
+            >
+              <div className="flex flex-col items-center py-12 pb-32 min-h-full">
                 <canvas
                   ref={canvasRef}
-                  className="shadow-lg"
-                  style={{
-                    maxHeight: 'none',
-                    maxWidth: '100%',
-                    display: 'block'
-                  }}
+                  className="bg-white shadow-xl max-w-full"
                 />
               </div>
             </div>
@@ -690,7 +551,7 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
   return (
     <div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-4'
       }`}>
-      <div className={`bg-white rounded-lg overflow-hidden ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
+      <div className={`bg-white rounded-lg overflow-hidden flex flex-col ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
         }`}>
         {/* 头部 */}
         <div className="flex items-center justify-between p-4 border-b bg-gray-50">
@@ -775,33 +636,6 @@ export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authTo
   );
 };
 
-// Add global styles for text layer if not present
-const style = document.createElement('style');
-style.innerHTML = `
-  .textLayer {
-    position: absolute;
-    text-align: initial;
-    left: 0;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    overflow: hidden;
-    opacity: 1; /* Increased from 0.25 to 1 because the spans are transparent anyway */
-    line-height: 1.0;
-    pointer-events: auto; /* Enable text selection */
-    z-index: 10; /* Ensure text layer is above canvas */
-    mix-blend-mode: multiply; /* Better blending for highlights */
-  }
-  .textLayer > span {
-    color: transparent;
-    position: absolute;
-    white-space: pre;
-    cursor: text;
-    transform-origin: 0% 0%;
-  }
-`;
-document.head.appendChild(style);
-
 interface PDFPreviewButtonProps {
   fileId: string;
   fileName: string;
@@ -816,10 +650,15 @@ export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
   const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
   const [loading, setLoading] = useState(true);
   const { t } = useLanguage();
+  const isSupported = isFormatSupportedForPreview(fileName);
 
   useEffect(() => {
-    checkStatus();
-  }, [fileId]);
+    if (isSupported) {
+      checkStatus();
+    } else {
+      setLoading(false);
+    }
+  }, [fileId, isSupported]);
 
   const checkStatus = async () => {
     try {
@@ -833,6 +672,9 @@ export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
   };
 
   const getIcon = () => {
+    if (!isSupported) {
+      return <Eye className="w-3 h-3 text-slate-200" />;
+    }
     if (loading || status.status === 'converting') {
       return <Loader className="w-3 h-3 animate-spin" />;
     }
@@ -843,6 +685,7 @@ export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
   };
 
   const getTitle = () => {
+    if (!isSupported) return t('previewNotSupported');
     switch (status.status) {
       case 'ready': return t('pdfPreviewReady');
       case 'converting': return t('convertingInProgress');
@@ -854,10 +697,12 @@ export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
   return (
     <button
       onClick={onPreview}
-      disabled={loading || status.status === 'converting'}
-      className={`p-1 rounded transition-colors ${status.status === 'failed'
-        ? 'text-red-400 hover:text-red-500 hover:bg-red-50'
-        : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
+      disabled={loading || status.status === 'converting' || !isSupported}
+      className={`p-1 rounded transition-colors ${!isSupported
+        ? 'text-slate-200 cursor-not-allowed'
+        : status.status === 'failed'
+          ? 'text-red-400 hover:text-red-500 hover:bg-red-50'
+          : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
         } disabled:opacity-50 disabled:cursor-not-allowed`}
       title={getTitle()}
     >

+ 3 - 1
web/components/PDFSelectionTool.tsx

@@ -1,5 +1,6 @@
 import React, { useState, useRef, useEffect } from 'react';
 import * as pdfjs from 'pdfjs-dist';
+import { useLanguage } from '../contexts/LanguageContext';
 
 // Set worker path for PDF.js
 pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
@@ -129,6 +130,7 @@ export const PDFSelectionTool: React.FC<PDFSelectionToolProps> = ({
     authToken,
     zoomLevel = 1.0,  // デフォルトのズームレベルは1.0
 }) => {
+    const { t } = useLanguage();
     const [isSelecting, setIsSelecting] = useState(false);
     const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
     const [currentPoint, setCurrentPoint] = useState<{ x: number; y: number } | null>(null);
@@ -428,7 +430,7 @@ export const PDFSelectionTool: React.FC<PDFSelectionToolProps> = ({
                 className="absolute inset-0 pointer-events-none"
             />
             <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-75 text-white px-4 py-2 rounded-lg text-sm z-[60]">
-                拖动鼠标选择区域 • 按 ESC 取消
+                {t('dragToSelect')}
             </div>
         </div>
     );

+ 31 - 19
web/components/SearchHistoryList.tsx

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
 import { SearchHistoryItem, KnowledgeGroup } from '../types';
 import { searchHistoryService } from '../services/searchHistoryService';
 import { useToast } from '../contexts/ToastContext';
+import { useLanguage } from '../contexts/LanguageContext';
+import { useConfirm } from '../contexts/ConfirmContext';
 import { MessageCircle, Trash2, Clock, Users } from 'lucide-react';
 
 interface SearchHistoryListProps {
@@ -19,23 +21,25 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
   const [loading, setLoading] = useState(true);
   const [page, setPage] = useState(1);
   const [hasMore, setHasMore] = useState(true);
-  const { showToast } = useToast();
+  const { showError, showSuccess } = useToast();
+  const { confirm } = useConfirm();
+  const { t, language } = useLanguage();
 
   const loadHistories = async (pageNum: number = 1, append: boolean = false) => {
     try {
       setLoading(true);
       const response = await searchHistoryService.getHistories(pageNum, 20);
-      
+
       if (append) {
         setHistories(prev => [...prev, ...response.histories]);
       } else {
         setHistories(response.histories);
       }
-      
+
       setHasMore(response.histories.length === 20);
       setPage(pageNum);
     } catch (error) {
-      showToast('加载搜索历史失败', 'error');
+      showError(t('loadingHistoriesFailed'));
     } finally {
       setLoading(false);
     }
@@ -47,16 +51,16 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
 
   const handleDelete = async (historyId: string, e: React.MouseEvent) => {
     e.stopPropagation();
-    
-    if (!confirm('确定要删除这条对话历史吗?')) return;
+
+    if (!(await confirm(t('confirmDeleteHistory')))) return;
 
     try {
       await searchHistoryService.deleteHistory(historyId);
       setHistories(prev => prev.filter(h => h.id !== historyId));
       onDeleteHistory?.(historyId);
-      showToast('对话历史删除成功', 'success');
+      showSuccess(t('deleteHistorySuccess'));
     } catch (error) {
-      showToast('删除对话历史失败', 'error');
+      showError(t('deleteHistoryFailed'));
     }
   };
 
@@ -72,20 +76,28 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
     const diffMs = now.getTime() - date.getTime();
     const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
 
+    // Determine locale for standard date functions
+    const localeMap: Record<string, string> = {
+      'zh': 'zh-CN',
+      'en': 'en-US',
+      'ja': 'ja-JP'
+    };
+    const locale = localeMap[language] || 'ja-JP';
+
     if (diffDays === 0) {
-      return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
+      return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
     } else if (diffDays === 1) {
-      return '昨天';
+      return t('yesterday');
     } else if (diffDays < 7) {
-      return `${diffDays}天前`;
+      return t('daysAgo', diffDays);
     } else {
-      return date.toLocaleDateString('zh-CN');
+      return date.toLocaleDateString(locale);
     }
   };
 
   const getGroupNames = (selectedGroups: string[] | null) => {
     if (!selectedGroups || selectedGroups.length === 0) {
-      return '全部分组';
+      return t('allKnowledgeGroups');
     }
     return selectedGroups
       .map(id => groups.find(g => g.id === id)?.name)
@@ -96,7 +108,7 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
   if (loading && histories.length === 0) {
     return (
       <div className="flex items-center justify-center py-8">
-        <div className="text-gray-500">加载中...</div>
+        <div className="text-gray-500">{t('loading')}</div>
       </div>
     );
   }
@@ -105,8 +117,8 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
     return (
       <div className="text-center py-8">
         <MessageCircle size={48} className="mx-auto text-gray-300 mb-4" />
-        <div className="text-gray-500">暂无对话历史</div>
-        <div className="text-sm text-gray-400 mt-1">开始一次对话来创建历史记录</div>
+        <div className="text-gray-500">{t('noHistory')}</div>
+        <div className="text-sm text-gray-400 mt-1">{t('noHistoryDesc')}</div>
       </div>
     );
   }
@@ -124,11 +136,11 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
               <div className="font-medium text-gray-900 truncate mb-1">
                 {history.title}
               </div>
-              
+
               <div className="flex items-center space-x-4 text-sm text-gray-500 mb-2">
                 <div className="flex items-center space-x-1">
                   <MessageCircle size={14} />
-                  <span>{history.messageCount} 条消息</span>
+                  <span>{t('historyMessages', history.messageCount)}</span>
                 </div>
                 <div className="flex items-center space-x-1">
                   <Clock size={14} />
@@ -158,7 +170,7 @@ export const SearchHistoryList: React.FC<SearchHistoryListProps> = ({
           disabled={loading}
           className="w-full py-3 text-center text-blue-600 hover:text-blue-700 disabled:opacity-50 transition-colors"
         >
-          {loading ? '加载中...' : '加载更多'}
+          {loading ? t('loading') : t('loadMore')}
         </button>
       )}
     </div>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff