Răsfoiți Sursa

2.0 AuraK init

anhuiqiang 3 săptămâni în urmă
părinte
comite
29c2d54670
54 a modificat fișierele cu 1157 adăugiri și 404 ștergeri
  1. 2 2
      README.md
  2. 14 14
      docker-compose.yml
  3. 0 0
      docs/1.0/API.md
  4. 0 0
      docs/1.0/CHUNK_SIZE_LIMITS.md
  5. 0 0
      docs/1.0/CURRENT_IMPLEMENTATION.md
  6. 0 0
      docs/1.0/DEPLOYMENT.md
  7. 0 0
      docs/1.0/DESIGN.md
  8. 0 0
      docs/1.0/DEVELOPMENT_STANDARDS.md
  9. 0 0
      docs/1.0/EMBEDDING_MODEL_ID_FIX.md
  10. 0 0
      docs/1.0/FEATURE_SUMMARY.md
  11. 0 0
      docs/1.0/INTERNAL_DEPLOYMENT_GUIDE.md
  12. 0 0
      docs/1.0/INTERNAL_DEPLOYMENT_SUMMARY.md
  13. 0 0
      docs/1.0/KNOWLEDGE_BASE_ENHANCEMENTS.md
  14. 0 0
      docs/1.0/LARGE_FILE_HANDLING.md
  15. 0 0
      docs/1.0/MEMORY_OPTIMIZATION_FIX.md
  16. 0 0
      docs/1.0/PDF_PREVIEW_FIX.md
  17. 0 0
      docs/1.0/PROJECT_EXPLANATION_JA.md
  18. 0 0
      docs/1.0/PROJECT_EXPLANATION_JA.pdf
  19. 0 0
      docs/1.0/RAG_COMPLETE_IMPLEMENTATION.md
  20. 0 0
      docs/1.0/README.md
  21. 0 0
      docs/1.0/SIMILARITY_SCORE_BUGFIX.md
  22. 0 0
      docs/1.0/SUPPORTED_FILE_TYPES.md
  23. 0 0
      docs/1.0/VECTOR_DB_COMPARISON_JA.md
  24. 0 0
      docs/1.0/VECTOR_DB_COMPARISON_JA.pdf
  25. 0 0
      docs/1.0/VISION_PIPELINE_COMPLETE.md
  26. 0 0
      docs/1.0/test_admin_features.md
  27. BIN
      docs/2.0/google_workspace_style_ui_mockup.png
  28. 102 0
      docs/2.0/implementation_plan.md
  29. 66 7
      package-lock.json
  30. 1 0
      server/package.json
  31. 265 0
      server/src/api/api-v1.controller.ts
  32. 14 4
      server/src/api/api.module.ts
  33. 6 0
      server/src/app.module.ts
  34. 31 0
      server/src/auth/api-key.guard.ts
  35. 11 0
      server/src/auth/super-admin.guard.ts
  36. 16 0
      server/src/auth/tenant-admin.guard.ts
  37. 18 6
      server/src/elasticsearch/elasticsearch.service.ts
  38. 3 0
      server/src/knowledge-base/knowledge-base.entity.ts
  39. 3 0
      server/src/knowledge-group/knowledge-group.entity.ts
  40. 12 0
      server/src/main.ts
  41. 5 0
      server/src/model-config/model-config.entity.ts
  42. 3 0
      server/src/note/note.entity.ts
  43. 3 0
      server/src/search-history/search-history.entity.ts
  44. 81 0
      server/src/tenant/tenant-setting.entity.ts
  45. 56 0
      server/src/tenant/tenant.controller.ts
  46. 33 0
      server/src/tenant/tenant.entity.ts
  47. 14 0
      server/src/tenant/tenant.module.ts
  48. 89 0
      server/src/tenant/tenant.service.ts
  49. 13 0
      server/src/user/user.controller.ts
  50. 33 3
      server/src/user/user.entity.ts
  51. 39 3
      server/src/user/user.service.ts
  52. 1 1
      web/components/Logo.tsx
  53. 2 2
      web/index.html
  54. 221 362
      yarn.lock

+ 2 - 2
README.md

@@ -1,6 +1,6 @@
-# 簡易ナレッジベース (Simple Knowledge Base)
+# AuraK
 
-React + NestJS をベースにしたフルスタックのナレッジベースQ&Aシステムです。マルチモデル、多言語対応の RAG (検索拡張生成) 機能を備えています。
+AuraK は、マルチテナント対応のインテリジェント AI ナレッジベースプラットフォームです。React + NestJS をベースにしたフルスタックの RAG (検索拡張生成) システムで、外部 API、RBAC、テナント分離をサポートします。
 
 ## ✨ 特徴
 

+ 14 - 14
docker-compose.yml

@@ -1,7 +1,7 @@
 services:
   es:
     image: elasticsearch:9.2.1
-    container_name: local-es
+    container_name: aurak-es
     environment:
       - discovery.type=single-node
       - xpack.security.enabled=false
@@ -11,34 +11,34 @@ services:
     volumes:
       - es-data:/usr/share/elasticsearch/data
     networks:
-      - simple-kb-network
+      - aurak-network
   #    restart: unless-stopped
 
   tika:
     image: apache/tika:latest
-    container_name: simple-kb-tika
+    container_name: aurak-tika
     ports:
       - "9998:9998"
     networks:
-      - simple-kb-network
+      - aurak-network
     restart: unless-stopped
 
   libreoffice:
     build:
       context: ./libreoffice-server
       dockerfile: Dockerfile
-    container_name: simple-kb-libreoffice
+    container_name: aurak-libreoffice
     ports:
       - "8100:8100"
     volumes:
       - ./uploads:/app/uploads
       - ./temp:/temp
     networks:
-      - simple-kb-network
+      - aurak-network
     restart: unless-stopped
   # ollama:
   #   image: ollama/ollama:latest
-  #   container_name: simple-kb-ollama
+  #   container_name: aurak-ollama
   #   ports:
   #     - "11434:11434"
   #   environment:
@@ -46,7 +46,7 @@ services:
   #   volumes:
   #     - ollama-data:/root/.ollama
   #   networks:
-  #     - simple-kb-network
+  #     - aurak-network
   #   restart: unless-stopped
   #   entrypoint: ["/bin/sh", "-c"]
   #   command: >
@@ -62,7 +62,7 @@ services:
   #   build:
   #     context: ./server
   #     dockerfile: Dockerfile
-  #   container_name: simple-kb-server
+  #   container_name: aurak-server
   #   environment:
   #     - NODE_ENV=production
   #     - NODE_OPTIONS=--max-old-space-size=8192
@@ -86,7 +86,7 @@ services:
   #     - libreoffice
   #   #    restart: unless-stopped
   #   networks:
-  #     - simple-kb-network
+  #     - aurak-network
 
   # web:
   #   build:
@@ -94,7 +94,7 @@ services:
   #     dockerfile: ./web/Dockerfile
   #     args:
   #       - VITE_API_BASE_URL=/api
-  #   container_name: simple-kb-web
+  #   container_name: aurak-web
   #   depends_on:
   #     - server
   #   ports:
@@ -103,10 +103,10 @@ services:
   #   volumes:
   #     - ./nginx/conf.d:/etc/nginx/conf.d
   #   networks:
-  #     - simple-kb-network
+  #     - aurak-network
 
 networks:
-  simple-kb-network:
+  aurak-network:
     driver: bridge
 
 volumes:
@@ -114,5 +114,5 @@ volumes:
     driver: local
   ollama-data:
     driver: local
-  simple-kb-data:
+  aurak-data:
     driver: local

+ 0 - 0
docs/API.md → docs/1.0/API.md


+ 0 - 0
docs/CHUNK_SIZE_LIMITS.md → docs/1.0/CHUNK_SIZE_LIMITS.md


+ 0 - 0
docs/CURRENT_IMPLEMENTATION.md → docs/1.0/CURRENT_IMPLEMENTATION.md


+ 0 - 0
docs/DEPLOYMENT.md → docs/1.0/DEPLOYMENT.md


+ 0 - 0
docs/DESIGN.md → docs/1.0/DESIGN.md


+ 0 - 0
docs/DEVELOPMENT_STANDARDS.md → docs/1.0/DEVELOPMENT_STANDARDS.md


+ 0 - 0
docs/EMBEDDING_MODEL_ID_FIX.md → docs/1.0/EMBEDDING_MODEL_ID_FIX.md


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


+ 0 - 0
INTERNAL_DEPLOYMENT_GUIDE.md → docs/1.0/INTERNAL_DEPLOYMENT_GUIDE.md


+ 0 - 0
INTERNAL_DEPLOYMENT_SUMMARY.md → docs/1.0/INTERNAL_DEPLOYMENT_SUMMARY.md


+ 0 - 0
docs/KNOWLEDGE_BASE_ENHANCEMENTS.md → docs/1.0/KNOWLEDGE_BASE_ENHANCEMENTS.md


+ 0 - 0
docs/LARGE_FILE_HANDLING.md → docs/1.0/LARGE_FILE_HANDLING.md


+ 0 - 0
docs/MEMORY_OPTIMIZATION_FIX.md → docs/1.0/MEMORY_OPTIMIZATION_FIX.md


+ 0 - 0
docs/PDF_PREVIEW_FIX.md → docs/1.0/PDF_PREVIEW_FIX.md


+ 0 - 0
docs/PROJECT_EXPLANATION_JA.md → docs/1.0/PROJECT_EXPLANATION_JA.md


+ 0 - 0
docs/PROJECT_EXPLANATION_JA.pdf → docs/1.0/PROJECT_EXPLANATION_JA.pdf


+ 0 - 0
docs/RAG_COMPLETE_IMPLEMENTATION.md → docs/1.0/RAG_COMPLETE_IMPLEMENTATION.md


+ 0 - 0
docs/README.md → docs/1.0/README.md


+ 0 - 0
docs/SIMILARITY_SCORE_BUGFIX.md → docs/1.0/SIMILARITY_SCORE_BUGFIX.md


+ 0 - 0
docs/SUPPORTED_FILE_TYPES.md → docs/1.0/SUPPORTED_FILE_TYPES.md


+ 0 - 0
docs/VECTOR_DB_COMPARISON_JA.md → docs/1.0/VECTOR_DB_COMPARISON_JA.md


+ 0 - 0
docs/VECTOR_DB_COMPARISON_JA.pdf → docs/1.0/VECTOR_DB_COMPARISON_JA.pdf


+ 0 - 0
docs/VISION_PIPELINE_COMPLETE.md → docs/1.0/VISION_PIPELINE_COMPLETE.md


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


BIN
docs/2.0/google_workspace_style_ui_mockup.png


+ 102 - 0
docs/2.0/implementation_plan.md

@@ -0,0 +1,102 @@
+# Implementation Plan - AuraK External API Service (v2.0)
+
+Provide an API service for external systems to access the KnowledgeBase functionalities, including chat, search, and document management.
+
+## User Review Required
+
+> [!IMPORTANT]
+> **Multi-Tenancy & Resource Sharing**:
+> - **Tenant Entity**: We will introduce a `Tenant` (Organization) entity.
+> - **Resource Scoping**: `User`, `KnowledgeBase`, `KnowledgeGroup`, `SearchHistory`, `Note`, and `ImportTask` will be scoped by `tenantId`.
+> - **Configuration Hierarchy**:
+>     - **ModelConfig**: Inherited hierarchy: `System Models (Global)` -> `Tenant Models (Shared in Org)` -> `User Models (Private)`.
+>     - **TenantSettings**: New entity to define organization-wide defaults (Language, Default Models, Search thresholds). `UserSetting` can still override these for personalization.
+> - **Data Migration**: Existing data will be migrated to a "Default Tenant" during the first run.
+> - **Elasticsearch Isolation**: The `tenantId` field will be added to the ES mapping and enforced in all search/delete queries.
+> - **Storage Partitioning**: Uploaded files will be stored in `uploads/{tenantId}/{fileId}` to isolate files at the filesystem level.
+> - **API Key**: Tied to `User`, scoped by `Tenant`.
+
+> [!IMPORTANT]
+> **RBAC & Interface Separation**:
+> - **Roles**:
+>     - `SUPER_ADMIN`: Manage Tenants and global system settings.
+>     - `TENANT_ADMIN`: Manage Users and Knowledge Bases within their Tenant.
+>     - `USER`: Access Chat, Search, and Knowledge Base within their Tenant.
+> - **API Separation**: Administrative endpoints will be separated into `/api/v1/super-admin/*` and `/api/v1/admin/*`.
+> - **UI Separation**: Recommend separating the "Admin Portal" from the "User Workspace" to ensure a cleaner user experience and better security boundaries.
+
+> [!IMPORTANT]
+> **Frontend Modernization (Google Workspace Style)**:
+> - **Design Aesthetic**: Adopt a clean, modern, and professional style inspired by Google Workspace (Gmail, Drive, Gemini).
+> - **Core Elements**:
+>     - **Sleek Navigation Rail**: A minimal, collapsible sidebar with rounded active states.
+>     - **Top Global Search**: A prominent, rounded search bar for quick access.
+>     - **Airy Layout**: Increased white space and soft shadows to improve readability.
+>     - **Gemini-like Chat**: A modern AI chat interface with clean message bubbles and a refined input area.
+> - **Mockup**:
+> ![Google Workspace Style Mockup](./google_workspace_style_ui_mockup.png)
+
+## Proposed Changes
+
+### [Component] Database Schema
+#### [NEW] [tenant.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/tenant/tenant.entity.ts)
+- Define `Tenant` entity with `id` and `name`.
+
+#### [MODIFY] [user.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/user/user.entity.ts)
+- Add `tenantId` column (ManyToOne relationship with `Tenant`).
+- Add `role` column (Enum: `SUPER_ADMIN`, `TENANT_ADMIN`, `USER`).
+- Add `apiKey` column for API authentication.
+
+#### [NEW] [tenant-setting.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/tenant/tenant-setting.entity.ts)
+- Store organization-wide defaults (similar fields to `UserSetting`).
+
+#### [MODIFY] [model-config.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/model-config/model-config.entity.ts)
+- Add `tenantId` column (nullable for global models).
+- Update lookup logic to include system-level and tenant-level models.
+
+#### [MODIFY] [knowledge-base.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/knowledge-base/knowledge-base.entity.ts)
+- Add `tenantId` column.
+
+#### [MODIFY] [knowledge-group.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/knowledge-group/knowledge-group.entity.ts)
+- Add `tenantId` column.
+
+#### [MODIFY] [search-history.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/search-history/search-history.entity.ts)
+- Add `tenantId` column.
+
+#### [MODIFY] [note.entity.ts](file:///d:/tmp/KnowledgeBase/server/src/note/note.entity.ts)
+- Add `tenantId` column.
+
+### [Component] Infrastructure & Storage
+#### [MODIFY] [elasticsearch.service.ts](file:///d:/tmp/KnowledgeBase/server/src/elasticsearch/elasticsearch.service.ts)
+- Update `createIndex` mapping to include `tenantId` as a `keyword` field.
+- Modify `searchSimilar`, `searchFullText`, and `hybridSearch` to include `term: { tenantId }` filter.
+
+#### [MODIFY] [knowledge-base.service.ts](file:///d:/tmp/KnowledgeBase/server/src/knowledge-base/knowledge-base.service.ts)
+- Update file storage logic to use `uploads/{tenantId}/{fileId}` path.
+
+#### [NEW] [migrations]
+- Create a migration script to:
+    1. Create the `Tenant` table.
+    2. Create a "Default" tenant.
+    3. Update all existing records to link to the "Default" tenant.
+
+### [Component] User & Auth
+#### [NEW] [super-admin.guard.ts](file:///d:/tmp/KnowledgeBase/server/src/auth/super-admin.guard.ts)
+- Guard that checks for `SUPER_ADMIN` role.
+
+#### [NEW] [tenant-admin.guard.ts](file:///d:/tmp/KnowledgeBase/server/src/auth/tenant-admin.guard.ts)
+- Guard that checks for `TENANT_ADMIN` role or higher within the same tenant.
+
+---
+
+## Verification Plan
+
+### Automated Tests
+- `curl -X GET http://localhost:3001/api/v1/knowledge-bases -H "x-api-key: YOUR_KEY"`
+- `curl -X POST http://localhost:3001/api/v1/chat -H "x-api-key: YOUR_KEY" -d '{"message": "Hello"}'`
+
+### Manual Verification
+1.  **Retrieve API Key**: Login to the system, then call `/api/user/api-key` to get the key.
+2.  **Test External Request**: Use the retrieved key to call the new `/api/v1/*` endpoints.
+3.  **Check Swagger**: Visit `http://localhost:3001/api/docs`.
+4.  **Verify Streaming**: Ensure `POST /api/v1/chat` with `stream: true` returns SSE chunks.

+ 66 - 7
package-lock.json

@@ -2605,6 +2605,12 @@
         "langium": "3.3.1"
       }
     },
+    "node_modules/@microsoft/tsdoc": {
+      "version": "0.16.0",
+      "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
+      "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
+      "license": "MIT"
+    },
     "node_modules/@napi-rs/canvas": {
       "version": "0.1.88",
       "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
@@ -3264,6 +3270,45 @@
         }
       }
     },
+    "node_modules/@nestjs/swagger": {
+      "version": "11.2.6",
+      "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz",
+      "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==",
+      "license": "MIT",
+      "dependencies": {
+        "@microsoft/tsdoc": "0.16.0",
+        "@nestjs/mapped-types": "2.1.0",
+        "js-yaml": "4.1.1",
+        "lodash": "4.17.23",
+        "path-to-regexp": "8.3.0",
+        "swagger-ui-dist": "5.31.0"
+      },
+      "peerDependencies": {
+        "@fastify/static": "^8.0.0 || ^9.0.0",
+        "@nestjs/common": "^11.0.1",
+        "@nestjs/core": "^11.0.1",
+        "class-transformer": "*",
+        "class-validator": "*",
+        "reflect-metadata": "^0.1.12 || ^0.2.0"
+      },
+      "peerDependenciesMeta": {
+        "@fastify/static": {
+          "optional": true
+        },
+        "class-transformer": {
+          "optional": true
+        },
+        "class-validator": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@nestjs/swagger/node_modules/lodash": {
+      "version": "4.17.23",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+      "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+      "license": "MIT"
+    },
     "node_modules/@nestjs/testing": {
       "version": "11.1.9",
       "resolved": "https://registry.npmmirror.com/@nestjs/testing/-/testing-11.1.9.tgz",
@@ -3492,6 +3537,13 @@
         "win32"
       ]
     },
+    "node_modules/@scarf/scarf": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
+      "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0"
+    },
     "node_modules/@sinclair/typebox": {
       "version": "0.34.41",
       "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.41.tgz",
@@ -5108,7 +5160,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
       "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
-      "dev": true,
       "license": "Python-2.0"
     },
     "node_modules/array-back": {
@@ -10177,7 +10228,6 @@
       "version": "4.1.1",
       "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
       "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "argparse": "^2.0.1"
@@ -14068,10 +14118,6 @@
         "simple-concat": "^1.0.0"
       }
     },
-    "node_modules/simple-kb": {
-      "resolved": "web",
-      "link": true
-    },
     "node_modules/simple-wcswidth": {
       "version": "1.1.2",
       "resolved": "https://registry.npmmirror.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz",
@@ -14449,6 +14495,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/swagger-ui-dist": {
+      "version": "5.31.0",
+      "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz",
+      "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@scarf/scarf": "=1.4.0"
+      }
+    },
     "node_modules/symbol-observable": {
       "version": "4.0.0",
       "resolved": "https://registry.npmmirror.com/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -15807,6 +15862,10 @@
         "defaults": "^1.0.3"
       }
     },
+    "node_modules/web": {
+      "resolved": "web",
+      "link": true
+    },
     "node_modules/web-namespaces": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -16248,6 +16307,7 @@
         "@nestjs/platform-express": "^11.0.1",
         "@nestjs/schedule": "^6.1.0",
         "@nestjs/serve-static": "^5.0.4",
+        "@nestjs/swagger": "^11.2.6",
         "@nestjs/typeorm": "^11.0.0",
         "@types/cron": "^2.0.1",
         "axios": "^1.13.2",
@@ -16512,7 +16572,6 @@
       }
     },
     "web": {
-      "name": "simple-kb",
       "version": "0.0.0",
       "dependencies": {
         "@google/genai": "^1.32.0",

+ 1 - 0
server/package.json

@@ -33,6 +33,7 @@
     "@nestjs/platform-express": "^11.0.1",
     "@nestjs/schedule": "^6.1.0",
     "@nestjs/serve-static": "^5.0.4",
+    "@nestjs/swagger": "^11.2.6",
     "@nestjs/typeorm": "^11.0.0",
     "@types/cron": "^2.0.1",
     "axios": "^1.13.2",

+ 265 - 0
server/src/api/api-v1.controller.ts

@@ -0,0 +1,265 @@
+import {
+    Body,
+    Controller,
+    Delete,
+    Get,
+    Param,
+    Post,
+    Request,
+    Res,
+    UploadedFile,
+    UseGuards,
+    UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { Response } from 'express';
+import { ApiKeyGuard } from '../auth/api-key.guard';
+import { RagService } from '../rag/rag.service';
+import { ChatService } from '../chat/chat.service';
+import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
+import { ModelConfigService } from '../model-config/model-config.service';
+import { UserSettingService } from '../user-setting/user-setting.service';
+
+@Controller('v1')
+@UseGuards(ApiKeyGuard)
+export class ApiV1Controller {
+    constructor(
+        private readonly ragService: RagService,
+        private readonly chatService: ChatService,
+        private readonly knowledgeBaseService: KnowledgeBaseService,
+        private readonly modelConfigService: ModelConfigService,
+        private readonly userSettingService: UserSettingService,
+    ) { }
+
+    // ========== Chat / RAG ==========
+    /**
+     * POST /api/v1/chat
+     * Tenant-scoped RAG chat. Supports both streaming (SSE) and standard JSON responses.
+     * Body: { message, stream?, selectedGroups?, selectedFiles? }
+     */
+    @Post('chat')
+    async chat(
+        @Request() req,
+        @Body()
+        body: {
+            message: string;
+            stream?: boolean;
+            selectedGroups?: string[];
+            selectedFiles?: string[];
+        },
+        @Res() res: Response,
+    ) {
+        const { message, stream = false, selectedGroups, selectedFiles } = body;
+        const user = req.user;
+
+        if (!message) {
+            return res.status(400).json({ error: 'message is required' });
+        }
+
+        // Get user settings and model configuration
+        const userSetting = await this.userSettingService.findOrCreate(user.id);
+        const models = await this.modelConfigService.findAll(user.id);
+        const llmModel = models.find((m) => m.id === userSetting?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
+
+        if (!llmModel) {
+            return res.status(400).json({ error: 'No LLM model configured for this user' });
+        }
+
+        const modelConfig = llmModel as any;
+
+        if (stream) {
+            res.setHeader('Content-Type', 'text/event-stream');
+            res.setHeader('Cache-Control', 'no-cache');
+            res.setHeader('Connection', 'keep-alive');
+
+            try {
+                const stream = this.chatService.streamChat(
+                    message,
+                    [],                                       // history
+                    user.id,
+                    modelConfig,
+                    userSetting?.language ?? 'zh',            // userLanguage
+                    userSetting?.selectedEmbeddingId,          // selectedEmbeddingId
+                    selectedGroups,                           // selectedGroups
+                    selectedFiles,                            // selectedFiles
+                    undefined,                                // historyId
+                    userSetting?.enableRerank ?? false,        // enableRerank
+                    userSetting?.selectedRerankId,             // selectedRerankId
+                    userSetting?.temperature,                  // temperature
+                    userSetting?.maxTokens,                    // maxTokens
+                    userSetting?.topK ?? 5,                    // topK
+                    userSetting?.similarityThreshold ?? 0.3,   // similarityThreshold
+                    userSetting?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
+                    userSetting?.enableQueryExpansion ?? false, // enableQueryExpansion
+                    userSetting?.enableHyDE ?? false,           // enableHyDE
+                );
+
+                for await (const chunk of stream) {
+                    res.write(`data: ${JSON.stringify(chunk)}\n\n`);
+                }
+                res.write('data: [DONE]\n\n');
+                res.end();
+            } catch (error) {
+                res.write(`data: ${JSON.stringify({ type: 'error', data: error.message })}\n\n`);
+                res.end();
+            }
+        } else {
+            // Non-streaming: collect all chunks and return as JSON
+            try {
+                let fullContent = '';
+                const sources: any[] = [];
+                let historyId: string | undefined;
+
+                const chatStream = this.chatService.streamChat(
+                    message,
+                    [],
+                    user.id,
+                    modelConfig,
+                    userSetting?.language ?? 'zh',
+                    userSetting?.selectedEmbeddingId,
+                    selectedGroups,
+                    selectedFiles,
+                    undefined,                                // historyId
+                    userSetting?.enableRerank ?? false,
+                    userSetting?.selectedRerankId,
+                    userSetting?.temperature,
+                    userSetting?.maxTokens,
+                    userSetting?.topK ?? 5,
+                    userSetting?.similarityThreshold ?? 0.3,
+                    userSetting?.rerankSimilarityThreshold ?? 0.5,
+                    userSetting?.enableQueryExpansion ?? false,
+                    userSetting?.enableHyDE ?? false,
+                );
+
+                for await (const chunk of chatStream) {
+                    if (chunk.type === 'content') fullContent += chunk.data;
+                    else if (chunk.type === 'sources') sources.push(...chunk.data);
+                    else if (chunk.type === 'historyId') historyId = chunk.data;
+                }
+
+                return res.json({ content: fullContent, sources, historyId });
+            } catch (error) {
+                return res.status(500).json({ error: error.message });
+            }
+        }
+    }
+
+    // ========== Search ==========
+    /**
+     * POST /api/v1/search
+     * Tenant-scoped hybrid search across knowledge base.
+     * Body: { query, topK?, threshold?, selectedGroups?, selectedFiles? }
+     */
+    @Post('search')
+    async search(
+        @Request() req,
+        @Body()
+        body: {
+            query: string;
+            topK?: number;
+            threshold?: number;
+            selectedGroups?: string[];
+            selectedFiles?: string[];
+        },
+    ) {
+        const { query, topK = 5, threshold = 0.3, selectedGroups, selectedFiles } = body;
+        const user = req.user;
+
+        if (!query) return { error: 'query is required' };
+
+        const userSetting = await this.userSettingService.findOrCreate(user.id);
+
+        const results = await this.ragService.searchKnowledge(
+            query,
+            user.id,
+            topK,
+            threshold,
+            userSetting?.selectedEmbeddingId,
+            userSetting?.enableFullTextSearch ?? false,
+            userSetting?.enableRerank ?? false,
+            userSetting?.selectedRerankId,
+            selectedGroups,
+            selectedFiles,
+            userSetting?.rerankSimilarityThreshold ?? 0.5,
+            userSetting?.enableQueryExpansion ?? false,
+            userSetting?.enableHyDE ?? false,
+        );
+
+        return { results, total: results.length };
+    }
+
+    // ========== Knowledge Base ==========
+    /**
+     * GET /api/v1/knowledge-bases
+     * List all files belonging to the caller's tenant.
+     */
+    @Get('knowledge-bases')
+    async listFiles(@Request() req) {
+        const user = req.user;
+        const files = await this.knowledgeBaseService.findAll(user.id);
+        return {
+            files: files.map((f) => ({
+                id: f.id,
+                name: f.originalName,
+                title: f.title,
+                status: f.status,
+                size: f.size,
+                mimetype: f.mimetype,
+                createdAt: f.createdAt,
+            })),
+            total: files.length,
+        };
+    }
+
+    /**
+     * POST /api/v1/knowledge-bases/upload
+     * Upload and index a file into the caller's tenant knowledge base.
+     */
+    @Post('knowledge-bases/upload')
+    @UseInterceptors(FileInterceptor('file'))
+    async uploadFile(
+        @Request() req,
+        @UploadedFile() file: Express.Multer.File,
+        @Body() body: { mode?: 'fast' | 'precise'; chunkSize?: number; chunkOverlap?: number },
+    ) {
+        if (!file) return { error: 'file is required' };
+        const user = req.user;
+
+        const kb = await this.knowledgeBaseService.createAndIndex(
+            file,
+            user.id,
+            {
+                mode: body.mode ?? 'fast',
+                chunkSize: body.chunkSize ? Number(body.chunkSize) : 1000,
+                chunkOverlap: body.chunkOverlap ? Number(body.chunkOverlap) : 200,
+            },
+        );
+
+        return {
+            id: kb.id,
+            name: kb.originalName,
+            status: kb.status,
+            message: 'File uploaded and indexing started',
+        };
+    }
+
+    /**
+     * DELETE /api/v1/knowledge-bases/:id
+     * Delete a specific file from the knowledge base.
+     */
+    @Delete('knowledge-bases/:id')
+    async deleteFile(@Request() req, @Param('id') id: string) {
+        const user = req.user;
+        await this.knowledgeBaseService.deleteFile(id, user.id);
+        return { message: 'File deleted successfully' };
+    }
+
+    @Get('knowledge-bases/:id')
+    async getFile(@Request() req, @Param('id') id: string) {
+        const user = req.user;
+        const files = await this.knowledgeBaseService.findAll(user.id);
+        const file = files.find((f) => f.id === id);
+        if (!file) return { error: 'File not found' };
+        return file;
+    }
+}

+ 14 - 4
server/src/api/api.module.ts

@@ -1,10 +1,16 @@
 import { Module } from '@nestjs/common';
 import { ApiController } from './api.controller';
 import { ApiService } from './api.service';
+import { ApiV1Controller } from './api-v1.controller';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
 import { AuthModule } from '../auth/auth.module';
-import { ModelConfigModule } from '../model-config/model-config.module'; // Added
-import { UserSettingModule } from '../user-setting/user-setting.module'; // Added
+import { ModelConfigModule } from '../model-config/model-config.module';
+import { UserSettingModule } from '../user-setting/user-setting.module';
+import { RagModule } from '../rag/rag.module';
+import { ChatModule } from '../chat/chat.module';
+import { UserModule } from '../user/user.module';
+import { MulterModule } from '@nestjs/platform-express';
+import { memoryStorage } from 'multer';
 
 @Module({
   imports: [
@@ -12,8 +18,12 @@ import { UserSettingModule } from '../user-setting/user-setting.module'; // Adde
     AuthModule,
     ModelConfigModule,
     UserSettingModule,
+    RagModule,
+    ChatModule,
+    UserModule,
+    MulterModule.register({ storage: memoryStorage() }),
   ],
-  controllers: [ApiController],
+  controllers: [ApiController, ApiV1Controller],
   providers: [ApiService],
 })
-export class ApiModule {}
+export class ApiModule { }

+ 6 - 0
server/src/app.module.ts

@@ -39,6 +39,9 @@ import { ChatMessage } from './search-history/chat-message.entity';
 import { Note } from './note/note.entity';
 import { PodcastEpisode } from './podcasts/entities/podcast-episode.entity';
 import { ImportTask } from './import-task/import-task.entity';
+import { Tenant } from './tenant/tenant.entity';
+import { TenantSetting } from './tenant/tenant-setting.entity';
+import { TenantModule } from './tenant/tenant.module';
 
 @Module({
   imports: [
@@ -68,6 +71,8 @@ import { ImportTask } from './import-task/import-task.entity';
           Note,
           PodcastEpisode,
           ImportTask,
+          Tenant,
+          TenantSetting,
         ],
         synchronize: true, // Auto-create database schema. Disable in production.
       }),
@@ -75,6 +80,7 @@ import { ImportTask } from './import-task/import-task.entity';
     AuthModule,
     I18nModule,
     UserModule,
+    TenantModule,
     UserSettingModule,
     ModelConfigModule,
     KnowledgeBaseModule,

+ 31 - 0
server/src/auth/api-key.guard.ts

@@ -0,0 +1,31 @@
+import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import { UserService } from '../user/user.service';
+
+/**
+ * ApiKeyGuard validates the `x-api-key` header for external API requests.
+ * It attaches the resolved `user` and their `tenantId` to the request object.
+ */
+@Injectable()
+export class ApiKeyGuard implements CanActivate {
+    constructor(private readonly userService: UserService) { }
+
+    async canActivate(context: ExecutionContext): Promise<boolean> {
+        const request = context.switchToHttp().getRequest();
+        const apiKey = request.headers['x-api-key'];
+
+        if (!apiKey) {
+            throw new UnauthorizedException('Missing x-api-key header');
+        }
+
+        const user = await this.userService.findByApiKey(apiKey);
+        if (!user) {
+            throw new UnauthorizedException('Invalid API key');
+        }
+
+        // Attach user and tenantId to request so controllers can use them
+        request.user = user;
+        request.tenantId = user.tenantId;
+
+        return true;
+    }
+}

+ 11 - 0
server/src/auth/super-admin.guard.ts

@@ -0,0 +1,11 @@
+import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
+import { UserRole } from '../user/user.entity';
+
+@Injectable()
+export class SuperAdminGuard implements CanActivate {
+    canActivate(context: ExecutionContext): boolean {
+        const request = context.switchToHttp().getRequest();
+        const user = request.user;
+        return user && (user.role === UserRole.SUPER_ADMIN || user.isAdmin === true);
+    }
+}

+ 16 - 0
server/src/auth/tenant-admin.guard.ts

@@ -0,0 +1,16 @@
+import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
+import { UserRole } from '../user/user.entity';
+
+@Injectable()
+export class TenantAdminGuard implements CanActivate {
+    canActivate(context: ExecutionContext): boolean {
+        const request = context.switchToHttp().getRequest();
+        const user = request.user;
+        return (
+            user &&
+            (user.role === UserRole.SUPER_ADMIN ||
+                user.role === UserRole.TENANT_ADMIN ||
+                user.isAdmin === true)
+        );
+    }
+}

+ 18 - 6
server/src/elasticsearch/elasticsearch.service.ts

@@ -106,6 +106,7 @@ export class ElasticsearchService implements OnModuleInit {
       startPosition: metadata.startPosition,
       endPosition: metadata.endPosition,
       userId: metadata.userId,
+      tenantId: metadata.tenantId,
       createdAt: new Date(),
     };
 
@@ -381,6 +382,9 @@ export class ElasticsearchService implements OnModuleInit {
         // ユーザー情報
         userId: { type: 'keyword' },
 
+        // テナント情報(マルチテナント分離用)
+        tenantId: { type: 'keyword' },
+
         // タイムスタンプ
         createdAt: { type: 'date' },
       },
@@ -416,10 +420,11 @@ export class ElasticsearchService implements OnModuleInit {
     userId: string,
     topK: number = 5,
     fileIds?: string[],
+    tenantId?: string,
   ) {
     try {
       this.logger.log(
-        `Vector search with filter: userId=${userId}, vectorDim=${queryVector?.length}, topK=${topK}, fileIds=${fileIds?.length || 'all'}`,
+        `Vector search with filter: userId=${userId}, tenantId=${tenantId}, vectorDim=${queryVector?.length}, topK=${topK}, fileIds=${fileIds?.length || 'all'}`,
       );
 
       if (!queryVector || queryVector.length === 0) {
@@ -432,15 +437,22 @@ export class ElasticsearchService implements OnModuleInit {
         return [];
       }
 
-      let filter: any;
+      const filterClauses: any[] = [];
       if (fileIds && fileIds.length > 0) {
-        filter = {
-          terms: { fileId: fileIds },
-        };
+        filterClauses.push({ terms: { fileId: fileIds } });
+      }
+      // Tenant isolation: when tenantId is provided, enforce it
+      if (tenantId) {
+        filterClauses.push({ term: { tenantId } });
       } else {
-        filter = {}; // No filter when no file IDs specified
+        // Legacy: fall back to userId-based filter
+        filterClauses.push({ term: { userId } });
       }
 
+      const filter = filterClauses.length > 0
+        ? { bool: { must: filterClauses } }
+        : undefined;
+
       const queryBody: any = {
         index: this.indexName,
         knn: {

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

@@ -51,6 +51,9 @@ export class KnowledgeBase {
   @Column({ name: 'user_id', nullable: true }) // 暫定的に空を許可(デバッグ用)、将来的には必須にすべき
   userId: string;
 
+  @Column({ name: 'tenant_id', nullable: true })
+  tenantId: string;
+
   @Column({ type: 'text', nullable: true })
   content: string; // Tika で抽出されたテキスト内容を保存
 

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

@@ -24,6 +24,9 @@ export class KnowledgeGroup {
   color: string;
 
   // Removed userId field to make groups globally accessible
+  // Tenant scoped: groups are shared within a tenant but isolated across tenants
+  @Column({ name: 'tenant_id', nullable: true })
+  tenantId: string;
 
   @CreateDateColumn({ name: 'created_at' })
   createdAt: Date;

+ 12 - 0
server/src/main.ts

@@ -1,6 +1,7 @@
 import { NestFactory } from '@nestjs/core';
 import { AppModule } from './app.module';
 import { ValidationPipe } from '@nestjs/common';
+import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
 
 async function bootstrap() {
   const app = await NestFactory.create(AppModule);
@@ -11,6 +12,17 @@ async function bootstrap() {
     credentials: true,
   });
   app.setGlobalPrefix('api'); // Set a global API prefix
+
+  // Swagger / OpenAPI documentation
+  const config = new DocumentBuilder()
+    .setTitle('AuraK API')
+    .setDescription('External API for accessing AuraK functionalities via API Key')
+    .setVersion('1.0')
+    .addApiKey({ type: 'apiKey', name: 'x-api-key', in: 'header' }, 'x-api-key')
+    .build();
+  const document = SwaggerModule.createDocument(app, config);
+  SwaggerModule.setup('api/docs', app, document);
+
   await app.listen(process.env.PORT ?? 3001);
 }
 bootstrap();

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

@@ -82,6 +82,11 @@ export class ModelConfig {
   @Column({ type: 'text' }) // For TypeORM, the FK column is often just a primitive type
   userId: string;
 
+  // null = global/system model (visible to all tenants)
+  // set = private to this tenant
+  @Column({ type: 'text', nullable: true, name: 'tenant_id' })
+  tenantId: string;
+
   @ManyToOne(() => User, (user) => user.modelConfigs, {
     onDelete: 'CASCADE',
     nullable: true,

+ 3 - 0
server/src/note/note.entity.ts

@@ -24,6 +24,9 @@ export class Note {
     @Column({ name: 'user_id' })
     userId: string;
 
+    @Column({ name: 'tenant_id', nullable: true })
+    tenantId: string;
+
     @Column({ name: 'group_id', nullable: true })
     groupId: string; // Corresponds to Notebook/KnowledgeGroup ID
 

+ 3 - 0
server/src/search-history/search-history.entity.ts

@@ -16,6 +16,9 @@ export class SearchHistory {
   @Column({ name: 'user_id' })
   userId: string;
 
+  @Column({ name: 'tenant_id', nullable: true })
+  tenantId: string;
+
   @Column()
   title: string;
 

+ 81 - 0
server/src/tenant/tenant-setting.entity.ts

@@ -0,0 +1,81 @@
+import {
+    Column,
+    CreateDateColumn,
+    Entity,
+    JoinColumn,
+    OneToOne,
+    PrimaryGeneratedColumn,
+    UpdateDateColumn,
+} from 'typeorm';
+import { Tenant } from './tenant.entity';
+
+/**
+ * Organization-wide default settings.
+ * UserSetting can still override these on a per-user basis.
+ */
+@Entity('tenant_settings')
+export class TenantSetting {
+    @PrimaryGeneratedColumn('uuid')
+    id: string;
+
+    @Column({ type: 'text' })
+    tenantId: string;
+
+    @OneToOne(() => Tenant, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'tenantId' })
+    tenant: Tenant;
+
+    // Default language for the entire organization
+    @Column({ type: 'text', default: 'zh' })
+    language: string;
+
+    // Default LLM model (override per user in UserSetting)
+    @Column({ type: 'text', nullable: true })
+    selectedLLMId: string;
+
+    // Default embedding model
+    @Column({ type: 'text', nullable: true })
+    selectedEmbeddingId: string;
+
+    // Default rerank model
+    @Column({ type: 'text', nullable: true })
+    selectedRerankId: string;
+
+    // Search configuration defaults
+    @Column({ type: 'real', default: 0.3 })
+    similarityThreshold: number;
+
+    @Column({ type: 'real', default: 0.5 })
+    rerankSimilarityThreshold: number;
+
+    @Column({ type: 'integer', default: 5 })
+    topK: number;
+
+    @Column({ type: 'boolean', default: false })
+    enableFullTextSearch: boolean;
+
+    @Column({ type: 'boolean', default: false })
+    enableRerank: boolean;
+
+    @Column({ type: 'real', default: 0.7 })
+    hybridVectorWeight: number;
+
+    @Column({ type: 'boolean', default: false })
+    enableQueryExpansion: boolean;
+
+    @Column({ type: 'boolean', default: false })
+    enableHyDE: boolean;
+
+    // LLM generation defaults
+    @Column({ type: 'real', default: 0.7 })
+    temperature: number;
+
+    @Column({ type: 'integer', default: 2048 })
+    maxTokens: number;
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+}

+ 56 - 0
server/src/tenant/tenant.controller.ts

@@ -0,0 +1,56 @@
+import {
+    Body,
+    Controller,
+    Delete,
+    ForbiddenException,
+    Get,
+    Param,
+    Post,
+    Put,
+    Request,
+    UseGuards,
+} from '@nestjs/common';
+import { TenantService } from './tenant.service';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { SuperAdminGuard } from '../auth/super-admin.guard';
+
+@Controller('tenants')
+@UseGuards(JwtAuthGuard, SuperAdminGuard)
+export class TenantController {
+    constructor(private readonly tenantService: TenantService) { }
+
+    @Get()
+    findAll() {
+        return this.tenantService.findAll();
+    }
+
+    @Get(':id')
+    findOne(@Param('id') id: string) {
+        return this.tenantService.findById(id);
+    }
+
+    @Post()
+    create(@Body() body: { name: string; description?: string }) {
+        return this.tenantService.create(body.name, body.description);
+    }
+
+    @Put(':id')
+    update(@Param('id') id: string, @Body() body: { name?: string; description?: string; isActive?: boolean }) {
+        return this.tenantService.update(id, body);
+    }
+
+    @Delete(':id')
+    remove(@Param('id') id: string) {
+        return this.tenantService.remove(id);
+    }
+
+    @Get(':id/settings')
+    getSettings(@Param('id') id: string) {
+        return this.tenantService.getSettings(id);
+    }
+
+    @Put(':id/settings')
+    updateSettings(@Param('id') id: string, @Body() body: any) {
+        return this.tenantService.updateSettings(id, body);
+    }
+}

+ 33 - 0
server/src/tenant/tenant.entity.ts

@@ -0,0 +1,33 @@
+import {
+    Column,
+    CreateDateColumn,
+    Entity,
+    OneToMany,
+    PrimaryGeneratedColumn,
+    UpdateDateColumn,
+} from 'typeorm';
+import { User } from '../user/user.entity';
+
+@Entity('tenants')
+export class Tenant {
+    @PrimaryGeneratedColumn('uuid')
+    id: string;
+
+    @Column({ type: 'text', unique: true })
+    name: string;
+
+    @Column({ type: 'text', nullable: true })
+    description: string;
+
+    @Column({ type: 'boolean', default: true })
+    isActive: boolean;
+
+    @OneToMany(() => User, (user) => user.tenant)
+    users: User[];
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+}

+ 14 - 0
server/src/tenant/tenant.module.ts

@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Tenant } from './tenant.entity';
+import { TenantSetting } from './tenant-setting.entity';
+import { TenantService } from './tenant.service';
+import { TenantController } from './tenant.controller';
+
+@Module({
+    imports: [TypeOrmModule.forFeature([Tenant, TenantSetting])],
+    providers: [TenantService],
+    controllers: [TenantController],
+    exports: [TenantService],
+})
+export class TenantModule { }

+ 89 - 0
server/src/tenant/tenant.service.ts

@@ -0,0 +1,89 @@
+import {
+    BadRequestException,
+    Injectable,
+    NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Tenant } from './tenant.entity';
+import { TenantSetting } from './tenant-setting.entity';
+
+@Injectable()
+export class TenantService {
+    constructor(
+        @InjectRepository(Tenant)
+        private readonly tenantRepository: Repository<Tenant>,
+        @InjectRepository(TenantSetting)
+        private readonly tenantSettingRepository: Repository<TenantSetting>,
+    ) { }
+
+    async findAll(): Promise<Tenant[]> {
+        return this.tenantRepository.find({ order: { createdAt: 'ASC' } });
+    }
+
+    async findById(id: string): Promise<Tenant> {
+        const tenant = await this.tenantRepository.findOneBy({ id });
+        if (!tenant) throw new NotFoundException(`Tenant ${id} not found`);
+        return tenant;
+    }
+
+    async findByName(name: string): Promise<Tenant | null> {
+        return this.tenantRepository.findOneBy({ name });
+    }
+
+    async create(name: string, description?: string): Promise<Tenant> {
+        const existing = await this.findByName(name);
+        if (existing) throw new BadRequestException(`Tenant name "${name}" already exists`);
+
+        const tenant = this.tenantRepository.create({ name, description });
+        const saved = await this.tenantRepository.save(tenant);
+
+        // Auto-create default TenantSettings
+        const setting = this.tenantSettingRepository.create({ tenantId: saved.id });
+        await this.tenantSettingRepository.save(setting);
+
+        return saved;
+    }
+
+    async update(id: string, data: Partial<Tenant>): Promise<Tenant> {
+        await this.findById(id);
+        await this.tenantRepository.update(id, data);
+        return this.findById(id);
+    }
+
+    async remove(id: string): Promise<void> {
+        await this.findById(id);
+        await this.tenantRepository.delete(id);
+    }
+
+    async getSettings(tenantId: string): Promise<TenantSetting> {
+        let setting = await this.tenantSettingRepository.findOneBy({ tenantId });
+        if (!setting) {
+            setting = this.tenantSettingRepository.create({ tenantId });
+            setting = await this.tenantSettingRepository.save(setting);
+        }
+        return setting;
+    }
+
+    async updateSettings(tenantId: string, data: Partial<TenantSetting>): Promise<TenantSetting> {
+        let setting = await this.tenantSettingRepository.findOneBy({ tenantId });
+        if (!setting) {
+            setting = this.tenantSettingRepository.create({ tenantId, ...data });
+        } else {
+            Object.assign(setting, data);
+        }
+        return this.tenantSettingRepository.save(setting);
+    }
+
+    /**
+     * Ensure a "Default" tenant exists for data migration purposes.
+     * Called during app bootstrap.
+     */
+    async ensureDefaultTenant(): Promise<Tenant> {
+        let defaultTenant = await this.findByName('Default');
+        if (!defaultTenant) {
+            defaultTenant = await this.create('Default', 'Default tenant for existing data');
+        }
+        return defaultTenant;
+    }
+}

+ 13 - 0
server/src/user/user.controller.ts

@@ -25,6 +25,19 @@ export class UserController {
     private readonly i18nService: I18nService,
   ) { }
 
+  // --- API Key Management ---
+  @Get('api-key')
+  async getApiKey(@Request() req) {
+    const apiKey = await this.userService.getOrCreateApiKey(req.user.id);
+    return { apiKey };
+  }
+
+  @Post('api-key/rotate')
+  async rotateApiKey(@Request() req) {
+    const apiKey = await this.userService.regenerateApiKey(req.user.id);
+    return { apiKey };
+  }
+
   @Get()
   async findAll(@Request() req) {
     const isAdmin = await this.userService.isAdmin(req.user.id);

+ 33 - 3
server/src/user/user.entity.ts

@@ -3,6 +3,8 @@ import {
   Column,
   CreateDateColumn,
   Entity,
+  JoinColumn,
+  ManyToOne,
   OneToMany,
   OneToOne,
   PrimaryGeneratedColumn,
@@ -11,6 +13,13 @@ import {
 import * as bcrypt from 'bcrypt';
 import { ModelConfig } from '../model-config/model-config.entity';
 import { UserSetting } from '../user-setting/user-setting.entity';
+import { Tenant } from '../tenant/tenant.entity';
+
+export enum UserRole {
+  SUPER_ADMIN = 'SUPER_ADMIN',
+  TENANT_ADMIN = 'TENANT_ADMIN',
+  USER = 'USER',
+}
 
 @Entity('users')
 export class User {
@@ -23,18 +32,39 @@ export class User {
   @Column({ type: 'text' })
   password: string;
 
+  // Legacy field - kept for backward compatibility, use `role` for new logic
   @Column({ type: 'boolean', default: false })
   isAdmin: boolean;
 
+  // New role-based access control
+  @Column({
+    type: 'simple-enum',
+    enum: UserRole,
+    default: UserRole.USER,
+  })
+  role: UserRole;
+
+  // Multi-tenancy: Each user belongs to a tenant
+  @Column({ type: 'text', nullable: true, name: 'tenant_id' })
+  tenantId: string;
+
+  @ManyToOne(() => Tenant, (tenant) => tenant.users, { nullable: true })
+  @JoinColumn({ name: 'tenant_id' })
+  tenant: Tenant;
+
+  // API Key for external API access
+  @Column({ type: 'text', nullable: true, unique: true, name: 'api_key' })
+  apiKey: string;
+
   // クォータ管理フィールド
   @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
-  monthlyCost: number; // 今月の使用コスト(USD)
+  monthlyCost: number;
 
   @Column({ type: 'decimal', precision: 10, scale: 2, default: 100 })
-  maxCost: number; // 月間最大コスト制限(USD)
+  maxCost: number;
 
   @Column({ type: 'datetime', nullable: true })
-  lastQuotaReset: Date; // 前回のクォータリセット日時
+  lastQuotaReset: Date;
 
   @CreateDateColumn({ name: 'created_at' })
   createdAt: Date;

+ 39 - 3
server/src/user/user.service.ts

@@ -1,9 +1,10 @@
 import { BadRequestException, ConflictException, Injectable, NotFoundException, OnModuleInit, ForbiddenException, Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository, Not } from 'typeorm';
-import { User } from './user.entity';
-import { CreateUserDto } from './dto/create-user.dto';
+import { Repository } from 'typeorm';
+import { User, UserRole } from './user.entity';
 import * as bcrypt from 'bcrypt';
+import { CreateUserDto } from './dto/create-user.dto';
+import * as crypto from 'crypto';
 import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
@@ -74,6 +75,8 @@ export class UserService implements OnModuleInit {
     username: string,
     password: string,
     isAdmin: boolean = false,
+    tenantId?: string,
+    role?: UserRole,
   ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
     const existingUser = await this.findOneByUsername(username);
     if (existingUser) {
@@ -85,6 +88,8 @@ export class UserService implements OnModuleInit {
       username,
       password: hashedPassword,
       isAdmin,
+      tenantId: tenantId ?? undefined,
+      role: role ?? (isAdmin ? UserRole.TENANT_ADMIN : UserRole.USER),
     });
 
     return {
@@ -97,6 +102,37 @@ export class UserService implements OnModuleInit {
     return this.usersRepository.findOne({ where: { id: userId } });
   }
 
+  async findByApiKey(apiKey: string): Promise<User | null> {
+    return this.usersRepository.findOne({ where: { apiKey } });
+  }
+
+  async findByTenantId(tenantId: string): Promise<User[]> {
+    return this.usersRepository.find({ where: { tenantId }, select: ['id', 'username', 'isAdmin', 'role', 'createdAt'] });
+  }
+
+  /**
+   * Generates a new API key for the user, or returns the existing one.
+   */
+  async getOrCreateApiKey(userId: string): Promise<string> {
+    const user = await this.usersRepository.findOne({ where: { id: userId } });
+    if (!user) throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
+    if (user.apiKey) return user.apiKey;
+    const apiKey = 'kb_' + crypto.randomBytes(32).toString('hex');
+    await this.usersRepository.update(userId, { apiKey });
+    return apiKey;
+  }
+
+  /**
+   * Regenerates (rotates) the API key for the user.
+   */
+  async regenerateApiKey(userId: string): Promise<string> {
+    const user = await this.usersRepository.findOne({ where: { id: userId } });
+    if (!user) throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
+    const apiKey = 'kb_' + crypto.randomBytes(32).toString('hex');
+    await this.usersRepository.update(userId, { apiKey });
+    return apiKey;
+  }
+
   async updateUser(
     userId: string,
     updateData: { isAdmin?: boolean; password?: string },

+ 1 - 1
web/components/Logo.tsx

@@ -40,7 +40,7 @@ export const Logo: React.FC<LogoProps> = ({ className = '', size = 32, withText
 
             {withText && (
                 <span className={`font-bold text-xl tracking-tight bg-gradient-to-r from-blue-400 to-indigo-300 bg-clip-text text-transparent ${textClassName}`}>
-                    Lumina
+                    AuraK
                 </span>
             )}
         </div>

+ 2 - 2
web/index.html

@@ -5,8 +5,8 @@
   <meta charset="UTF-8" />
   <link rel="icon" type="image/svg+xml" href="/vite.svg" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-  <meta name="description" content="Lumina - Your Intelligent AI Knowledge Base" />
-  <title>Lumina</title>
+  <meta name="description" content="AuraK - Your Intelligent AI Knowledge Base" />
+  <title>AuraK</title>
   <!-- Katex CSS for math rendering -->
   <link rel="stylesheet" href="/katex/katex.min.css">
 </head>

Fișier diff suprimat deoarece este prea mare
+ 221 - 362
yarn.lock


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff