anhuiqiang 3 недель назад
Родитель
Сommit
3cdfeda9da
100 измененных файлов с 2741 добавлено и 1885 удалено
  1. 2 0
      .gitignore
  2. 19 4
      docs/2.0/implementation_plan.md
  3. 0 0
      docs/2.0/refacting.md
  4. 567 208
      package-lock.json
  5. 3 1
      server/package.json
  6. 24 0
      server/scripts/reset-admin.mjs
  7. 33 0
      server/src/admin/admin.controller.ts
  8. 12 0
      server/src/admin/admin.module.ts
  9. 30 0
      server/src/admin/admin.service.ts
  10. 4 2
      server/src/api/api-v1.controller.ts
  11. 3 3
      server/src/api/api.controller.ts
  12. 10 1
      server/src/app.module.ts
  13. 34 16
      server/src/auth/api-key.guard.ts
  14. 16 2
      server/src/auth/auth.controller.ts
  15. 21 2
      server/src/auth/auth.service.ts
  16. 76 0
      server/src/auth/combined-auth.guard.ts
  17. 28 0
      server/src/auth/entities/api-key.entity.ts
  18. 7 1
      server/src/auth/jwt.strategy.ts
  19. 5 0
      server/src/auth/roles.decorator.ts
  20. 28 0
      server/src/auth/roles.guard.ts
  21. 27 5
      server/src/chat/chat.controller.ts
  22. 2 0
      server/src/chat/chat.module.ts
  23. 20 12
      server/src/chat/chat.service.ts
  24. 35 0
      server/src/data-source.ts
  25. 57 36
      server/src/elasticsearch/elasticsearch.service.ts
  26. 2 2
      server/src/import-task/import-task.controller.ts
  27. 3 0
      server/src/import-task/import-task.entity.ts
  28. 4 4
      server/src/import-task/import-task.service.ts
  29. 13 8
      server/src/knowledge-base/chunk-config.service.ts
  30. 7 5
      server/src/knowledge-base/embedding.service.ts
  31. 26 25
      server/src/knowledge-base/knowledge-base.controller.ts
  32. 8 1
      server/src/knowledge-base/knowledge-base.entity.ts
  33. 5 1
      server/src/knowledge-base/knowledge-base.module.ts
  34. 104 55
      server/src/knowledge-base/knowledge-base.service.ts
  35. 20 21
      server/src/knowledge-group/knowledge-group.controller.ts
  36. 8 1
      server/src/knowledge-group/knowledge-group.entity.ts
  37. 6 3
      server/src/knowledge-group/knowledge-group.module.ts
  38. 27 28
      server/src/knowledge-group/knowledge-group.service.ts
  39. 5 0
      server/src/main.ts
  40. 66 0
      server/src/migrations/1772329237979-AddDefaultTenant.ts
  41. 47 0
      server/src/migrations/1772334811108-AddTenantModule.ts
  42. 16 11
      server/src/model-config/model-config.controller.ts
  43. 1 1
      server/src/model-config/model-config.entity.ts
  44. 49 30
      server/src/model-config/model-config.service.ts
  45. 34 0
      server/src/note/note-category.controller.ts
  46. 58 0
      server/src/note/note-category.entity.ts
  47. 84 0
      server/src/note/note-category.service.ts
  48. 9 7
      server/src/note/note.controller.ts
  49. 21 1
      server/src/note/note.entity.ts
  50. 14 7
      server/src/note/note.module.ts
  51. 36 30
      server/src/note/note.service.ts
  52. 3 3
      server/src/ocr/ocr.controller.ts
  53. 5 5
      server/src/podcasts/podcast.controller.ts
  54. 2 1
      server/src/podcasts/podcast.service.ts
  55. 11 8
      server/src/rag/rag.service.ts
  56. 2 1
      server/src/rag/rerank.service.ts
  57. 8 7
      server/src/search-history/search-history.controller.ts
  58. 18 7
      server/src/search-history/search-history.service.ts
  59. 39 0
      server/src/super-admin/super-admin.controller.ts
  60. 12 0
      server/src/super-admin/super-admin.module.ts
  61. 48 0
      server/src/super-admin/super-admin.service.ts
  62. 7 0
      server/src/tenant/tenant-setting.entity.ts
  63. 2 2
      server/src/tenant/tenant.controller.ts
  64. 7 4
      server/src/tenant/tenant.entity.ts
  65. 3 3
      server/src/tenant/tenant.service.ts
  66. 6 4
      server/src/upload/upload.controller.ts
  67. 10 2
      server/src/upload/upload.module.ts
  68. 3 3
      server/src/user-setting/user-setting.controller.ts
  69. 5 1
      server/src/user/dto/create-user.dto.ts
  70. 8 0
      server/src/user/dto/update-user.dto.ts
  71. 4 0
      server/src/user/dto/user-safe.dto.ts
  72. 79 19
      server/src/user/user.controller.ts
  73. 4 3
      server/src/user/user.entity.ts
  74. 9 3
      server/src/user/user.module.ts
  75. 71 22
      server/src/user/user.service.ts
  76. 3 3
      server/src/vision-pipeline/vision-pipeline-cost-aware.service.ts
  77. 1 0
      server/src/vision-pipeline/vision-pipeline.interface.ts
  78. 1 3
      server/src/vision-pipeline/vision-pipeline.module.ts
  79. 3 3
      server/src/vision-pipeline/vision-pipeline.service.ts
  80. 11 215
      web/App.tsx
  81. 90 91
      web/README.md
  82. 32 27
      web/components/ChatInterface.tsx
  83. 112 89
      web/components/ChatMessage.tsx
  84. 0 294
      web/components/ConfigPanel.tsx
  85. 1 1
      web/components/CreateNoteFromPDFDialog.tsx
  86. 79 72
      web/components/DragDropUpload.tsx
  87. 1 1
      web/components/FileGroupTags.tsx
  88. 57 132
      web/components/GlobalDragDropOverlay.tsx
  89. 1 1
      web/components/GroupManager.tsx
  90. 17 14
      web/components/GroupSelectionDrawer.tsx
  91. 5 3
      web/components/HistoryDrawer.tsx
  92. 35 30
      web/components/IndexingModal.tsx
  93. 62 57
      web/components/IndexingModalWithMode.tsx
  94. 5 3
      web/components/InputDrawer.tsx
  95. 47 72
      web/components/NotebookDragDropUpload.tsx
  96. 53 131
      web/components/NotebookGlobalDragDropOverlay.tsx
  97. 1 1
      web/components/PDFPreview.tsx
  98. 1 1
      web/components/PDFSelectionTool.tsx
  99. 5 3
      web/components/SettingsDrawer.tsx
  100. 16 6
      web/components/SettingsModal.tsx

+ 2 - 0
.gitignore

@@ -45,3 +45,5 @@ coverage
 
 # temp
 analyze_translations.py
+web/dist-check/
+web2

+ 19 - 4
docs/2.0/implementation_plan.md

@@ -14,7 +14,7 @@ Provide an API service for external systems to access the KnowledgeBase function
 > - **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`.
+> - **API Key**: Tied to `User`, and all operations will be automatically scoped to the user's and tenant's data range.
 
 > [!IMPORTANT]
 > **RBAC & Interface Separation**:
@@ -26,10 +26,14 @@ Provide an API service for external systems to access the KnowledgeBase function
 > - **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).
+> **Frontend Modernization & Boundary Separation (Google Workspace Style)**:
+> - **Design Aesthetic**: Adopt a clean, modern, and professional style inspired by Google Workspace (Gmail, Drive, Gemini), following Material Design 3 specifications.
+> - **Frontend Boundary Separation**:
+>     - **User Workspace**: Focused purely on end-user tools (Chat, Notebooks, Personal Settings).
+>     - **Admin Dashboard**: Dedicated area for management (Knowledge Base files, System/Tenant Settings, Global Models).
+>     - **Implementation**: We will introduce `react-router-dom` to provide clear URL boundaries (e.g., `/` for workspace and `/admin` for management) OR use a strict state-based layout split (`WorkspaceLayout` vs `AdminLayout`) with an app switcher.
 > - **Core Elements**:
->     - **Sleek Navigation Rail**: A minimal, collapsible sidebar with rounded active states.
+>     - **Sleek Navigation Rail**: A minimal, collapsible sidebar with rounded active states, scoped to the current boundary (Admin vs Workspace).
 >     - **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.
@@ -87,6 +91,17 @@ Provide an API service for external systems to access the KnowledgeBase function
 #### [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.
 
+### [Component] Frontend Separation
+#### [MODIFY] [App.tsx](file:///d:/workspace/AuraK/web/App.tsx)
+- Introduce a clear layout abstraction: `WorkspaceLayout` and `AdminLayout`.
+- Add an "App Switcher" for Admin users to toggle between User Workspace and Admin Dashboard.
+
+#### [NEW] [WorkspaceLayout.tsx](file:///d:/workspace/AuraK/web/components/layouts/WorkspaceLayout.tsx)
+- Contains a customized `WorkspaceSidebarRail` showing only user-centric views (`chat`, `notebooks`).
+
+#### [NEW] [AdminLayout.tsx](file:///d:/workspace/AuraK/web/components/layouts/AdminLayout.tsx)
+- Contains an `AdminSidebarRail` showing management views (`knowledge`, `settings`).
+
 ---
 
 ## Verification Plan

+ 0 - 0
docs/2.0/refacting.md


Разница между файлами не показана из-за своего большого размера
+ 567 - 208
package-lock.json


+ 3 - 1
server/package.json

@@ -13,6 +13,8 @@
     "start:debug": "nest start --debug --watch",
     "start:prod": "node dist/main",
     "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
+    "typeorm": "typeorm-ts-node-commonjs",
+    "migration:run": "npm run typeorm migration:run -- -d src/data-source.ts",
     "test": "jest",
     "test:watch": "jest --watch",
     "test:cov": "jest --coverage",
@@ -102,4 +104,4 @@
     "coverageDirectory": "../coverage",
     "testEnvironment": "node"
   }
-}
+}

+ 24 - 0
server/scripts/reset-admin.mjs

@@ -0,0 +1,24 @@
+/**
+ * Quick script to reset the admin user password for E2E testing.
+ * Usage: node reset-admin.mjs <newpassword>
+ */
+import Database from 'better-sqlite3';
+import bcrypt from 'bcrypt';
+import { fileURLToPath } from 'url';
+import path from 'path';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DB_PATH = path.resolve(__dirname, '../data/metadata.db');
+const newPassword = process.argv[2] || 'Admin@2026';
+
+const db = new Database(DB_PATH);
+
+const hashed = await bcrypt.hash(newPassword, 10);
+const result = db.prepare("UPDATE users SET password = ? WHERE username = 'admin'").run(hashed);
+
+if (result.changes > 0) {
+    console.log(`✅ Admin password reset to: ${newPassword}`);
+} else {
+    console.log('❌ Admin user not found');
+}
+db.close();

+ 33 - 0
server/src/admin/admin.controller.ts

@@ -0,0 +1,33 @@
+import { Controller, Get, Post, Put, Body, UseGuards, Request } from '@nestjs/common';
+import { AdminService } from './admin.service';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user.entity';
+
+@Controller('v1/admin')
+@UseGuards(CombinedAuthGuard, RolesGuard)
+@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+export class AdminController {
+    constructor(private readonly adminService: AdminService) { }
+
+    @Get('users')
+    async getUsers(@Request() req: any) {
+        return this.adminService.getTenantUsers(req.user.tenantId);
+    }
+
+    @Get('settings')
+    async getSettings(@Request() req: any) {
+        return this.adminService.getTenantSettings(req.user.tenantId);
+    }
+
+    @Put('settings')
+    async updateSettings(@Request() req: any, @Body() body: any) {
+        return this.adminService.updateTenantSettings(req.user.tenantId, body);
+    }
+
+    @Get('pending-shares')
+    async getPendingShares(@Request() req: any) {
+        return this.adminService.getPendingShares(req.user.tenantId);
+    }
+}

+ 12 - 0
server/src/admin/admin.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AdminController } from './admin.controller';
+import { AdminService } from './admin.service';
+import { UserModule } from '../user/user.module';
+import { TenantModule } from '../tenant/tenant.module';
+
+@Module({
+    imports: [UserModule, TenantModule],
+    controllers: [AdminController],
+    providers: [AdminService],
+})
+export class AdminModule { }

+ 30 - 0
server/src/admin/admin.service.ts

@@ -0,0 +1,30 @@
+import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
+import { UserService } from '../user/user.service';
+import { TenantService } from '../tenant/tenant.service';
+
+@Injectable()
+export class AdminService {
+    constructor(
+        private readonly userService: UserService,
+        private readonly tenantService: TenantService,
+    ) { }
+
+    async getTenantUsers(tenantId: string) {
+        return this.userService.findByTenantId(tenantId);
+    }
+
+    async getTenantSettings(tenantId: string) {
+        return this.tenantService.getSettings(tenantId);
+    }
+
+    async updateTenantSettings(tenantId: string, data: any) {
+        return this.tenantService.updateSettings(tenantId, data);
+    }
+
+    // Notebook sharing approval and model assignments would go here
+    async getPendingShares(tenantId: string) {
+        // Mock implementation for pending shares to satisfy UI.
+        // Needs proper schema/entity support in the future.
+        return [];
+    }
+}

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

@@ -58,7 +58,7 @@ export class ApiV1Controller {
 
         // Get user settings and model configuration
         const userSetting = await this.userSettingService.findOrCreate(user.id);
-        const models = await this.modelConfigService.findAll(user.id);
+        const models = await this.modelConfigService.findAll(user.id, user.tenantId);
         const llmModel = models.find((m) => m.id === userSetting?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
 
         if (!llmModel) {
@@ -181,6 +181,7 @@ export class ApiV1Controller {
             selectedGroups,
             selectedFiles,
             userSetting?.rerankSimilarityThreshold ?? 0.5,
+            user.tenantId,
             userSetting?.enableQueryExpansion ?? false,
             userSetting?.enableHyDE ?? false,
         );
@@ -228,6 +229,7 @@ export class ApiV1Controller {
         const kb = await this.knowledgeBaseService.createAndIndex(
             file,
             user.id,
+            user.tenantId,
             {
                 mode: body.mode ?? 'fast',
                 chunkSize: body.chunkSize ? Number(body.chunkSize) : 1000,
@@ -250,7 +252,7 @@ export class ApiV1Controller {
     @Delete('knowledge-bases/:id')
     async deleteFile(@Request() req, @Param('id') id: string) {
         const user = req.user;
-        await this.knowledgeBaseService.deleteFile(id, user.id);
+        await this.knowledgeBaseService.deleteFile(id, user.id, user.tenantId);
         return { message: 'File deleted successfully' };
     }
 

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

@@ -9,7 +9,7 @@ import {
   UseGuards,
 } from '@nestjs/common';
 import { ApiService } from './api.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { I18nService } from '../i18n/i18n.service';
 
@@ -31,7 +31,7 @@ export class ApiController {
   }
 
   @Post('chat')
-  @UseGuards(JwtAuthGuard)
+  @UseGuards(CombinedAuthGuard)
   @HttpCode(HttpStatus.OK)
   async chat(@Request() req, @Body() chatDto: ChatDto) {
     const { prompt } = chatDto;
@@ -41,7 +41,7 @@ export class ApiController {
 
     try {
       // ユーザーの LLM モデル設定を取得
-      const models = await this.modelConfigService.findAll(req.user.id);
+      const models = await this.modelConfigService.findAll(req.user.id, req.user.tenantId);
       const llmModel = models.find((m) => m.type === 'llm');
       if (!llmModel) {
         throw new Error(this.i18nService.getMessage('addLLMConfig'));

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

@@ -14,6 +14,7 @@ import { ChatModule } from './chat/chat.module';
 import { AuthModule } from './auth/auth.module';
 import { I18nModule } from './i18n/i18n.module';
 import { JwtAuthGuard } from './auth/jwt-auth.guard';
+import { CombinedAuthGuard } from './auth/combined-auth.guard';
 import { KnowledgeBaseModule } from './knowledge-base/knowledge-base.module';
 import { ModelConfigModule } from './model-config/model-config.module';
 import { UserModule } from './user/user.module';
@@ -37,11 +38,15 @@ import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
 import { SearchHistory } from './search-history/search-history.entity';
 import { ChatMessage } from './search-history/chat-message.entity';
 import { Note } from './note/note.entity';
+import { NoteCategory } from './note/note-category.entity';
 import { PodcastEpisode } from './podcasts/entities/podcast-episode.entity';
 import { ImportTask } from './import-task/import-task.entity';
 import { Tenant } from './tenant/tenant.entity';
 import { TenantSetting } from './tenant/tenant-setting.entity';
+import { ApiKey } from './auth/entities/api-key.entity';
 import { TenantModule } from './tenant/tenant.module';
+import { SuperAdminModule } from './super-admin/super-admin.module';
+import { AdminModule } from './admin/admin.module';
 
 @Module({
   imports: [
@@ -69,10 +74,12 @@ import { TenantModule } from './tenant/tenant.module';
           SearchHistory,
           ChatMessage,
           Note,
+          NoteCategory,
           PodcastEpisode,
           ImportTask,
           Tenant,
           TenantSetting,
+          ApiKey,
         ],
         synchronize: true, // Auto-create database schema. Disable in production.
       }),
@@ -98,13 +105,15 @@ import { TenantModule } from './tenant/tenant.module';
     UploadModule,
     ChatModule,
     ImportTaskModule,
+    SuperAdminModule,
+    AdminModule,
   ],
   controllers: [AppController],
   providers: [
     AppService,
     {
       provide: APP_GUARD,
-      useClass: JwtAuthGuard,
+      useClass: CombinedAuthGuard,
     },
   ],
 })

+ 34 - 16
server/src/auth/api-key.guard.ts

@@ -1,31 +1,49 @@
 import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
 import { UserService } from '../user/user.service';
+import { Request } from 'express';
+import { IS_PUBLIC_KEY } from './public.decorator';
 
-/**
- * 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) { }
+    constructor(
+        private reflector: Reflector,
+        private 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 isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
+            context.getHandler(),
+            context.getClass(),
+        ]);
+        if (isPublic) {
+            return true;
         }
 
-        const user = await this.userService.findByApiKey(apiKey);
-        if (!user) {
+        const request = context.switchToHttp().getRequest<Request & { user?: any, tenantId?: string }>();
+        const apiKey = this.extractApiKeyFromHeader(request);
+
+        if (apiKey) {
+            const user = await this.userService.findByApiKey(apiKey);
+            if (user) {
+                request.user = user;
+                request.tenantId = user.tenantId;
+                return true;
+            }
             throw new UnauthorizedException('Invalid API key');
         }
 
-        // Attach user and tenantId to request so controllers can use them
-        request.user = user;
-        request.tenantId = user.tenantId;
+        throw new UnauthorizedException('Missing API key');
+    }
+
+    private extractApiKeyFromHeader(request: Request): string | undefined {
+        const authHeader = request.headers.authorization;
+        if (authHeader && authHeader.startsWith('Bearer kb_')) {
+            return authHeader.substring(7, authHeader.length);
+        }
+        const headerKey = request.headers['x-api-key'] as string;
+        if (headerKey) return headerKey;
 
-        return true;
+        return undefined;
     }
 }

+ 16 - 2
server/src/auth/auth.controller.ts

@@ -1,7 +1,7 @@
 import { Controller, Get, Post, Request, UseGuards } from '@nestjs/common';
 import { AuthService } from './auth.service';
 import { LocalAuthGuard } from './local-auth.guard';
-import { JwtAuthGuard } from './jwt-auth.guard';
+import { CombinedAuthGuard } from './combined-auth.guard';
 import { Public } from './public.decorator';
 
 @Controller('auth')
@@ -15,9 +15,23 @@ export class AuthController {
     return this.authService.login(req.user);
   }
 
-  @UseGuards(JwtAuthGuard)
+  @UseGuards(CombinedAuthGuard)
   @Get('profile')
   getProfile(@Request() req) {
     return req.user;
   }
+
+  @UseGuards(CombinedAuthGuard)
+  @Get('api-key')
+  async getApiKey(@Request() req) {
+    const apiKey = await this.authService.getOrCreateApiKey(req.user.id);
+    return { apiKey };
+  }
+
+  @UseGuards(CombinedAuthGuard)
+  @Post('api-key/regenerate')
+  async regenerateApiKey(@Request() req) {
+    const apiKey = await this.authService.regenerateApiKey(req.user.id);
+    return { apiKey };
+  }
 }

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

@@ -9,7 +9,7 @@ export class AuthService {
   constructor(
     private userService: UserService,
     private jwtService: JwtService,
-  ) {}
+  ) { }
 
   async validateUser(username: string, pass: string): Promise<User | null> {
     const user = await this.userService.findOneByUsername(username);
@@ -20,9 +20,28 @@ export class AuthService {
   }
 
   async login(user: SafeUser) {
-    const payload = { username: user.username, sub: user.id };
+    const payload = {
+      username: user.username,
+      sub: user.id,
+      role: user.role,
+      tenantId: user.tenantId
+    };
     return {
       access_token: this.jwtService.sign(payload),
+      user: {
+        id: user.id,
+        username: user.username,
+        role: user.role,
+        tenantId: user.tenantId
+      }
     };
   }
+
+  async getOrCreateApiKey(userId: string) {
+    return this.userService.getOrCreateApiKey(userId);
+  }
+
+  async regenerateApiKey(userId: string) {
+    return this.userService.regenerateApiKey(userId);
+  }
 }

+ 76 - 0
server/src/auth/combined-auth.guard.ts

@@ -0,0 +1,76 @@
+import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { AuthGuard } from '@nestjs/passport';
+import { UserService } from '../user/user.service';
+import { Request } from 'express';
+import { IS_PUBLIC_KEY } from './public.decorator';
+
+/**
+ * A combined authentication guard that accepts either:
+ *  1. An API key via the `x-api-key` header (or `Authorization: Bearer kb_...`)
+ *  2. A standard JWT Bearer token
+ *
+ * This replaces JwtAuthGuard on routes that should support both auth methods.
+ */
+@Injectable()
+export class CombinedAuthGuard implements CanActivate {
+    // We extend AuthGuard('jwt') functionality by composition
+    private jwtGuard: ReturnType<typeof AuthGuard>;
+
+    constructor(
+        private reflector: Reflector,
+        private userService: UserService,
+    ) {
+        // Create a JWT guard instance
+        const JwtGuardClass = AuthGuard('jwt');
+        this.jwtGuard = new JwtGuardClass() as any;
+    }
+
+    async canActivate(context: ExecutionContext): Promise<boolean> {
+        // Allow @Public() decorated routes
+        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
+            context.getHandler(),
+            context.getClass(),
+        ]);
+        if (isPublic) return true;
+
+        const request = context.switchToHttp().getRequest<Request & { user?: any; tenantId?: string }>();
+
+        // --- Try API Key first ---
+        const apiKey = this.extractApiKey(request);
+        if (apiKey) {
+            const user = await this.userService.findByApiKey(apiKey);
+            if (user) {
+                request.user = {
+                    id: user.id,
+                    username: user.username,
+                    role: user.role,
+                    tenantId: user.tenantId,
+                };
+                request.tenantId = user.tenantId;
+                return true;
+            }
+            throw new UnauthorizedException('Invalid API key');
+        }
+
+        // --- Fall back to JWT ---
+        try {
+            const result = await (this.jwtGuard as any).canActivate(context);
+            return result as boolean;
+        } catch {
+            throw new UnauthorizedException('Authentication required');
+        }
+    }
+
+    private extractApiKey(request: Request): string | undefined {
+        // Allow `Authorization: Bearer kb_...` form
+        const authHeader = request.headers.authorization;
+        if (authHeader?.startsWith('Bearer kb_')) {
+            return authHeader.substring(7);
+        }
+        // Or a plain `x-api-key` header
+        const headerKey = request.headers['x-api-key'] as string;
+        if (headerKey) return headerKey;
+        return undefined;
+    }
+}

+ 28 - 0
server/src/auth/entities/api-key.entity.ts

@@ -0,0 +1,28 @@
+import {
+    Column,
+    CreateDateColumn,
+    Entity,
+    JoinColumn,
+    ManyToOne,
+    PrimaryGeneratedColumn,
+} from 'typeorm';
+import { User } from '../../user/user.entity';
+
+@Entity('api_keys')
+export class ApiKey {
+    @PrimaryGeneratedColumn('uuid')
+    id: string;
+
+    @Column({ name: 'user_id', type: 'uuid' })
+    userId: string;
+
+    @ManyToOne(() => User, (user) => user.apiKeys, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'user_id' })
+    user: User;
+
+    @Column({ type: 'text', unique: true })
+    key: string;
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+}

+ 7 - 1
server/src/auth/jwt.strategy.ts

@@ -22,11 +22,17 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
   async validate(payload: {
     sub: string;
     username: string;
+    role?: string;
+    tenantId?: string;
   }): Promise<SafeUser | null> {
     const user = await this.userService.findOneByUsername(payload.username);
     if (user) {
       const { password, ...result } = user;
-      return result as SafeUser;
+      return {
+        ...result,
+        role: payload.role || result.role,
+        tenantId: payload.tenantId || result.tenantId
+      } as SafeUser;
     }
     return null;
   }

+ 5 - 0
server/src/auth/roles.decorator.ts

@@ -0,0 +1,5 @@
+import { SetMetadata } from '@nestjs/common';
+import { UserRole } from '../user/user.entity';
+
+export const ROLES_KEY = 'roles';
+export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

+ 28 - 0
server/src/auth/roles.guard.ts

@@ -0,0 +1,28 @@
+import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { ROLES_KEY } from './roles.decorator';
+import { UserRole } from '../user/user.entity';
+
+@Injectable()
+export class RolesGuard implements CanActivate {
+    constructor(private reflector: Reflector) { }
+
+    canActivate(context: ExecutionContext): boolean {
+        const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
+            context.getHandler(),
+            context.getClass(),
+        ]);
+
+        if (!requiredRoles) {
+            return true;
+        }
+
+        const { user } = context.switchToHttp().getRequest();
+        // User might not be injected yet if auth guard fails, but auth guard runs first usually.
+        if (!user) {
+            return false;
+        }
+
+        return requiredRoles.includes(user.role);
+    }
+}

+ 27 - 5
server/src/chat/chat.controller.ts

@@ -8,8 +8,9 @@ import {
 } from '@nestjs/common';
 import { Response } from 'express';
 import { ChatMessage, ChatService } from './chat.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { ModelConfigService } from '../model-config/model-config.service';
+import { TenantService } from '../tenant/tenant.service';
 
 class StreamChatDto {
   message: string;
@@ -32,11 +33,12 @@ class StreamChatDto {
 }
 
 @Controller('chat')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class ChatController {
   constructor(
     private chatService: ChatService,
     private modelConfigService: ModelConfigService,
+    private tenantService: TenantService,
   ) { }
 
   @Post('stream')
@@ -67,8 +69,18 @@ export class ChatController {
       console.log('Query Expansion:', enableQueryExpansion);
       console.log('HyDE:', enableHyDE);
 
+      const role = req.user.role;
+      const tenantId = req.user.tenantId;
+
       // 获取用户的LLM模型配置
-      const models = await this.modelConfigService.findAll(userId);
+      let models = await this.modelConfigService.findAll(userId, tenantId);
+
+      if (role !== 'SUPER_ADMIN') {
+        const tenantSettings = await this.tenantService.getSettings(tenantId);
+        const enabledIds = tenantSettings.enabledModelIds || [];
+        // Only allow models that are enabled by the tenant admin
+        models = models.filter(m => enabledIds.includes(m.id));
+      }
 
       let llmModel;
       if (selectedLLMId) {
@@ -120,7 +132,8 @@ export class ChatController {
         similarityThreshold, // 传递 similarityThreshold 参数
         rerankSimilarityThreshold, // 传递 rerankSimilarityThreshold 参数
         enableQueryExpansion, // 传递 enableQueryExpansion
-        enableHyDE // 传递 enableHyDE
+        enableHyDE, // 传递 enableHyDE
+        req.user.tenantId // Pass tenant ID
       );
 
       for await (const chunk of stream) {
@@ -152,8 +165,17 @@ export class ChatController {
     try {
       const { instruction, context } = body;
       const userId = req.user.id; // Corrected to use req.user.id
+      const tenantId = req.user.tenantId;
+      const role = req.user.role;
 
-      const models = await this.modelConfigService.findAll(userId);
+      let models = await this.modelConfigService.findAll(userId, tenantId);
+
+      if (role !== 'SUPER_ADMIN') {
+        const tenantSettings = await this.tenantService.getSettings(tenantId);
+        const enabledIds = tenantSettings.enabledModelIds || [];
+        // Only allow models that are enabled by the tenant admin
+        models = models.filter(m => enabledIds.includes(m.id));
+      }
 
       // デフォルトモデルを優先
       const llmModel = models.find((m) => m.type === 'llm' && m.isDefault && m.isEnabled !== false);

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

@@ -8,6 +8,7 @@ import { UserSettingModule } from '../user-setting/user-setting.module';
 import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
 import { SearchHistoryModule } from '../search-history/search-history.module';
 import { RagModule } from '../rag/rag.module';
+import { TenantModule } from '../tenant/tenant.module';
 
 @Module({
   imports: [
@@ -18,6 +19,7 @@ import { RagModule } from '../rag/rag.module';
     forwardRef(() => KnowledgeGroupModule),
     SearchHistoryModule,
     RagModule,
+    TenantModule,
   ],
   controllers: [ChatController],
   providers: [ChatService],

+ 20 - 12
server/src/chat/chat.service.ts

@@ -60,7 +60,8 @@ export class ChatService {
     similarityThreshold?: number, // 新規: similarityThreshold パラメータ
     rerankSimilarityThreshold?: number, // 新規: rerankSimilarityThreshold パラメータ
     enableQueryExpansion?: boolean, // 新規
-    enableHyDE?: boolean // 新規
+    enableHyDE?: boolean, // 新規
+    tenantId?: string // 新規: tenant isolation
   ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
     console.log('=== ChatService.streamChat ===');
     console.log('ユーザーID:', userId);
@@ -96,6 +97,7 @@ export class ChatService {
       if (!currentHistoryId) {
         const searchHistory = await this.searchHistoryService.create(
           userId,
+          tenantId || 'default', // 新規
           message,
           selectedGroups,
         );
@@ -107,7 +109,7 @@ export class ChatService {
       // ユーザーメッセージを保存
       await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
       // 1. ユーザーの埋め込みモデル設定を取得
-      const models = await this.modelConfigService.findAll(userId);
+      const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
 
       // ユーザーが選択した埋め込みモデルIDを優先し、そうでない場合は最初のものを使用
       let embeddingModel;
@@ -153,7 +155,7 @@ export class ChatService {
         let effectiveFileIds = selectedFiles; // 明示的に指定されたファイルを優先
         if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
           // ナレッジグループからファイルIDを取得
-          effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId);
+          effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId, tenantId as string);
         }
 
         // 3. RagService を使用して検索 (混合検索 + Rerank をサポート)
@@ -169,6 +171,7 @@ export class ChatService {
           undefined, // selectedGroups
           effectiveFileIds,
           rerankSimilarityThreshold,
+          tenantId,
           enableQueryExpansion,
           enableHyDE
         );
@@ -269,9 +272,9 @@ export class ChatService {
       );
 
       // 7. 自動チャットタイトル生成 (最初のやり取りの後に実行)
-      const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId);
+      const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId, tenantId);
       if (messagesInHistory.messages.length === 2) {
-        this.generateChatTitle(currentHistoryId, userId).catch((err) => {
+        this.generateChatTitle(currentHistoryId, userId, tenantId).catch((err) => {
           this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err);
         });
       }
@@ -339,6 +342,7 @@ ${instruction}`;
     embeddingModelId?: string,
     selectedGroups?: string[], // 新規パラメータ
     explicitFileIds?: string[], // 新規パラメータ
+    tenantId?: string, // 追加
   ): Promise<any[]> {
     try {
       // キーワードを検索文字列に結合
@@ -371,6 +375,7 @@ ${instruction}`;
         0.6,
         selectedGroups, // 選択されたグループを渡す
         explicitFileIds, // 明示的なファイルIDを渡す
+        tenantId, // 追加: tenantId
       );
       console.log(this.i18nService.getMessage('esSearchCompleted', 'ja') + this.i18nService.getMessage('resultsCount', 'ja') + ':', results.length);
 
@@ -405,9 +410,9 @@ ${instruction}`;
       )
       .join('\n');
   }
-  async getContextForTopic(topic: string, userId: string, groupId?: string, fileIds?: string[]): Promise<string> {
+  async getContextForTopic(topic: string, userId: string, tenantId?: string, groupId?: string, fileIds?: string[]): Promise<string> {
     try {
-      const models = await this.modelConfigService.findAll(userId);
+      const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
 
       // デフォルトの埋め込みモデルを優先
       const embeddingModel = models.find(m => m.type === 'embedding' && m.isDefault && m.isEnabled !== false);
@@ -418,7 +423,8 @@ ${instruction}`;
         userId,
         embeddingModel.id,
         groupId ? [groupId] : undefined,
-        fileIds
+        fileIds,
+        tenantId
       );
 
       return this.buildContext(results);
@@ -431,13 +437,14 @@ ${instruction}`;
   async generateSimpleChat(
     messages: ChatMessage[],
     userId: string,
+    tenantId?: 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);
+        const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
         // Cast to unknown first to bypass partial mismatch between Entity and Interface
         // デフォルトのLLMモデルを優先
         const found = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
@@ -475,11 +482,11 @@ ${instruction}`;
   /**
    * 対話内容に基づいてチャットのタイトルを自動生成する
    */
-  async generateChatTitle(historyId: string, userId: string): Promise<string | null> {
+  async generateChatTitle(historyId: string, userId: string, tenantId?: string): Promise<string | null> {
     this.logger.log(`Generating automatic title for chat session ${historyId}`);
 
     try {
-      const history = await this.searchHistoryService.findOne(historyId, userId);
+      const history = await this.searchHistoryService.findOne(historyId, userId, tenantId || 'default');
       if (!history || history.messages.length < 2) {
         return null;
       }
@@ -501,7 +508,8 @@ ${instruction}`;
       // LLMを呼び出してタイトルを生成
       const generatedTitle = await this.generateSimpleChat(
         [{ role: 'user', content: prompt }],
-        userId
+        userId,
+        tenantId || 'default'
       );
 
       if (generatedTitle && generatedTitle.trim().length > 0) {

+ 35 - 0
server/src/data-source.ts

@@ -0,0 +1,35 @@
+import { DataSource } from 'typeorm';
+import { User } from './user/user.entity';
+import { UserSetting } from './user-setting/user-setting.entity';
+import { ModelConfig } from './model-config/model-config.entity';
+import { KnowledgeBase } from './knowledge-base/knowledge-base.entity';
+import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
+import { SearchHistory } from './search-history/search-history.entity';
+import { ChatMessage } from './search-history/chat-message.entity';
+import { Note } from './note/note.entity';
+import { 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';
+
+export const AppDataSource = new DataSource({
+    type: 'better-sqlite3',
+    database: './data/knowledge-base.db',
+    synchronize: false,
+    logging: true,
+    entities: [
+        User,
+        UserSetting,
+        ModelConfig,
+        KnowledgeBase,
+        KnowledgeGroup,
+        SearchHistory,
+        ChatMessage,
+        Note,
+        PodcastEpisode,
+        ImportTask,
+        Tenant,
+        TenantSetting,
+    ],
+    migrations: ['src/migrations/**/*.ts'],
+});

+ 57 - 36
server/src/elasticsearch/elasticsearch.service.ts

@@ -1,8 +1,7 @@
 
-import { Injectable, Logger, OnModuleInit, Inject, forwardRef } from '@nestjs/common';
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Client } from '@elastic/elasticsearch';
-import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
 
 @Injectable()
 export class ElasticsearchService implements OnModuleInit {
@@ -12,8 +11,6 @@ export class ElasticsearchService implements OnModuleInit {
 
   constructor(
     private configService: ConfigService,
-    @Inject(forwardRef(() => KnowledgeGroupService))
-    private knowledgeGroupService: KnowledgeGroupService,
   ) {
     const node = this.configService.get<string>('ELASTICSEARCH_HOST'); // Changed from NODE to HOST
     this.indexName = this.configService.get<string>(
@@ -122,20 +119,32 @@ export class ElasticsearchService implements OnModuleInit {
     return result;
   }
 
-  async deleteByFileId(fileId: string, userId: string) {
+  async deleteByFileId(fileId: string, userId: string, tenantId?: string) {
+    const filter: any[] = [{ term: { fileId } }];
+    if (tenantId) {
+      filter.push({ term: { tenantId } });
+    } else {
+      filter.push({ term: { userId } });
+    }
+
     await this.client.deleteByQuery({
       index: this.indexName,
       query: {
-        term: { fileId },
+        bool: { filter },
       },
     });
   }
 
-  async updateTitleByFileId(fileId: string, title: string) {
+  async updateTitleByFileId(fileId: string, title: string, tenantId?: string) {
+    const filter: any[] = [{ term: { fileId } }];
+    if (tenantId) {
+      filter.push({ term: { tenantId } });
+    }
+
     await this.client.updateByQuery({
       index: this.indexName,
       query: {
-        term: { fileId },
+        bool: { filter },
       },
       script: {
         source: 'ctx._source.title = params.title',
@@ -156,7 +165,7 @@ export class ElasticsearchService implements OnModuleInit {
     });
   }
 
-  async searchSimilar(queryVector: number[], userId: string, topK: number = 5) {
+  async searchSimilar(queryVector: number[], userId: string, topK: number = 5, tenantId?: string) {
     try {
       this.logger.log(
         `Vector search: userId=${userId}, vectorDim=${queryVector?.length}, topK=${topK}`,
@@ -167,6 +176,13 @@ export class ElasticsearchService implements OnModuleInit {
         return [];
       }
 
+      const filterClauses: any[] = [];
+      if (tenantId) {
+        filterClauses.push({ term: { tenantId } });
+      } else {
+        filterClauses.push({ term: { userId } });
+      }
+
       const response = await this.client.search({
         index: this.indexName,
         knn: {
@@ -174,6 +190,7 @@ export class ElasticsearchService implements OnModuleInit {
           query_vector: queryVector,
           k: topK,
           num_candidates: topK * 2,
+          filter: { bool: { must: filterClauses } },
         },
         size: topK,
         _source: {
@@ -203,7 +220,7 @@ export class ElasticsearchService implements OnModuleInit {
     }
   }
 
-  async searchFullText(query: string, userId: string, topK: number = 5) {
+  async searchFullText(query: string, userId: string, topK: number = 5, tenantId?: string) {
     try {
       this.logger.log(
         `Full-text search: userId=${userId}, query="${query}", topK=${topK}`,
@@ -214,14 +231,26 @@ export class ElasticsearchService implements OnModuleInit {
         return [];
       }
 
+      const filterClauses: any[] = [];
+      if (tenantId) {
+        filterClauses.push({ term: { tenantId } });
+      } else {
+        filterClauses.push({ term: { userId } });
+      }
+
       const response = await this.client.search({
         index: this.indexName,
         query: {
-          match: {
-            content: {
-              query: query,
-              fuzziness: 'AUTO',
+          bool: {
+            must: {
+              match: {
+                content: {
+                  query: query,
+                  fuzziness: 'AUTO',
+                },
+              },
             },
+            filter: filterClauses,
           },
         },
         size: topK,
@@ -258,23 +287,12 @@ export class ElasticsearchService implements OnModuleInit {
     userId: string,
     topK: number = 5,
     vectorWeight: number = 0.7,
-    selectedGroups?: string[], // 新規パラメータ
-    explicitFileIds?: string[], // 新規パラメータ:明示的に指定されたファイルIDリスト
+    selectedGroups?: string[], // 後方互換性のために残す(未使用)
+    explicitFileIds?: string[], // 明示的に指定されたファイルIDリスト
+    tenantId?: string,
   ) {
-    // 検索範囲の決定:
-    // 1. explicitFileIds が指定されている場合(例:「ファイル対話」モード)、その範囲内のみを検索し、selectedGroups は無視する
-    // 2. それ以外で selectedGroups が指定されている場合(例:「ノートブック対話」モード)、そのグループ配下の全ファイルを取得する
-    // 3. どちらも指定されていない場合、fileIds は undefined となり、全ファイルを対象に検索する(searchXXXWithFileFilter が処理)
-
-    let fileIds: string[] | undefined;
-
-    if (explicitFileIds) {
-      this.logger.log(`明示的なファイルフィルタを使用: ${explicitFileIds.length} 個のファイル`);
-      fileIds = explicitFileIds;
-    } else if (selectedGroups && selectedGroups.length > 0) {
-      this.logger.log(`グループファイルフィルタを使用: グループ ${selectedGroups.join(', ')}`);
-      fileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId);
-    }
+    // selectedGroups は廃止予定。呼び出し側で fileIds に変換して explicitFileIds を使用してください
+    const fileIds = explicitFileIds;
 
     if (fileIds && fileIds.length === 0) {
       this.logger.log('検索対象ファイルが0件のため、検索をスキップします');
@@ -287,8 +305,8 @@ export class ElasticsearchService implements OnModuleInit {
 
     // ハイブリッド検索:ベクトル検索 + 全文検索
     const [vectorResults, textResults] = await Promise.all([
-      this.searchSimilarWithFileFilter(queryVector, userId, topK, fileIds),
-      this.searchFullTextWithFileFilter(query, userId, topK, fileIds),
+      this.searchSimilarWithFileFilter(queryVector, userId, topK, fileIds, tenantId),
+      this.searchFullTextWithFileFilter(query, userId, topK, fileIds, tenantId),
     ]);
 
     // 結果をマージして重複を排除
@@ -501,6 +519,7 @@ export class ElasticsearchService implements OnModuleInit {
     userId: string,
     topK: number = 5,
     fileIds?: string[],
+    tenantId?: string,
   ) {
     try {
       this.logger.log(
@@ -532,12 +551,18 @@ export class ElasticsearchService implements OnModuleInit {
       if (fileIds && fileIds.length > 0) {
         filter.push({ terms: { fileId: fileIds } });
       }
+      if (tenantId) {
+        filter.push({ term: { tenantId } });
+      } else {
+        filter.push({ term: { userId } });
+      }
 
       const queryBody: any = {
         index: this.indexName,
         query: {
           bool: {
             must: mustClause,
+            filter: filter,
           },
         },
         size: topK,
@@ -546,10 +571,6 @@ export class ElasticsearchService implements OnModuleInit {
         },
       };
 
-      if (filter.length > 0) {
-        queryBody.query.bool.filter = filter;
-      }
-
       const response = await this.client.search(queryBody);
 
       const results = response.hits.hits.map((hit: any) => ({

+ 2 - 2
server/src/import-task/import-task.controller.ts

@@ -1,10 +1,10 @@
 import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common';
 import { ImportTaskService } from './import-task.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { AdminGuard } from '../auth/admin.guard';
 
 @Controller('import-tasks')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class ImportTaskController {
     constructor(private readonly taskService: ImportTaskService) { }
 

+ 3 - 0
server/src/import-task/import-task.entity.ts

@@ -17,6 +17,9 @@ export class ImportTask {
     @Column()
     userId: string;
 
+    @Column({ nullable: true })
+    tenantId: string;
+
     @Column({ nullable: true })
     scheduledAt: Date;
 

+ 4 - 4
server/src/import-task/import-task.service.ts

@@ -90,7 +90,7 @@ export class ImportTaskService {
             let groupId = task.targetGroupId;
             if (!groupId && task.targetGroupName) {
                 // Create new group
-                const group = await this.groupService.create(task.userId, {
+                const group = await this.groupService.create(task.userId, task.tenantId || 'default', {
                     name: task.targetGroupName,
                     description: `Imported from ${task.sourcePath}`,
                     color: '#0078D4', // Default blue
@@ -164,14 +164,14 @@ export class ImportTaskService {
 
                     // Ingest sequentially
                     this.logger.log(`Processing file ${i + 1}/${filesToImport.length}: ${fileInfo.originalname}`);
-                    const kb = await this.kbService.createAndIndex(fileInfo, task.userId, {
+                    const kb = await this.kbService.createAndIndex(fileInfo, task.userId, task.tenantId || 'default', {
                         ...indexingConfig,
                         waitForCompletion: true // Ensure sequential processing
-                    });
+                    } as any);
                     this.logger.log(`File ${i + 1}/${filesToImport.length} processing completed`);
 
                     // Link to Group
-                    await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId);
+                    await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId, task.tenantId || 'default');
                     this.logger.debug(`Task ${taskId}: Linked KB ${kb.id} to group ${groupId}.`);
 
                     successCount++;

+ 13 - 8
server/src/knowledge-base/chunk-config.service.ts

@@ -62,14 +62,14 @@ export class ChunkConfigService {
   /**
    * モデルの制限設定を取得(データベースから読み込み)
    */
-  async getModelLimits(modelId: string, userId: string): Promise<{
+  async getModelLimits(modelId: string, userId: string, tenantId?: string): Promise<{
     maxInputTokens: number;
     maxBatchSize: number;
     expectedDimensions: number;
     providerName: string;
     isVectorModel: boolean;
   }> {
-    const modelConfig = await this.modelConfigService.findOne(modelId, userId);
+    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
 
     if (!modelConfig || modelConfig.type !== 'embedding') {
       throw new BadRequestException(this.i18nService.formatMessage('embeddingModelNotFound', { id: modelId }));
@@ -109,6 +109,7 @@ export class ChunkConfigService {
     chunkOverlap: number,
     modelId: string,
     userId: string,
+    tenantId?: string,
   ): Promise<{
     chunkSize: number;
     chunkOverlap: number;
@@ -117,7 +118,7 @@ export class ChunkConfigService {
     effectiveMaxOverlapSize: number;
   }> {
     const warnings: string[] = [];
-    const limits = await this.getModelLimits(modelId, userId);
+    const limits = await this.getModelLimits(modelId, userId, tenantId);
 
     // 1. 最終的な上限を計算(環境変数とモデル制限の小さい方を選択)
     const effectiveMaxChunkSize = Math.min(
@@ -235,9 +236,10 @@ export class ChunkConfigService {
   async getRecommendedBatchSize(
     modelId: string,
     userId: string,
+    tenantId?: string,
     currentBatchSize: number = 100,
   ): Promise<number> {
-    const limits = await this.getModelLimits(modelId, userId);
+    const limits = await this.getModelLimits(modelId, userId, tenantId);
 
     // 設定値とモデル制限の小さい方を選択
     const recommended = Math.min(
@@ -274,8 +276,9 @@ export class ChunkConfigService {
     modelId: string,
     userId: string,
     actualDimensions: number,
+    tenantId?: string,
   ): Promise<boolean> {
-    const limits = await this.getModelLimits(modelId, userId);
+    const limits = await this.getModelLimits(modelId, userId, tenantId);
 
     if (actualDimensions !== limits.expectedDimensions) {
       this.logger.warn(
@@ -299,8 +302,9 @@ export class ChunkConfigService {
     chunkOverlap: number,
     modelId: string,
     userId: string,
+    tenantId?: string,
   ): Promise<string> {
-    const limits = await this.getModelLimits(modelId, userId);
+    const limits = await this.getModelLimits(modelId, userId, tenantId);
 
     return [
       `モデル: ${modelId}`,
@@ -318,6 +322,7 @@ export class ChunkConfigService {
   async getFrontendLimits(
     modelId: string,
     userId: string,
+    tenantId?: string,
   ): Promise<{
     maxChunkSize: number;
     maxOverlapSize: number;
@@ -331,7 +336,7 @@ export class ChunkConfigService {
       expectedDimensions: number;
     };
   }> {
-    const limits = await this.getModelLimits(modelId, userId);
+    const limits = await this.getModelLimits(modelId, userId, tenantId);
 
     // 最終的な上限を計算(環境変数とモデル制限の小さい方を選択)
     const maxChunkSize = Math.min(this.envMaxChunkSize, limits.maxInputTokens);
@@ -341,7 +346,7 @@ export class ChunkConfigService {
     );
 
     // モデル設定名を取得
-    const modelConfig = await this.modelConfigService.findOne(modelId, userId);
+    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
     const modelName = modelConfig?.name || 'Unknown';
 
     return {

+ 7 - 5
server/src/knowledge-base/embedding.service.ts

@@ -33,12 +33,14 @@ export class EmbeddingService {
     texts: string[],
     userId: string,
     embeddingModelConfigId: string,
+    tenantId?: string,
   ): Promise<number[][]> {
     this.logger.log(`${texts.length} 個のテキストに対して埋め込みベクトルを生成しています`);
 
     const modelConfig = await this.modelConfigService.findOne(
       embeddingModelConfigId,
       userId,
+      tenantId || 'default',
     );
     if (!modelConfig || modelConfig.type !== 'embedding') {
       throw new Error(`埋め込みモデル設定 ${embeddingModelConfigId} が見つかりません`);
@@ -100,7 +102,7 @@ export class EmbeddingService {
   private getMaxBatchSizeForModel(modelId: string, configuredMaxBatchSize?: number): number {
     // モデル固有のバッチサイズ制限
     if (modelId.includes('text-embedding-004') || modelId.includes('text-embedding-v4') ||
-        modelId.includes('text-embedding-ada-002')) {
+      modelId.includes('text-embedding-ada-002')) {
       return Math.min(10, configuredMaxBatchSize || 100); // Googleの場合は10を上限
     } else if (modelId.includes('text-embedding-3') || modelId.includes('text-embedding-003')) {
       return Math.min(2048, configuredMaxBatchSize || 2048); // OpenAI v3は2048が上限
@@ -161,9 +163,9 @@ export class EmbeddingService {
 
           // バッチサイズ制限エラーを検出
           if (errorText.includes('batch size is invalid') || errorText.includes('batch_size') ||
-              errorText.includes('invalid') || errorText.includes('larger than')) {
+            errorText.includes('invalid') || errorText.includes('larger than')) {
             this.logger.warn(
-              `バッチサイズ制限エラーが検出されました。バッチサイズを半分に分割して再試行します: ${maxBatchSize} -> ${Math.floor(maxBatchSize/2)}`
+              `バッチサイズ制限エラーが検出されました。バッチサイズを半分に分割して再試行します: ${maxBatchSize} -> ${Math.floor(maxBatchSize / 2)}`
             );
 
             // バッチをさらに小さな単位に分割して再試行
@@ -172,8 +174,8 @@ export class EmbeddingService {
               const firstHalf = texts.slice(0, midPoint);
               const secondHalf = texts.slice(midPoint);
 
-              const firstResult = await this.getEmbeddingsForBatch(firstHalf, userId, modelConfig, Math.floor(maxBatchSize/2));
-              const secondResult = await this.getEmbeddingsForBatch(secondHalf, userId, modelConfig, Math.floor(maxBatchSize/2));
+              const firstResult = await this.getEmbeddingsForBatch(firstHalf, userId, modelConfig, Math.floor(maxBatchSize / 2));
+              const secondResult = await this.getEmbeddingsForBatch(secondHalf, userId, modelConfig, Math.floor(maxBatchSize / 2));
 
               return [...firstResult, ...secondResult];
             }

+ 26 - 25
server/src/knowledge-base/knowledge-base.controller.ts

@@ -16,8 +16,10 @@ import { Response } from 'express';
 import * as path from 'path';
 import { Logger } from '@nestjs/common';
 import { KnowledgeBaseService } from './knowledge-base.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
-import { AdminGuard } from '../auth/admin.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user.entity';
 import { Public } from '../auth/public.decorator';
 import { KnowledgeBase } from './knowledge-base.entity';
 import { ChunkConfigService } from './chunk-config.service';
@@ -25,7 +27,7 @@ import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.servic
 import { I18nService } from '../i18n/i18n.service';
 
 @Controller('knowledge-bases')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard, RolesGuard)
 export class KnowledgeBaseController {
   private readonly logger = new Logger(KnowledgeBaseController.name);
 
@@ -37,67 +39,66 @@ export class KnowledgeBaseController {
   ) { }
 
   @Get()
-  @UseGuards(JwtAuthGuard)
+  @UseGuards(CombinedAuthGuard)
   async findAll(@Request() req): Promise<KnowledgeBase[]> {
-    return this.knowledgeBaseService.findAll(req.user.id);
+    return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId);
   }
 
   @Delete('clear')
-  @UseGuards(AdminGuard)  // Only admin can clear all knowledge base
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async clearAll(@Request() req): Promise<{ message: string }> {
-    await this.knowledgeBaseService.clearAll(req.user.id);
+    await this.knowledgeBaseService.clearAll(req.user.id, req.user.tenantId);
     return { message: this.i18nService.getMessage('kbCleared') };
   }
 
   @Post('search')
-  @UseGuards(JwtAuthGuard)
   async search(@Request() req, @Body() body: { query: string; topK?: number }) {
     return this.knowledgeBaseService.searchKnowledge(
       req.user.id,
+      req.user.tenantId, // New
       body.query,
       body.topK || 5,
     );
   }
 
   @Post('rag-search')
-  @UseGuards(JwtAuthGuard)
   async ragSearch(
     @Request() req,
     @Body() body: { query: string; settings: any },
   ) {
     return this.knowledgeBaseService.ragSearch(
       req.user.id,
+      req.user.tenantId, // New
       body.query,
       body.settings,
     );
   }
 
   @Delete(':id')
-  @UseGuards(AdminGuard)  // Only admin can delete files
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async deleteFile(
     @Request() req,
     @Param('id') fileId: string,
   ): Promise<{ message: string }> {
-    await this.knowledgeBaseService.deleteFile(fileId, req.user.id);
+    await this.knowledgeBaseService.deleteFile(fileId, req.user.id, req.user.tenantId);
     return { message: this.i18nService.getMessage('fileDeleted') };
   }
 
   @Post(':id/retry')
-  @UseGuards(AdminGuard)  // Only admin can retry files
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async retryFile(
     @Request() req,
     @Param('id') fileId: string,
   ): Promise<KnowledgeBase> {
-    return this.knowledgeBaseService.retryFailedFile(fileId, req.user.id);
+    return this.knowledgeBaseService.retryFailedFile(fileId, req.user.id, req.user.tenantId);
   }
 
   @Get(':id/chunks')
-  @UseGuards(JwtAuthGuard)
   async getFileChunks(
     @Request() req,
     @Param('id') fileId: string,
   ) {
-    return this.knowledgeBaseService.getFileChunks(fileId, req.user.id);
+    return this.knowledgeBaseService.getFileChunks(fileId, req.user.id, req.user.tenantId);
   }
 
 
@@ -106,7 +107,6 @@ export class KnowledgeBaseController {
    * クエリパラメータ: embeddingModelId - 埋め込みモデルID
    */
   @Get('chunk-config/limits')
-  @UseGuards(JwtAuthGuard)
   async getChunkConfigLimits(
     @Request() req,
     @Query('embeddingModelId') embeddingModelId: string,
@@ -133,9 +133,9 @@ export class KnowledgeBaseController {
     );
   }
 
-  // ファイルグループ管理 - 管理者権限を追加
+  // 文件分组管理 - 需要管理员权限
   @Post(':id/groups')
-  @UseGuards(AdminGuard)  // Only admin can add files to groups
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async addFileToGroups(
     @Param('id') fileId: string,
     @Body() body: { groupIds: string[] },
@@ -145,12 +145,13 @@ export class KnowledgeBaseController {
       fileId,
       body.groupIds,
       req.user.id,
+      req.user.tenantId,
     );
     return { message: this.i18nService.getMessage('groupSyncSuccess') };
   }
 
   @Delete(':id/groups/:groupId')
-  @UseGuards(AdminGuard)  // Only admin can remove files from groups
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async removeFileFromGroup(
     @Param('id') fileId: string,
     @Param('groupId') groupId: string,
@@ -160,6 +161,7 @@ export class KnowledgeBaseController {
       fileId,
       groupId,
       req.user.id,
+      req.user.tenantId,
     );
     return { message: this.i18nService.getMessage('fileDeletedFromGroup') };
   }
@@ -197,6 +199,7 @@ export class KnowledgeBaseController {
       const pdfPath = await this.knowledgeBaseService.ensurePDFExists(
         fileId,
         decoded.userId,
+        decoded.tenantId, // New
       );
 
       const fs = await import('fs');
@@ -233,7 +236,6 @@ export class KnowledgeBaseController {
 
   // PDF プレビューアドレスを取得
   @Get(':id/pdf-url')
-  @UseGuards(JwtAuthGuard)  // Allow any authenticated user to access their own PDF
   async getPDFUrl(
     @Param('id') fileId: string,
     @Query('force') force: string,
@@ -241,7 +243,7 @@ export class KnowledgeBaseController {
   ) {
     try {
       // PDF 変換をトリガー
-      await this.knowledgeBaseService.ensurePDFExists(fileId, req.user.id, force === 'true');
+      await this.knowledgeBaseService.ensurePDFExists(fileId, req.user.id, req.user.tenantId, force === 'true');
 
       // 一時的なアクセストークンを生成
       const jwt = await import('jsonwebtoken');
@@ -252,7 +254,7 @@ export class KnowledgeBaseController {
       }
 
       const token = jwt.sign(
-        { fileId, userId: req.user.id, type: 'pdf-access' },
+        { fileId, userId: req.user.id, tenantId: req.user.tenantId, type: 'pdf-access' },
         secret,
         { expiresIn: '1h' }
       );
@@ -269,17 +271,15 @@ export class KnowledgeBaseController {
   }
 
   @Get(':id/pdf-status')
-  @UseGuards(JwtAuthGuard)  // Allow any authenticated user to check their own PDF status
   async getPDFStatus(
     @Param('id') fileId: string,
     @Request() req,
   ) {
-    return await this.knowledgeBaseService.getPDFStatus(fileId, req.user.id);
+    return await this.knowledgeBaseService.getPDFStatus(fileId, req.user.id, req.user.tenantId);
   }
 
   // PDF の特定ページの画像を取得
   @Get(':id/page/:index')
-  @UseGuards(JwtAuthGuard)  // Allow any authenticated user to access their own PDF page images
   async getPageImage(
     @Param('id') fileId: string,
     @Param('index') index: number,
@@ -291,6 +291,7 @@ export class KnowledgeBaseController {
         fileId,
         Number(index),
         req.user.id,
+        req.user.tenantId,
       );
 
       const fs = await import('fs');

+ 8 - 1
server/src/knowledge-base/knowledge-base.entity.ts

@@ -5,8 +5,11 @@ import {
   PrimaryGeneratedColumn,
   UpdateDateColumn,
   ManyToMany,
+  ManyToOne,
+  JoinColumn,
 } from 'typeorm';
 import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
+import { Tenant } from '../tenant/tenant.entity';
 
 export enum FileStatus {
   PENDING = 'pending',
@@ -51,9 +54,13 @@ export class KnowledgeBase {
   @Column({ name: 'user_id', nullable: true }) // 暫定的に空を許可(デバッグ用)、将来的には必須にすべき
   userId: string;
 
-  @Column({ name: 'tenant_id', nullable: true })
+  @Column({ name: 'tenant_id', nullable: true, type: 'text' })
   tenantId: string;
 
+  @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'tenant_id' })
+  tenant: Tenant;
+
   @Column({ type: 'text', nullable: true })
   content: string; // Tika で抽出されたテキスト内容を保存
 

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

@@ -18,11 +18,13 @@ 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';
+import { UserModule } from '../user/user.module';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 
 @Module({
   imports: [
     TypeOrmModule.forFeature([KnowledgeBase]),
-    ElasticsearchModule,
+    forwardRef(() => ElasticsearchModule),
     TikaModule,
     ModelConfigModule,
     forwardRef(() => RagModule),
@@ -33,6 +35,7 @@ import { ChatModule } from '../chat/chat.module';
     VisionPipelineModule,
     forwardRef(() => KnowledgeGroupModule),
     forwardRef(() => ChatModule),
+    UserModule,
   ],
   controllers: [KnowledgeBaseController],
   providers: [
@@ -41,6 +44,7 @@ import { ChatModule } from '../chat/chat.module';
     TextChunkerService,
     MemoryMonitorService,
     ChunkConfigService,
+    CombinedAuthGuard,
   ],
   exports: [KnowledgeBaseService, EmbeddingService],
 })

+ 104 - 55
server/src/knowledge-base/knowledge-base.service.ts

@@ -29,6 +29,7 @@ export class KnowledgeBaseService {
   constructor(
     @InjectRepository(KnowledgeBase)
     private kbRepository: Repository<KnowledgeBase>,
+    @Inject(forwardRef(() => ElasticsearchService))
     private elasticsearchService: ElasticsearchService,
     private tikaService: TikaService,
     private embeddingService: EmbeddingService,
@@ -52,6 +53,7 @@ export class KnowledgeBaseService {
   async createAndIndex(
     fileInfo: any,
     userId: string,
+    tenantId: string,
     config?: any,
   ): Promise<KnowledgeBase> {
     const mode = config?.mode || 'fast';
@@ -64,6 +66,7 @@ export class KnowledgeBaseService {
       mimetype: fileInfo.mimetype,
       status: FileStatus.PENDING,
       userId: userId,
+      tenantId: tenantId,
       chunkSize: config?.chunkSize || 200,
       chunkOverlap: config?.chunkOverlap || 40,
       embeddingModelId: config?.embeddingModelId || null,
@@ -76,12 +79,39 @@ export class KnowledgeBaseService {
       `Created KB record: ${savedKb.id}, mode: ${mode}, file: ${fileInfo.originalname}`
     );
 
+    // ---------------------------------------------------------
+    // Move the file to the final partitioned directory
+    // source: uploads/{tenantId}/{filename} (or wherever it was)
+    // target: uploads/{tenantId}/{savedKb.id}/{filename}
+    // ---------------------------------------------------------
+    const fs = await import('fs');
+    const path = await import('path');
+    const uploadPath = process.env.UPLOAD_FILE_PATH || './uploads';
+    const targetDir = path.join(uploadPath, tenantId || 'default', savedKb.id);
+    const targetPath = path.join(targetDir, fileInfo.filename);
+
+    try {
+      if (!fs.existsSync(targetDir)) {
+        fs.mkdirSync(targetDir, { recursive: true });
+      }
+      if (fs.existsSync(fileInfo.path)) {
+        fs.renameSync(fileInfo.path, targetPath);
+        // Update the DB record with the new path
+        savedKb.storagePath = targetPath;
+        await this.kbRepository.save(savedKb);
+        this.logger.log(`Moved file to partitioned storage: ${targetPath}`);
+      }
+    } catch (fsError) {
+      this.logger.error(`Failed to move file ${savedKb.id} to partitioned storage`, fsError);
+      // We will let it continue, but the file might be stuck in the temp/root folder
+    }
+
     // If queue processing is requested, await completion
     if (config?.waitForCompletion) {
-      await this.processFile(savedKb.id, userId, config);
+      await this.processFile(savedKb.id, userId, tenantId, config);
     } else {
       // Otherwise trigger asynchronously (default)
-      this.processFile(savedKb.id, userId, config).catch((err) => {
+      this.processFile(savedKb.id, userId, tenantId, config).catch((err) => {
         this.logger.error(`Error processing file ${savedKb.id}`, err);
       });
     }
@@ -89,14 +119,19 @@ export class KnowledgeBaseService {
     return savedKb;
   }
 
-  async findAll(userId: string): Promise<KnowledgeBase[]> {
+  async findAll(userId: string, tenantId?: string): Promise<KnowledgeBase[]> {
+    const where: any = { userId };
+    if (tenantId) {
+      where.tenantId = tenantId;
+    }
     return this.kbRepository.find({
+      where,
       relations: ['groups'], // グループリレーションをロード
       order: { createdAt: 'DESC' },
     });
   }
 
-  async searchKnowledge(userId: string, query: string, topK: number = 5) {
+  async searchKnowledge(userId: string, tenantId: string, query: string, topK: number = 5) {
     try {
       // 環境変数のデフォルト次元数を使用してシミュレーションベクトルを生成
       const defaultDimensions = parseInt(
@@ -113,6 +148,7 @@ export class KnowledgeBaseService {
         queryVector,
         userId,
         topK,
+        tenantId, // Ensure tenant isolation in ES
       );
 
       // 3. Get file information from database
@@ -151,7 +187,7 @@ export class KnowledgeBaseService {
     }
   }
 
-  async ragSearch(userId: string, query: string, settings: any) {
+  async ragSearch(userId: string, tenantId: string, query: string, settings: any) {
     this.logger.log(
       `RAG search request: userId=${userId}, query="${query}", settings=${JSON.stringify(settings)}`,
     );
@@ -169,6 +205,7 @@ export class KnowledgeBaseService {
         undefined,
         undefined,
         settings.rerankSimilarityThreshold,
+        tenantId, // Ensure tenant isolation in RAG
       );
 
       const sources = this.ragService.extractSources(ragResults);
@@ -204,13 +241,13 @@ export class KnowledgeBaseService {
     }
   }
 
-  async deleteFile(fileId: string, userId: string): Promise<void> {
+  async deleteFile(fileId: string, userId: string, tenantId: string): Promise<void> {
     this.logger.log(`Deleting file ${fileId} for user ${userId}`);
 
     try {
       // 1. Get file info
       const file = await this.kbRepository.findOne({
-        where: { id: fileId },
+        where: { id: fileId, tenantId }, // Filter by tenantId
       });
       if (!file) {
         throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
@@ -229,7 +266,7 @@ export class KnowledgeBaseService {
 
       // 3. Delete from Elasticsearch
       try {
-        await this.elasticsearchService.deleteByFileId(fileId, userId);
+        await this.elasticsearchService.deleteByFileId(fileId, userId, tenantId);
         this.logger.log(`Deleted ES documents for file ${fileId}`);
       } catch (error) {
         this.logger.warn(
@@ -240,7 +277,7 @@ export class KnowledgeBaseService {
 
       // 4. Remove from all groups (cleanup M2M relations)
       const fileWithGroups = await this.kbRepository.findOne({
-        where: { id: fileId },
+        where: { id: fileId, tenantId },
         relations: ['groups'],
       });
 
@@ -261,15 +298,15 @@ export class KnowledgeBaseService {
 
   }
 
-  async clearAll(userId: string): Promise<void> {
-    this.logger.log(`Clearing all knowledge base data for user ${userId}`);
+  async clearAll(userId: string, tenantId: string): Promise<void> {
+    this.logger.log(`Clearing all knowledge base data for user ${userId} in tenant ${tenantId}`);
 
     try {
       // Get all files and delete them one by one
       const files = await this.kbRepository.find();
 
       for (const file of files) {
-        await this.deleteFile(file.id, userId);
+        await this.deleteFile(file.id, userId, tenantId);
       }
 
       this.logger.log(`Cleared all knowledge base data for user ${userId}`);
@@ -282,7 +319,7 @@ export class KnowledgeBaseService {
     }
   }
 
-  private async processFile(kbId: string, userId: string, config?: any) {
+  private async processFile(kbId: string, userId: string, tenantId: string, config?: any) {
     this.logger.log(`Starting processing for file ${kbId}, mode: ${config?.mode || 'fast'}`);
     await this.updateStatus(kbId, FileStatus.INDEXING);
 
@@ -302,10 +339,10 @@ export class KnowledgeBaseService {
 
       if (mode === 'precise') {
         // 精密モード - Vision Pipeline を使用
-        await this.processPreciseMode(kb, userId, config);
+        await this.processPreciseMode(kb, userId, tenantId, config);
       } else {
         // 高速モード - Tika を使用
-        await this.processFastMode(kb, userId, config);
+        await this.processFastMode(kb, userId, tenantId, config);
       }
 
       this.logger.log(`File ${kbId} processed successfully in ${mode} mode.`);
@@ -318,7 +355,7 @@ export class KnowledgeBaseService {
   /**
    * 高速モード処理(既存フロー)
    */
-  private async processFastMode(kb: KnowledgeBase, userId: string, config?: any) {
+  private async processFastMode(kb: KnowledgeBase, userId: string, tenantId: string, config?: any) {
     // 1. Tika を使用してテキストを抽出
     let text = await this.tikaService.extractText(kb.storagePath);
 
@@ -329,6 +366,7 @@ export class KnowledgeBaseService {
         const visionModel = await this.modelConfigService.findOne(
           visionModelId,
           userId,
+          tenantId,
         );
         if (visionModel && visionModel.type === 'vision' && visionModel.isEnabled !== false) {
           text = await this.visionService.extractImageContent(kb.storagePath, {
@@ -355,7 +393,7 @@ export class KnowledgeBaseService {
     await this.updateStatus(kb.id, FileStatus.EXTRACTED);
 
     // 非同期ベクトル化
-    await this.vectorizeToElasticsearch(kb.id, userId, text, config).catch((err) => {
+    await this.vectorizeToElasticsearch(kb.id, userId, tenantId, text, config).catch((err) => {
       this.logger.error(`Error vectorizing file ${kb.id}`, err);
     });
 
@@ -365,7 +403,7 @@ export class KnowledgeBaseService {
     });
 
     // 非同期的に PDF 変換をトリガー(ドキュメントファイルの場合)
-    this.ensurePDFExists(kb.id, userId).catch((err) => {
+    this.ensurePDFExists(kb.id, userId, tenantId).catch((err) => {
       this.logger.warn(this.i18nService.formatMessage('pdfConversionFailedDetail', { id: kb.id }), err);
     });
   }
@@ -373,7 +411,7 @@ export class KnowledgeBaseService {
   /**
    * 精密モード処理(新規フロー)
    */
-  private async processPreciseMode(kb: KnowledgeBase, userId: string, config?: any) {
+  private async processPreciseMode(kb: KnowledgeBase, userId: string, tenantId: string, config?: any) {
     // 精密モードがサポートされているか確認
     const preciseFormats = ['.pdf', '.doc', '.docx', '.ppt', '.pptx'];
     const ext = kb.originalName.toLowerCase().substring(kb.originalName.lastIndexOf('.'));
@@ -382,7 +420,7 @@ export class KnowledgeBaseService {
       this.logger.warn(
         this.i18nService.formatMessage('preciseModeUnsupported', { ext })
       );
-      return this.processFastMode(kb, userId, config);
+      return this.processFastMode(kb, userId, tenantId, config);
     }
 
     // Vision モデルが設定されているか確認
@@ -391,18 +429,19 @@ export class KnowledgeBaseService {
       this.logger.warn(
         this.i18nService.getMessage('visionModelNotConfiguredFallback')
       );
-      return this.processFastMode(kb, userId, config);
+      return this.processFastMode(kb, userId, tenantId, config);
     }
 
     const visionModel = await this.modelConfigService.findOne(
       visionModelId,
       userId,
+      tenantId,
     );
     if (!visionModel || visionModel.type !== 'vision' || visionModel.isEnabled === false) {
       this.logger.warn(
         this.i18nService.getMessage('visionModelInvalidFallback')
       );
-      return this.processFastMode(kb, userId, config);
+      return this.processFastMode(kb, userId, tenantId, config);
     }
 
     // Vision Pipeline を呼び出し
@@ -411,6 +450,7 @@ export class KnowledgeBaseService {
         kb.storagePath,
         {
           userId,
+          tenantId, // New
           modelId: visionModelId,
           fileId: kb.id,
           fileName: kb.originalName,
@@ -421,7 +461,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);
+        return this.processFastMode(kb, userId, tenantId, config);
       }
 
       // テキスト内容をデータベースに保存
@@ -450,12 +490,12 @@ export class KnowledgeBaseService {
 
       // 非同期でベクトル化し、Elasticsearch にインデックス
       // 各ページを独立したドキュメントとして作成し、メタデータを保持
-      this.indexPreciseResults(kb, userId, kb.embeddingModelId, result.results).catch((err) => {
+      this.indexPreciseResults(kb, userId, tenantId, kb.embeddingModelId, result.results).catch((err) => {
         this.logger.error(`Error indexing precise results for ${kb.id}`, err);
       });
 
       // 非同期で PDF 変換をトリガー
-      this.ensurePDFExists(kb.id, userId).catch((err) => {
+      this.ensurePDFExists(kb.id, userId, tenantId).catch((err) => {
         this.logger.warn(`Initial PDF conversion failed for ${kb.id}`, err);
       });
 
@@ -466,7 +506,7 @@ export class KnowledgeBaseService {
 
     } catch (error) {
       this.logger.error(`Vision pipeline error: ${error.message}, falling back to fast mode`);
-      return this.processFastMode(kb, userId, config);
+      return this.processFastMode(kb, userId, tenantId, config);
     }
   }
 
@@ -476,13 +516,14 @@ export class KnowledgeBaseService {
   private async indexPreciseResults(
     kb: KnowledgeBase,
     userId: string,
+    tenantId: string,
     embeddingModelId: string,
     results: any[]
   ): Promise<void> {
     this.logger.log(`Indexing ${results.length} precise results for ${kb.id}`);
 
     // インデックスの存在を確認 - 実際のモデル次元数を取得
-    const actualDimensions = await this.getActualModelDimensions(embeddingModelId, userId);
+    const actualDimensions = await this.getActualModelDimensions(embeddingModelId, userId, tenantId);
     await this.elasticsearchService.createIndexIfNotExists(actualDimensions);
 
     // ベクトル化とインデックスをバッチ処理
@@ -519,6 +560,7 @@ export class KnowledgeBaseService {
               originalName: kb.originalName,
               mimetype: kb.mimetype,
               userId: userId,
+              tenantId: tenantId, // New
               pageNumber: result.pageIndex,
               images: result.images,
               layout: result.layout,
@@ -542,8 +584,8 @@ export class KnowledgeBaseService {
   /**
    * PDF の特定ページの画像を取得
    */
-  async getPageAsImage(fileId: string, pageIndex: number, userId: string): Promise<string> {
-    const pdfPath = await this.ensurePDFExists(fileId, userId);
+  async getPageAsImage(fileId: string, pageIndex: number, userId: string, tenantId: string): Promise<string> {
+    const pdfPath = await this.ensurePDFExists(fileId, userId, tenantId);
 
     // 特定のページを変換
     const result = await this.pdf2ImageService.convertToImages(pdfPath, {
@@ -564,11 +606,12 @@ export class KnowledgeBaseService {
   private async vectorizeToElasticsearch(
     kbId: string,
     userId: string,
+    tenantId: string,
     text: string,
     config?: any,
   ) {
     try {
-      const kb = await this.kbRepository.findOne({ where: { id: kbId } });
+      const kb = await this.kbRepository.findOne({ where: { id: kbId, tenantId } });
       if (!kb) return;
 
       // メモリ監視 - ベクトル化前チェック
@@ -643,6 +686,7 @@ export class KnowledgeBaseService {
       const recommendedBatchSize = await this.chunkConfigService.getRecommendedBatchSize(
         kb.embeddingModelId,
         userId,
+        tenantId,
         parseInt(process.env.CHUNK_BATCH_SIZE || '100'),
       );
 
@@ -656,7 +700,7 @@ export class KnowledgeBaseService {
       this.logger.log(`推定メモリ使用量: ${estimatedMemory}MB (バッチサイズ: ${recommendedBatchSize})`);
 
       // 6. 実際のモデル次元数を取得し、インデックスの存在を確認
-      const actualDimensions = await this.getActualModelDimensions(kb.embeddingModelId, userId);
+      const actualDimensions = await this.getActualModelDimensions(kb.embeddingModelId, userId, tenantId);
       await this.elasticsearchService.createIndexIfNotExists(actualDimensions);
 
       // 7. ベクトル化とインデックス作成をバッチ処理
@@ -713,8 +757,8 @@ export class KnowledgeBaseService {
                     userId: userId,
                     chunkIndex: chunk.index,
                     startPosition: chunk.startPosition,
-                    endPosition: chunk.endPosition,
-                  },
+                    tenantId, // Passing tenantId to ES
+                  }
                 );
               }
 
@@ -763,7 +807,8 @@ export class KnowledgeBaseService {
                     chunkIndex: chunk.index,
                     startPosition: chunk.startPosition,
                     endPosition: chunk.endPosition,
-                  },
+                    tenantId,
+                  }
                 );
 
                 if ((i + 1) % 10 === 0) {
@@ -824,7 +869,8 @@ export class KnowledgeBaseService {
                       chunkIndex: chunk.index,
                       startPosition: chunk.startPosition,
                       endPosition: chunk.endPosition,
-                    },
+                      tenantId, // Passing tenantId to ES metadata
+                    }
                   );
                 }
               },
@@ -1064,12 +1110,12 @@ export class KnowledgeBaseService {
   /**
    * 失敗したファイルのベクトル化を再試行
    */
-  async retryFailedFile(fileId: string, userId: string): Promise<KnowledgeBase> {
-    this.logger.log(`Retrying failed file ${fileId} for user ${userId}`);
+  async retryFailedFile(fileId: string, userId: string, tenantId: string): Promise<KnowledgeBase> {
+    this.logger.log(`Retrying failed file ${fileId} for user ${userId} in tenant ${tenantId}`);
 
-    // 1. Get file without user restriction (now allowing access to all files)
+    // 1. Get file with tenant restriction
     const kb = await this.kbRepository.findOne({
-      where: { id: fileId },
+      where: { id: fileId, tenantId },
     });
 
     if (!kb) {
@@ -1091,6 +1137,7 @@ export class KnowledgeBaseService {
     this.vectorizeToElasticsearch(
       fileId,
       userId,
+      tenantId,
       kb.content,
       {
         chunkSize: kb.chunkSize,
@@ -1102,7 +1149,7 @@ export class KnowledgeBaseService {
     });
 
     // 4. 更新後のファイルステータスを返却
-    const updatedKb = await this.kbRepository.findOne({ where: { id: fileId } });
+    const updatedKb = await this.kbRepository.findOne({ where: { id: fileId, tenantId } });
     if (!updatedKb) {
       throw new NotFoundException('ファイルが存在しません');
     }
@@ -1112,12 +1159,12 @@ export class KnowledgeBaseService {
   /**
    * ファイルのすべてのチャンク情報を取得
    */
-  async getFileChunks(fileId: string, userId: string) {
-    this.logger.log(`Getting chunks for file ${fileId}, user ${userId}`);
+  async getFileChunks(fileId: string, userId: string, tenantId: string) {
+    this.logger.log(`Getting chunks for file ${fileId}, user ${userId}, tenant ${tenantId}`);
 
-    // 1. Get file without user ownership check (now allowing access to all files)
+    // 1. Get file with tenant check
     const kb = await this.kbRepository.findOne({
-      where: { id: fileId },
+      where: { id: fileId, tenantId },
     });
 
     if (!kb) {
@@ -1149,9 +1196,9 @@ export class KnowledgeBaseService {
   }
 
   // PDF プレビュー関連メソッド
-  async ensurePDFExists(fileId: string, userId: string, force: boolean = false): Promise<string> {
+  async ensurePDFExists(fileId: string, userId: string, tenantId: string, force: boolean = false): Promise<string> {
     const kb = await this.kbRepository.findOne({
-      where: { id: fileId },
+      where: { id: fileId, tenantId },
     });
 
     if (!kb) {
@@ -1225,9 +1272,9 @@ export class KnowledgeBaseService {
     }
   }
 
-  async getPDFStatus(fileId: string, userId: string) {
+  async getPDFStatus(fileId: string, userId: string, tenantId: string) {
     const kb = await this.kbRepository.findOne({
-      where: { id: fileId },
+      where: { id: fileId, tenantId },
     });
 
     if (!kb) {
@@ -1236,7 +1283,7 @@ export class KnowledgeBaseService {
 
     // 元ファイルが PDF の場合
     if (kb.mimetype === 'application/pdf') {
-      const token = this.generateTempToken(fileId, userId);
+      const token = this.generateTempToken(fileId, userId, tenantId);
       return {
         status: 'ready',
         url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`,
@@ -1256,7 +1303,7 @@ export class KnowledgeBaseService {
         kb.pdfPath = pdfPath;
         await this.kbRepository.save(kb);
       }
-      const token = this.generateTempToken(fileId, userId);
+      const token = this.generateTempToken(fileId, userId, tenantId);
       return {
         status: 'ready',
         url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`,
@@ -1269,7 +1316,7 @@ export class KnowledgeBaseService {
     };
   }
 
-  private generateTempToken(fileId: string, userId: string): string {
+  private generateTempToken(fileId: string, userId: string, tenantId: string): string {
     const jwt = require('jsonwebtoken');
 
     const secret = process.env.JWT_SECRET;
@@ -1278,7 +1325,7 @@ export class KnowledgeBaseService {
     }
 
     return jwt.sign(
-      { fileId, userId, type: 'pdf-access' },
+      { fileId, userId, tenantId, type: 'pdf-access' },
       secret,
       { expiresIn: '1h' }
     );
@@ -1287,7 +1334,7 @@ export class KnowledgeBaseService {
   /**
    * モデルの実際の次元数を取得(キャッシュ確認とプローブロジック付き)
    */
-  private async getActualModelDimensions(embeddingModelId: string, userId: string): Promise<number> {
+  private async getActualModelDimensions(embeddingModelId: string, userId: string, tenantId: string): Promise<number> {
     const defaultDimensions = parseInt(
       process.env.DEFAULT_VECTOR_DIMENSIONS || '2560',
     );
@@ -1297,6 +1344,7 @@ export class KnowledgeBaseService {
       const modelConfig = await this.modelConfigService.findOne(
         embeddingModelId,
         userId,
+        tenantId,
       );
 
       if (modelConfig && modelConfig.dimensions) {
@@ -1319,7 +1367,7 @@ export class KnowledgeBaseService {
         // 次回利用のためにモデル設定を更新
         if (modelConfig) {
           try {
-            await this.modelConfigService.update(modelConfig.id, userId, {
+            await this.modelConfigService.update(userId, tenantId, modelConfig.id, {
               dimensions: actualDimensions,
             });
             this.logger.log(`モデル ${modelConfig.name} の次元数設定を ${actualDimensions} に更新しました`);
@@ -1351,6 +1399,7 @@ export class KnowledgeBaseService {
       if (!kb || !kb.content || kb.content.trim().length === 0) {
         return null;
       }
+      const tenantId = kb.tenantId;
 
       // すでにタイトルがある場合はスキップ
       if (kb.title) {
@@ -1379,7 +1428,7 @@ export class KnowledgeBaseService {
         await this.kbRepository.update(kbId, { title: cleanedTitle });
 
         // Elasticsearch のチャンクも更新
-        await this.elasticsearchService.updateTitleByFileId(kbId, cleanedTitle).catch((err) => {
+        await this.elasticsearchService.updateTitleByFileId(kbId, cleanedTitle, tenantId).catch((err) => {
           this.logger.error(`Failed to update title in Elasticsearch for ${kbId}`, err);
         });
 

+ 20 - 21
server/src/knowledge-group/knowledge-group.controller.ts

@@ -9,60 +9,59 @@ import {
   UseGuards,
   Request,
 } from '@nestjs/common';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
-import { AdminGuard } from '../auth/admin.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user.entity';
 import { KnowledgeGroupService, CreateGroupDto, UpdateGroupDto } from './knowledge-group.service';
 
 @Controller('knowledge-groups')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard, RolesGuard)
 export class KnowledgeGroupController {
-  constructor(private readonly groupService: KnowledgeGroupService) {}
+  constructor(private readonly groupService: KnowledgeGroupService) { }
 
   @Get()
-  @UseGuards(JwtAuthGuard)
   async findAll(@Request() req) {
-    // All users can see all groups now (no user isolation)
-    return await this.groupService.findAll(req.user.id);
+    // All users can see all groups for their tenant
+    return await this.groupService.findAll(req.user.id, req.user.tenantId);
   }
 
   @Get(':id')
-  @UseGuards(JwtAuthGuard)
   async findOne(@Param('id') id: string, @Request() req) {
-    // All users can access any group now
-    return await this.groupService.findOne(id, req.user.id);
+    // Access group within tenant
+    return await this.groupService.findOne(id, req.user.id, req.user.tenantId);
   }
 
   @Post()
-  @UseGuards(AdminGuard)  // Only admin can create groups
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
-    // Only admin can create groups now
-    return await this.groupService.create(req.user.id, createGroupDto);
+    // Only admin can create groups (implicitly scoped to their tenant)
+    return await this.groupService.create(req.user.id, req.user.tenantId, createGroupDto);
   }
 
   @Put(':id')
-  @UseGuards(AdminGuard)  // Only admin can update groups
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async update(
     @Param('id') id: string,
     @Body() updateGroupDto: UpdateGroupDto,
     @Request() req,
   ) {
-    // Only admin can update any group
-    return await this.groupService.update(id, req.user.id, updateGroupDto);
+    // Only admin can update any group within tenant
+    return await this.groupService.update(id, req.user.id, req.user.tenantId, updateGroupDto);
   }
 
   @Delete(':id')
-  @UseGuards(AdminGuard)  // Only admin can delete groups
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async remove(@Param('id') id: string, @Request() req) {
     // Only admin can delete groups
-    await this.groupService.remove(id, req.user.id);
+    await this.groupService.remove(id, req.user.id, req.user.tenantId);
     return { message: '分组删除成功' };
   }
 
   @Get(':id/files')
-  @UseGuards(JwtAuthGuard)
   async getGroupFiles(@Param('id') id: string, @Request() req) {
-    // Any user can see files in any group
-    const files = await this.groupService.getGroupFiles(id, req.user.id);
+    // Any user can see files in any group within tenant
+    const files = await this.groupService.getGroupFiles(id, req.user.id, req.user.tenantId);
     return { files };
   }
 }

+ 8 - 1
server/src/knowledge-group/knowledge-group.entity.ts

@@ -6,8 +6,11 @@ import {
   UpdateDateColumn,
   ManyToMany,
   JoinTable,
+  ManyToOne,
+  JoinColumn,
 } from 'typeorm';
 import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
+import { Tenant } from '../tenant/tenant.entity';
 
 @Entity('knowledge_groups')
 export class KnowledgeGroup {
@@ -25,9 +28,13 @@ export class KnowledgeGroup {
 
   // 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 })
+  @Column({ name: 'tenant_id', nullable: true, type: 'text' })
   tenantId: string;
 
+  @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'tenant_id' })
+  tenant: Tenant;
+
   @CreateDateColumn({ name: 'created_at' })
   createdAt: Date;
 

+ 6 - 3
server/src/knowledge-group/knowledge-group.module.ts

@@ -1,22 +1,25 @@
 import { Module, forwardRef } from '@nestjs/common';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
-import { NoteModule } from '../note/note.module';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { KnowledgeGroup } from './knowledge-group.entity';
 import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
 import { KnowledgeGroupService } from './knowledge-group.service';
 import { KnowledgeGroupController } from './knowledge-group.controller';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
+import { I18nModule } from '../i18n/i18n.module';
+import { UserModule } from '../user/user.module';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 
 @Module({
   imports: [
     TypeOrmModule.forFeature([KnowledgeGroup, KnowledgeBase]),
     forwardRef(() => ElasticsearchModule),
     forwardRef(() => KnowledgeBaseModule),
-    NoteModule,
+    I18nModule,
+    UserModule,
   ],
   controllers: [KnowledgeGroupController],
-  providers: [KnowledgeGroupService],
+  providers: [KnowledgeGroupService, CombinedAuthGuard],
   exports: [KnowledgeGroupService],
 })
 export class KnowledgeGroupModule { }

+ 27 - 28
server/src/knowledge-group/knowledge-group.service.ts

@@ -5,7 +5,6 @@ import { Repository } from 'typeorm';
 import { KnowledgeGroup } from './knowledge-group.entity';
 import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
-import { NoteService } from '../note/note.service';
 
 export interface CreateGroupDto {
   name: string;
@@ -37,15 +36,15 @@ export class KnowledgeGroupService {
     private knowledgeBaseRepository: Repository<KnowledgeBase>,
     @Inject(forwardRef(() => KnowledgeBaseService))
     private knowledgeBaseService: KnowledgeBaseService,
-    private noteService: NoteService,
     private i18nService: I18nService,
   ) { }
 
-  async findAll(userId: string): Promise<GroupWithFileCount[]> {
-    // Return all groups for all users - no user isolation
+  async findAll(userId: string, tenantId: string): Promise<GroupWithFileCount[]> {
+    // Return all groups for the tenant
     const groups = await this.groupRepository
       .createQueryBuilder('group')
       .leftJoin('group.knowledgeBases', 'kb')
+      .where('group.tenantId = :tenantId', { tenantId })
       .addSelect('COUNT(kb.id)', 'fileCount')
       .groupBy('group.id')
       .orderBy('group.createdAt', 'DESC')
@@ -61,10 +60,10 @@ export class KnowledgeGroupService {
     }));
   }
 
-  async findOne(id: string, userId: string): Promise<KnowledgeGroup> {
-    // Remove user restriction - anyone can access any group
+  async findOne(id: string, userId: string, tenantId: string): Promise<KnowledgeGroup> {
+    // Restrict group to tenant
     const group = await this.groupRepository.findOne({
-      where: { id },
+      where: { id, tenantId },
       relations: ['knowledgeBases'],
     });
 
@@ -75,20 +74,19 @@ export class KnowledgeGroupService {
     return group;
   }
 
-  async create(userId: string, createGroupDto: CreateGroupDto): Promise<KnowledgeGroup> {
-    // Allow creation without user restriction
+  async create(userId: string, tenantId: string, createGroupDto: CreateGroupDto): Promise<KnowledgeGroup> {
     const group = this.groupRepository.create({
       ...createGroupDto,
-      // Remove userId to make it globally accessible
+      tenantId,
     });
 
     return await this.groupRepository.save(group);
   }
 
-  async update(id: string, userId: string, updateGroupDto: UpdateGroupDto): Promise<KnowledgeGroup> {
-    // Update any group without user restriction
+  async update(id: string, userId: string, tenantId: string, updateGroupDto: UpdateGroupDto): Promise<KnowledgeGroup> {
+    // Update group within the tenant
     const group = await this.groupRepository.findOne({
-      where: { id },
+      where: { id, tenantId },
     });
 
     if (!group) {
@@ -99,10 +97,10 @@ export class KnowledgeGroupService {
     return await this.groupRepository.save(group);
   }
 
-  async remove(id: string, userId: string): Promise<void> {
-    // Remove any group without user restriction
+  async remove(id: string, userId: string, tenantId: string): Promise<void> {
+    // Remove group within the tenant
     const group = await this.groupRepository.findOne({
-      where: { id },
+      where: { id, tenantId },
     });
 
     if (!group) {
@@ -123,11 +121,11 @@ export class KnowledgeGroupService {
         // We need to get the file's owner to delete it properly
         const fullFile = await this.knowledgeBaseRepository.findOne({
           where: { id: file.id },
-          select: ['id', 'userId']  // Get the owner of the file
+          select: ['id', 'userId', 'tenantId']  // Get the owner of the file
         });
 
         if (fullFile) {
-          await this.knowledgeBaseService.deleteFile(fullFile.id, fullFile.userId);
+          await this.knowledgeBaseService.deleteFile(fullFile.id, fullFile.userId, fullFile.tenantId as string);
         }
       } catch (error) {
         console.error(`Failed to delete file ${file.id} when deleting group ${id}`, error);
@@ -150,10 +148,9 @@ export class KnowledgeGroupService {
     await this.groupRepository.remove(group);
   }
 
-  async getGroupFiles(groupId: string, userId: string): Promise<KnowledgeBase[]> {
-    // Get group without user restriction
+  async getGroupFiles(groupId: string, userId: string, tenantId: string): Promise<KnowledgeBase[]> {
     const group = await this.groupRepository.findOne({
-      where: { id: groupId },
+      where: { id: groupId, tenantId },
       relations: ['knowledgeBases'],
     });
 
@@ -164,9 +161,9 @@ export class KnowledgeGroupService {
     return group.knowledgeBases;
   }
 
-  async addFilesToGroup(fileId: string, groupIds: string[], userId: string): Promise<void> {
+  async addFilesToGroup(fileId: string, groupIds: string[], userId: string, tenantId: string): Promise<void> {
     const file = await this.knowledgeBaseRepository.findOne({
-      where: { id: fileId, userId },
+      where: { id: fileId, userId, tenantId },
       relations: ['groups'],
     });
 
@@ -176,18 +173,19 @@ export class KnowledgeGroupService {
 
     // Load all groups by ID without user restriction
     const groups = await this.groupRepository.findByIds(groupIds);
+    const validGroups = groups.filter(g => g.tenantId === tenantId);
 
-    if (groups.length !== groupIds.length) {
+    if (validGroups.length !== groupIds.length) {
       throw new NotFoundException(this.i18nService.getMessage('someGroupsNotFound'));
     }
 
-    file.groups = groups;
+    file.groups = validGroups;
     await this.knowledgeBaseRepository.save(file);
   }
 
-  async removeFileFromGroup(fileId: string, groupId: string, userId: string): Promise<void> {
+  async removeFileFromGroup(fileId: string, groupId: string, userId: string, tenantId: string): Promise<void> {
     const file = await this.knowledgeBaseRepository.findOne({
-      where: { id: fileId, userId },
+      where: { id: fileId, userId, tenantId },
       relations: ['groups'],
     });
 
@@ -199,7 +197,7 @@ export class KnowledgeGroupService {
     await this.knowledgeBaseRepository.save(file);
   }
 
-  async getFileIdsByGroups(groupIds: string[], userId: string): Promise<string[]> {
+  async getFileIdsByGroups(groupIds: string[], userId: string, tenantId: string): Promise<string[]> {
     if (!groupIds || groupIds.length === 0) {
       return [];
     }
@@ -208,6 +206,7 @@ export class KnowledgeGroupService {
       .createQueryBuilder('kb')
       .innerJoin('kb.groups', 'group')
       .where('group.id IN (:...groupIds)', { groupIds })
+      .andWhere('kb.tenantId = :tenantId', { tenantId })
       .select('DISTINCT kb.id', 'id')
       .getRawMany();
 

+ 5 - 0
server/src/main.ts

@@ -24,5 +24,10 @@ async function bootstrap() {
   SwaggerModule.setup('api/docs', app, document);
 
   await app.listen(process.env.PORT ?? 3001);
+
+  // Ensure "Default" tenant exists
+  const { TenantService } = await import('./tenant/tenant.service');
+  const tenantService = app.get(TenantService);
+  await tenantService.ensureDefaultTenant();
 }
 bootstrap();

+ 66 - 0
server/src/migrations/1772329237979-AddDefaultTenant.ts

@@ -0,0 +1,66 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddDefaultTenant1772329237979 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        // 1. Insert "Default" tenant if it doesn't exist.
+        // We use a predefined UUID or let the DB generate it.
+        // Assuming Postgres/MySQL compatible uuid generation isn't strictly standard here,
+        // we'll insert and get the ID back, or use a workaround for SQLite if it's SQLite.
+        // Actually, since this is a TypeORM setup, we can use standard SQL.
+
+        // This is a bit tricky to write purely in SQL that works across all DBs (SQLite/Postgres/MySQL)
+        // without knowing the exact DB.  AuraK seems to use SQLite locally by default.
+        // First, check if there's any record in the tenant table.
+        const tenants = await queryRunner.query(`SELECT id FROM "tenant" WHERE "name" = 'Default'`);
+        let defaultTenantId;
+
+        if (tenants && tenants.length > 0) {
+            defaultTenantId = tenants[0].id;
+        } else {
+            // Create it with a JS generated UUID to be database agnostic
+            const crypto = require('crypto');
+            defaultTenantId = crypto.randomUUID();
+            const now = new Date().toISOString();
+            await queryRunner.query(
+                `INSERT INTO "tenant" (id, name, description, "createdAt", "updatedAt") VALUES (?, ?, ?, ?, ?)`,
+                [defaultTenantId, 'Default', 'Default tenant created by migration', now, now]
+            );
+
+            // Create tenant settings
+            const settingsId = crypto.randomUUID();
+            await queryRunner.query(
+                `INSERT INTO "tenant_setting" (id, "tenantId", "createdAt", "updatedAt") VALUES (?, ?, ?, ?)`,
+                [settingsId, defaultTenantId, now, now]
+            );
+        }
+
+        // 2. Assign the Default tenant to all relevant existing records that have no tenantId
+        const tablesToUpdate = ['user', 'knowledge_base', 'knowledge_group', 'search_history', 'note', 'model_config'];
+
+        for (const table of tablesToUpdate) {
+            // Check if table exists first (some might be missing if DB is fresh)
+            try {
+                await queryRunner.query(`UPDATE "${table}" SET "tenantId" = ? WHERE "tenantId" IS NULL`, [defaultTenantId]);
+            } catch (e) {
+                console.warn(`Could not update table ${table}, it might not exist or the tenantId column might not exist yet.`);
+            }
+        }
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        // We don't necessarily want to delete the tenant data in a down migration
+        // as it would orphan all the records or require setting them to NULL.
+        // But for completeness, we can set them back to NULL.
+
+        const tablesToUpdate = ['user', 'knowledge_base', 'knowledge_group', 'search_history', 'note', 'model_config'];
+        for (const table of tablesToUpdate) {
+            try {
+                await queryRunner.query(`UPDATE "${table}" SET "tenantId" = NULL`);
+            } catch (e) {
+                // Ignore
+            }
+        }
+    }
+
+}

+ 47 - 0
server/src/migrations/1772334811108-AddTenantModule.ts

@@ -0,0 +1,47 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddTenantModule1772334811108 implements MigrationInterface {
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        // 1. Ensure 'tenants' table exists before we insert into it. 
+        // This assumes the schema definition migrations have run or TypeORM synchronize handled the tables.
+        // We will insert a system default tenant.
+
+        await queryRunner.query(`
+            INSERT INTO "tenants" ("id", "name", "description", "isActive", "created_at", "updated_at")
+            SELECT '00000000-0000-0000-0000-000000000000', 'Default Tenant', 'System Default Organization', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+            WHERE NOT EXISTS (SELECT 1 FROM "tenants" WHERE "id" = '00000000-0000-0000-0000-000000000000');
+        `);
+
+        // 2. Link existing users to the default tenant
+        await queryRunner.query(`UPDATE "users" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
+
+        // 3. Link existing knowledge bases to the default tenant
+        await queryRunner.query(`UPDATE "knowledge_bases" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
+
+        // 4. Link existing knowledge groups to the default tenant
+        await queryRunner.query(`UPDATE "knowledge_groups" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
+
+        // 5. Link existing search histories to the default tenant
+        await queryRunner.query(`UPDATE "search_history" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
+
+        // 6. Link existing notes to the default tenant
+        await queryRunner.query(`UPDATE "notes" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
+
+        // 7. Make the existing admin users SUPER_ADMIN
+        await queryRunner.query(`UPDATE "users" SET "role" = 'SUPER_ADMIN' WHERE "isAdmin" = 1;`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        // Reverse operations if needed. Note: We do not delete the Default tenant here because it might be in active use.
+        // But we could nullify the links if we were strictly rolling back to a state without tenant links.
+        /*
+        await queryRunner.query(`UPDATE "users" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
+        await queryRunner.query(`UPDATE "knowledge_bases" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
+        await queryRunner.query(`UPDATE "knowledge_groups" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
+        await queryRunner.query(`UPDATE "search_history" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
+        await queryRunner.query(`UPDATE "notes" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
+        */
+    }
+
+}

+ 16 - 11
server/src/model-config/model-config.controller.ts

@@ -16,17 +16,19 @@ import {
 import { ModelConfigService } from './model-config.service';
 import { CreateModelConfigDto } from './dto/create-model-config.dto';
 import { UpdateModelConfigDto } from './dto/update-model-config.dto';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
-import { AdminGuard } from '../auth/admin.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user.entity';
 import { ModelConfigResponseDto } from './dto/model-config-response.dto';
 import { plainToClass } from 'class-transformer';
 
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 @Controller('models') // Global prefix /api/models
 export class ModelConfigController {
   constructor(private readonly modelConfigService: ModelConfigService) { }
 
-  @UseGuards(AdminGuard)  // Only admin can create models
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @Post()
   @HttpCode(HttpStatus.CREATED)
   async create(
@@ -35,6 +37,7 @@ export class ModelConfigController {
   ): Promise<ModelConfigResponseDto> {
     const modelConfig = await this.modelConfigService.create(
       req.user.id,
+      req.user.tenantId,
       createModelConfigDto,
     );
     return plainToClass(ModelConfigResponseDto, modelConfig);
@@ -42,7 +45,7 @@ export class ModelConfigController {
 
   @Get()
   async findAll(@Req() req): Promise<ModelConfigResponseDto[]> {
-    const modelConfigs = await this.modelConfigService.findAll(req.user.id);
+    const modelConfigs = await this.modelConfigService.findAll(req.user.id, req.user.tenantId);
     return modelConfigs.map((mc) => plainToClass(ModelConfigResponseDto, mc));
   }
 
@@ -51,11 +54,11 @@ export class ModelConfigController {
     @Req() req,
     @Param('id') id: string,
   ): Promise<ModelConfigResponseDto> {
-    const modelConfig = await this.modelConfigService.findOne(req.user.id, id);
+    const modelConfig = await this.modelConfigService.findOne(req.user.id, id, req.user.tenantId);
     return plainToClass(ModelConfigResponseDto, modelConfig);
   }
 
-  @UseGuards(AdminGuard)  // Only admin can update models
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @Put(':id')
   async update(
     @Req() req,
@@ -64,27 +67,29 @@ export class ModelConfigController {
   ): Promise<ModelConfigResponseDto> {
     const modelConfig = await this.modelConfigService.update(
       req.user.id,
+      req.user.tenantId,
       id,
       updateModelConfigDto,
     );
     return plainToClass(ModelConfigResponseDto, modelConfig);
   }
 
-  @UseGuards(AdminGuard)  // Only admin can delete models
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @Delete(':id')
   @HttpCode(HttpStatus.NO_CONTENT)
   async remove(@Req() req, @Param('id') id: string): Promise<void> {
-    await this.modelConfigService.remove(req.user.id, id);
+    await this.modelConfigService.remove(req.user.id, req.user.tenantId, id);
   }
 
-  @UseGuards(AdminGuard)  // Only admin can set default models
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @Patch(':id/set-default')
   async setDefault(
     @Req() req,
     @Param('id') id: string,
   ): Promise<ModelConfigResponseDto> {
     const userId = req.user.id;
-    const modelConfig = await this.modelConfigService.setDefault(userId, id);
+    const tenantId = req.user.tenantId;
+    const modelConfig = await this.modelConfigService.setDefault(userId, tenantId, id);
     return plainToClass(ModelConfigResponseDto, modelConfig);
   }
 }

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

@@ -79,7 +79,7 @@ export class ModelConfig {
 
   // ==================== 既存のフィールド ====================
 
-  @Column({ type: 'text' }) // For TypeORM, the FK column is often just a primitive type
+  @Column({ type: 'text', nullable: true })
   userId: string;
 
   // null = global/system model (visible to all tenants)

+ 49 - 30
server/src/model-config/model-config.service.ts

@@ -16,25 +16,29 @@ export class ModelConfigService {
 
   async create(
     userId: string,
+    tenantId: string,
     createModelConfigDto: CreateModelConfigDto,
   ): Promise<ModelConfig> {
     const modelConfig = this.modelConfigRepository.create({
       ...createModelConfigDto,
       userId,
+      tenantId,
     });
     return this.modelConfigRepository.save(modelConfig);
   }
 
-  async findAll(userId: string): Promise<ModelConfig[]> {
-    // Regular users get all models
-    // The admin guard in the controller ensures only admins can call this
-    return this.modelConfigRepository.find();
+  async findAll(userId: string, tenantId: string): Promise<ModelConfig[]> {
+    return this.modelConfigRepository.createQueryBuilder('model')
+      .where('model.tenantId = :tenantId OR model.tenantId IS NULL', { tenantId })
+      .getMany();
   }
 
-  async findOne(id: string, userId: string): Promise<ModelConfig> {
-    const modelConfig = await this.modelConfigRepository.findOne({
-      where: { id },
-    });
+  async findOne(id: string, userId: string, tenantId: string): Promise<ModelConfig> {
+    const modelConfig = await this.modelConfigRepository.createQueryBuilder('model')
+      .where('model.id = :id', { id })
+      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL)', { tenantId })
+      .getOne();
+
     if (!modelConfig) {
       throw new NotFoundException(
         `ModelConfig with ID "${id}" not found.`,
@@ -43,20 +47,20 @@ export class ModelConfigService {
     return modelConfig;
   }
 
-  async findByType(userId: string, type: string): Promise<ModelConfig[]> {
-    return this.modelConfigRepository.find({ where: { type } });
+  async findByType(userId: string, tenantId: string, type: string): Promise<ModelConfig[]> {
+    return this.modelConfigRepository.createQueryBuilder('model')
+      .where('model.type = :type', { type })
+      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL)', { tenantId })
+      .getMany();
   }
 
   async update(
     userId: string,
+    tenantId: string,
     id: string,
     updateModelConfigDto: UpdateModelConfigDto,
   ): Promise<ModelConfig> {
-    // For admin users (as determined by admin guard in controller)
-    // Find and update any model
-    const modelConfig = await this.modelConfigRepository.findOne({
-      where: { id },
-    });
+    const modelConfig = await this.findOne(id, userId, tenantId);
 
     if (!modelConfig) {
       throw new NotFoundException(
@@ -64,6 +68,11 @@ export class ModelConfigService {
       );
     }
 
+    // Only allow updating if it belongs to the tenant, or if it's a global admin (not fully implemented, so we check tenantId)
+    if (modelConfig.tenantId && modelConfig.tenantId !== tenantId) {
+      throw new ForbiddenException('Cannot update models from another tenant');
+    }
+
     // Update the model
     const updated = this.modelConfigRepository.merge(
       modelConfig,
@@ -72,43 +81,53 @@ export class ModelConfigService {
     return this.modelConfigRepository.save(updated);
   }
 
-  async remove(userId: string, id: string): Promise<void> {
-    // For admin users (as determined by admin guard in controller)
-    // Find and delete any model
+  async remove(userId: string, tenantId: string, id: string): Promise<void> {
+    // Only allow removing if it exists and accessible in current tenant context
+    const model = await this.findOne(id, userId, tenantId);
+    if (model.tenantId && model.tenantId !== tenantId) {
+      throw new ForbiddenException('Cannot delete models from another tenant');
+    }
     const result = await this.modelConfigRepository.delete({ id });
     if (result.affected === 0) {
-      throw new NotFoundException(
-        `ModelConfig with ID "${id}" not found.`,
-      );
+      throw new NotFoundException(`ModelConfig with ID "${id}" not found.`);
     }
   }
 
   /**
    * 指定されたモデルをデフォルトに設定
-   * 同じタイプの他のモデルのデフォルトフラグをクリア
    */
-  async setDefault(userId: string, id: string): Promise<ModelConfig> {
-    const modelConfig = await this.findOne(id, userId);
+  async setDefault(userId: string, tenantId: string, id: string): Promise<ModelConfig> {
+    const modelConfig = await this.findOne(id, userId, tenantId);
 
-    // 同じタイプの他のモデルのデフォルトフラグをクリア
+    // 同じタイプの他のモデルのデフォルトフラグをクリア (現在のテナント内またはglobal)
+    // 厳密には、現在のテナントのIsDefault設定といった方が正しいですが、シンプルにするため全体のIsDefaultを操作します
     await this.modelConfigRepository
       .createQueryBuilder()
       .update(ModelConfig)
       .set({ isDefault: false })
       .where('type = :type', { type: modelConfig.type })
+      .andWhere('(tenantId = :tenantId OR tenantId IS NULL)', { tenantId })
       .execute();
 
-    // 選択されたモデルをデフォルトに設定
     modelConfig.isDefault = true;
     return this.modelConfigRepository.save(modelConfig);
   }
 
   /**
    * 指定されたタイプのデフォルトモデルを取得
+   * テナント固有のデフォルトを優先、なければグローバル
    */
-  async findDefaultByType(type: string): Promise<ModelConfig | null> {
-    return this.modelConfigRepository.findOne({
-      where: { type, isDefault: true, isEnabled: true }
-    });
+  async findDefaultByType(tenantId: string, type: string): Promise<ModelConfig | null> {
+    const models = await this.modelConfigRepository.createQueryBuilder('model')
+      .where('model.type = :type', { type })
+      .andWhere('model.isDefault = :isDefault', { isDefault: true })
+      .andWhere('model.isEnabled = :isEnabled', { isEnabled: true })
+      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL)', { tenantId })
+      .orderBy('model.tenantId', 'DESC') // Null will be last in most DBs, or we can fetch all and rank in JS
+      .getMany();
+
+    // Prefer tenant specific model over global
+    const tenantModel = models.find(m => m.tenantId === tenantId);
+    return tenantModel || models[0] || null;
   }
 }

+ 34 - 0
server/src/note/note-category.controller.ts

@@ -0,0 +1,34 @@
+import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
+import { NoteCategoryService } from './note-category.service';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+
+@Controller('v1/note-categories')
+@UseGuards(CombinedAuthGuard)
+export class NoteCategoryController {
+    constructor(private readonly categoryService: NoteCategoryService) { }
+
+    @Get()
+    async findAll(@Request() req: any) {
+        return this.categoryService.findAll(req.user.id, req.user.tenantId);
+    }
+
+    @Post()
+    async create(@Request() req: any, @Body('name') name: string, @Body('parentId') parentId?: string) {
+        return this.categoryService.create(req.user.id, req.user.tenantId, name, parentId);
+    }
+
+    @Put(':id')
+    async update(
+        @Request() req: any,
+        @Param('id') id: string,
+        @Body('name') name?: string,
+        @Body('parentId') parentId?: string,
+    ) {
+        return this.categoryService.update(req.user.id, req.user.tenantId, id, name, parentId);
+    }
+
+    @Delete(':id')
+    async remove(@Request() req: any, @Param('id') id: string) {
+        return this.categoryService.remove(req.user.id, req.user.tenantId, id);
+    }
+}

+ 58 - 0
server/src/note/note-category.entity.ts

@@ -0,0 +1,58 @@
+import {
+    Entity,
+    PrimaryGeneratedColumn,
+    Column,
+    CreateDateColumn,
+    UpdateDateColumn,
+    ManyToOne,
+    JoinColumn,
+    OneToMany,
+} from 'typeorm';
+import { User } from '../user/user.entity';
+import { Tenant } from '../tenant/tenant.entity';
+import { Note } from './note.entity';
+
+@Entity('note_categories')
+export class NoteCategory {
+    @PrimaryGeneratedColumn('uuid')
+    id: string;
+
+    @Column()
+    name: string;
+
+    @Column({ name: 'user_id' })
+    userId: string;
+
+    @Column({ name: 'tenant_id', nullable: true, type: 'text' })
+    tenantId: string;
+
+    @Column({ name: 'parent_id', nullable: true, type: 'text' })
+    parentId: string;
+
+    @Column({ default: 1 })
+    level: number;
+
+    @ManyToOne(() => NoteCategory, (category) => category.children, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'parent_id' })
+    parent: NoteCategory;
+
+    @OneToMany(() => NoteCategory, (category) => category.parent)
+    children: NoteCategory[];
+
+    @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'tenant_id' })
+    tenant: Tenant;
+
+    @ManyToOne(() => User)
+    @JoinColumn({ name: 'user_id' })
+    user: User;
+
+    @OneToMany(() => Note, (note) => note.category)
+    notes: Note[];
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+}

+ 84 - 0
server/src/note/note-category.service.ts

@@ -0,0 +1,84 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { NoteCategory } from './note-category.entity';
+
+@Injectable()
+export class NoteCategoryService {
+    constructor(
+        @InjectRepository(NoteCategory)
+        private readonly categoryRepository: Repository<NoteCategory>,
+    ) { }
+
+    async findAll(userId: string, tenantId: string): Promise<NoteCategory[]> {
+        return this.categoryRepository.find({
+            where: { userId, tenantId },
+            order: { level: 'ASC', name: 'ASC' },
+        });
+    }
+
+    async create(userId: string, tenantId: string, name: string, parentId?: string): Promise<NoteCategory> {
+        let level = 1;
+        if (parentId) {
+            const parent = await this.categoryRepository.findOne({
+                where: { id: parentId, userId, tenantId }
+            });
+            if (!parent) {
+                throw new NotFoundException('Parent category not found');
+            }
+            if (parent.level >= 3) {
+                throw new Error('Maximum category depth (3 levels) exceeded');
+            }
+            level = parent.level + 1;
+        }
+
+        const category = this.categoryRepository.create({
+            name,
+            userId,
+            tenantId,
+            parentId,
+            level,
+        });
+        return this.categoryRepository.save(category);
+    }
+
+    async update(userId: string, tenantId: string, id: string, name?: string, parentId?: string): Promise<NoteCategory> {
+        const category = await this.categoryRepository.findOne({
+            where: { id, userId, tenantId },
+        });
+        if (!category) {
+            throw new NotFoundException('Category not found');
+        }
+
+        if (name !== undefined) {
+            category.name = name;
+        }
+
+        if (parentId !== undefined && parentId !== category.parentId) {
+            if (parentId === null) {
+                category.parentId = null as any;
+                category.level = 1;
+            } else {
+                const parent = await this.categoryRepository.findOne({
+                    where: { id: parentId, userId, tenantId }
+                });
+                if (!parent) throw new NotFoundException('Parent category not found');
+                if (parent.level >= 3) throw new Error('Maximum category depth (3 levels) exceeded');
+
+                category.parentId = parentId;
+                category.level = parent.level + 1;
+            }
+            // Note: In a real app we'd also need to update all children's levels recursively
+            // But for this requirement we'll assume shallow updates or handle edge cases
+        }
+
+        return this.categoryRepository.save(category);
+    }
+
+    async remove(userId: string, tenantId: string, id: string): Promise<void> {
+        const result = await this.categoryRepository.delete({ id, userId, tenantId });
+        if (result.affected === 0) {
+            throw new NotFoundException('Category not found');
+        }
+    }
+}

+ 9 - 7
server/src/note/note.controller.ts

@@ -13,31 +13,32 @@ import {
     UploadedFile,
 } from '@nestjs/common';
 import { FileInterceptor } from '@nestjs/platform-express';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { NoteService } from './note.service';
 import { Note } from './note.entity';
 
 @Controller('notes')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class NoteController {
     constructor(private readonly noteService: NoteService) { }
 
     @Post()
     create(@Req() req, @Body() createNoteDto: Partial<Note>) {
-        return this.noteService.create(req.user.id, createNoteDto);
+        return this.noteService.create(req.user.id, req.user.tenantId, createNoteDto);
     }
 
     @Get()
     findAll(
         @Req() req,
         @Query('groupId') groupId?: string,
+        @Query('categoryId') categoryId?: string,
     ) {
-        return this.noteService.findAll(req.user.id, req.user.isAdmin, groupId);
+        return this.noteService.findAll(req.user.id, req.user.tenantId, req.user.isAdmin, groupId, categoryId);
     }
 
     @Get(':id')
     findOne(@Req() req, @Param('id') id: string) {
-        return this.noteService.findOne(req.user.id, id, req.user.isAdmin);
+        return this.noteService.findOne(req.user.id, req.user.tenantId, id, req.user.isAdmin);
     }
 
     @Patch(':id')
@@ -46,12 +47,12 @@ export class NoteController {
         @Param('id') id: string,
         @Body() updateNoteDto: Partial<Note>,
     ) {
-        return this.noteService.update(req.user.id, id, updateNoteDto, req.user.isAdmin);
+        return this.noteService.update(req.user.id, req.user.tenantId, id, updateNoteDto, req.user.isAdmin);
     }
 
     @Delete(':id')
     remove(@Req() req, @Param('id') id: string) {
-        return this.noteService.remove(req.user.id, id, req.user.isAdmin);
+        return this.noteService.remove(req.user.id, req.user.tenantId, id, req.user.isAdmin);
     }
 
     @Post('from-pdf-selection')
@@ -65,6 +66,7 @@ export class NoteController {
     ) {
         return this.noteService.createFromPDFSelection(
             req.user.id,
+            req.user.tenantId,
             fileId,
             screenshot,
             groupId,

+ 21 - 1
server/src/note/note.entity.ts

@@ -9,6 +9,7 @@ import {
 } from 'typeorm';
 import { User } from '../user/user.entity';
 import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
+import { Tenant } from '../tenant/tenant.entity';
 
 @Entity('notes')
 export class Note {
@@ -24,12 +25,24 @@ export class Note {
     @Column({ name: 'user_id' })
     userId: string;
 
-    @Column({ name: 'tenant_id', nullable: true })
+    @Column({ name: 'tenant_id', nullable: true, type: 'text' })
     tenantId: string;
 
+    @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'tenant_id' })
+    tenant: Tenant;
+
     @Column({ name: 'group_id', nullable: true })
     groupId: string; // Corresponds to Notebook/KnowledgeGroup ID
 
+    @Column({
+        type: 'simple-enum',
+        enum: ['PRIVATE', 'TENANT', 'GLOBAL_PENDING', 'GLOBAL_APPROVED'],
+        default: 'PRIVATE',
+        name: 'sharing_status'
+    })
+    sharingStatus: string;
+
     @Column({ name: 'screenshot_path', nullable: true })
     screenshotPath: string; // Path to screenshot image for PDF selections
 
@@ -52,4 +65,11 @@ export class Note {
     @ManyToOne(() => KnowledgeGroup)
     @JoinColumn({ name: 'group_id' })
     group: KnowledgeGroup;
+
+    @Column({ name: 'category_id', nullable: true })
+    categoryId: string;
+
+    @ManyToOne('NoteCategory', 'notes', { nullable: true, onDelete: 'SET NULL' })
+    @JoinColumn({ name: 'category_id' })
+    category: any;
 }

+ 14 - 7
server/src/note/note.module.ts

@@ -1,17 +1,24 @@
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { NoteController } from './note.controller';
-import { NoteService } from './note.service';
 import { Note } from './note.entity';
+import { NoteCategory } from './note-category.entity';
+import { NoteService } from './note.service';
+import { NoteController } from './note.controller';
+import { NoteCategoryService } from './note-category.service';
+import { NoteCategoryController } from './note-category.controller';
+import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
 import { OcrModule } from '../ocr/ocr.module';
+import { I18nModule } from '../i18n/i18n.module';
 
 @Module({
     imports: [
-        TypeOrmModule.forFeature([Note]),
+        TypeOrmModule.forFeature([Note, NoteCategory]),
+        forwardRef(() => KnowledgeGroupModule),
         OcrModule,
+        I18nModule,
     ],
-    controllers: [NoteController],
-    providers: [NoteService],
-    exports: [NoteService],
+    providers: [NoteService, NoteCategoryService],
+    controllers: [NoteController, NoteCategoryController],
+    exports: [NoteService, NoteCategoryService],
 })
 export class NoteModule { }

+ 36 - 30
server/src/note/note.service.ts

@@ -11,65 +11,68 @@ import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class NoteService {
-    private readonly screenshotsDir = path.join(process.cwd(), 'uploads', 'notes-screenshots');
+    // Directory will be created dynamically per tenant
+    private getScreenshotsDir(tenantId: string) {
+        return path.join(process.cwd(), 'uploads', tenantId, 'notes-screenshots');
+    }
 
     constructor(
         @InjectRepository(Note)
         private readonly noteRepository: Repository<Note>,
         private readonly ocrService: OcrService,
         private readonly i18nService: I18nService,
-    ) {
-        // Ensure screenshots directory exists
-        this.ensureScreenshotsDir();
-    }
+    ) { }
 
-    private async ensureScreenshotsDir() {
+    private async ensureScreenshotsDir(tenantId: string) {
+        const dir = this.getScreenshotsDir(tenantId);
         try {
-            await fs.access(this.screenshotsDir);
+            await fs.access(dir);
         } catch {
-            await fs.mkdir(this.screenshotsDir, { recursive: true });
+            await fs.mkdir(dir, { recursive: true });
         }
     }
 
-    async create(userId: string, data: Partial<Note>): Promise<Note> {
+    async create(userId: string, tenantId: string, data: Partial<Note>): Promise<Note> {
         const note = this.noteRepository.create({
             ...data,
             userId,
+            tenantId,
         });
         return this.noteRepository.save(note);
     }
 
-    async findAll(userId: string, isAdmin: boolean, groupId?: string): Promise<Note[]> {
+    async findAll(userId: string, tenantId: string, isAdmin: boolean, groupId?: string, categoryId?: string): Promise<Note[]> {
         const query = this.noteRepository.createQueryBuilder('note')
             .leftJoinAndSelect('note.user', 'user')
+            .where('note.tenantId = :tenantId', { tenantId })
             .select(['note', 'user.id', 'user.username'])
             .orderBy('note.updatedAt', 'DESC');
 
         if (!isAdmin) {
-            query.where('note.userId = :userId', { userId });
+            query.andWhere('note.userId = :userId', { userId });
         }
 
         if (groupId) {
-            if (!isAdmin) {
-                query.andWhere('note.groupId = :groupId', { groupId });
-            } else {
-                query.where('note.groupId = :groupId', { groupId });
-            }
+            query.andWhere('note.groupId = :groupId', { groupId });
+        }
+
+        if (categoryId) {
+            query.andWhere('note.categoryId = :categoryId', { categoryId });
         }
 
         return query.getMany();
     }
 
-    async findOne(userId: string, id: string, isAdmin: boolean): Promise<Note> {
+    async findOne(userId: string, tenantId: string, id: string, isAdmin: boolean): Promise<Note> {
         let note;
         if (isAdmin) {
             note = await this.noteRepository.findOne({
-                where: { id },
+                where: { id, tenantId },
                 relations: ['user']
             });
         } else {
             note = await this.noteRepository.findOne({
-                where: { id, userId },
+                where: { id, userId, tenantId },
                 relations: ['user']
             });
         }
@@ -80,12 +83,12 @@ export class NoteService {
         return note;
     }
 
-    async update(userId: string, id: string, data: Partial<Note>, isAdmin: boolean): Promise<Note> {
-        const note = await this.findOne(userId, id, isAdmin);
+    async update(userId: string, tenantId: string, id: string, data: Partial<Note>, isAdmin: boolean): Promise<Note> {
+        const note = await this.findOne(userId, tenantId, id, isAdmin);
         // Remove protected fields
-        delete data.id;
-        delete data.userId;
-        delete data.createdAt;
+        delete (data as any).id;
+        delete (data as any).userId;
+        delete (data as any).createdAt;
 
         Object.assign(note, data);
         return this.noteRepository.save(note);
@@ -93,6 +96,7 @@ export class NoteService {
 
     async createFromPDFSelection(
         userId: string,
+        tenantId: string,
         fileId: string,
         screenshot: Express.Multer.File,
         groupId?: string,
@@ -104,7 +108,7 @@ export class NoteService {
         if (groupId) {
             const groupRepo = this.noteRepository.manager.getRepository(KnowledgeGroup);
             const group = await groupRepo.findOne({
-                where: { id: groupId }
+                where: { id: groupId, tenantId }
             });
 
             if (!group) {
@@ -116,8 +120,9 @@ export class NoteService {
         }
 
         // Save screenshot to disk
+        await this.ensureScreenshotsDir(tenantId);
         const filename = `${uuidv4()}-${Date.now()}.png`;
-        const screenshotPath = path.join(this.screenshotsDir, filename);
+        const screenshotPath = path.join(this.getScreenshotsDir(tenantId), filename);
         await fs.writeFile(screenshotPath, screenshot.buffer);
 
         // Extract text using OCR
@@ -135,20 +140,21 @@ export class NoteService {
             groupId,
             title: this.i18nService.formatMessage('pdfNoteTitle', { date: new Date().toLocaleString() }),
             content: extractedText || this.i18nService.getMessage('noTextExtracted'),
-            screenshotPath: `notes-screenshots/${filename}`,
+            screenshotPath: `${tenantId}/notes-screenshots/${filename}`,
             sourceFileId: fileId,
             sourcePageNumber: pageNumber,
+            tenantId,
         });
 
         return this.noteRepository.save(note);
     }
 
-    async remove(userId: string, id: string, isAdmin: boolean): Promise<void> {
+    async remove(userId: string, tenantId: string, id: string, isAdmin: boolean): Promise<void> {
         let result;
         if (isAdmin) {
-            result = await this.noteRepository.delete({ id });
+            result = await this.noteRepository.delete({ id, tenantId });
         } else {
-            result = await this.noteRepository.delete({ id, userId });
+            result = await this.noteRepository.delete({ id, userId, tenantId });
         }
 
         if (result.affected === 0) {

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

@@ -6,13 +6,13 @@ import {
     UploadedFile,
 } from '@nestjs/common';
 import { FileInterceptor } from '@nestjs/platform-express';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { OcrService } from './ocr.service';
 import { I18nService } from '../i18n/i18n.service';
 
 @Controller('ocr')
-@UseGuards(JwtAuthGuard)
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class OcrController {
     constructor(
         private readonly ocrService: OcrService,

+ 5 - 5
server/src/podcasts/podcast.controller.ts

@@ -10,7 +10,7 @@ import {
     Query,
     Res,
 } from '@nestjs/common';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { PodcastService } from './podcast.service';
 import { Response } from 'express';
 import * as path from 'path';
@@ -20,25 +20,25 @@ export class PodcastController {
     constructor(private readonly podcastService: PodcastService) { }
 
     @Post()
-    @UseGuards(JwtAuthGuard)
+    @UseGuards(CombinedAuthGuard)
     create(@Req() req, @Body() createDto: any) {
         return this.podcastService.create(req.user.id, createDto);
     }
 
     @Get()
-    @UseGuards(JwtAuthGuard)
+    @UseGuards(CombinedAuthGuard)
     findAll(@Req() req, @Query('groupId') groupId?: string) {
         return this.podcastService.findAll(req.user.id, groupId);
     }
 
     @Get(':id')
-    @UseGuards(JwtAuthGuard)
+    @UseGuards(CombinedAuthGuard)
     findOne(@Req() req, @Param('id') id: string) {
         return this.podcastService.findOne(req.user.id, id);
     }
 
     @Delete(':id')
-    @UseGuards(JwtAuthGuard)
+    @UseGuards(CombinedAuthGuard)
     remove(@Req() req, @Param('id') id: string) {
         return this.podcastService.delete(req.user.id, id);
     }

+ 2 - 1
server/src/podcasts/podcast.service.ts

@@ -133,7 +133,8 @@ export class PodcastService {
         // If groupId or fileIds are provided, try to enhance context with RAG
         if ((groupId || (fileIds && fileIds.length > 0)) && (!context || context.length < 100)) {
             try {
-                const ragContext = await this.chatService.getContextForTopic(topic, userId, groupId, fileIds);
+                // tenantId is optional, we pass undefined here, groupId is string, fileIds is string[]
+                const ragContext = await this.chatService.getContextForTopic(topic, userId, undefined, groupId, fileIds);
                 if (ragContext) {
                     context = `Manual Context: ${context}\n\nSearch Results:\n${ragContext}`;
                 }

+ 11 - 8
server/src/rag/rag.service.ts

@@ -54,6 +54,7 @@ export class RagService {
     selectedGroups?: string[],
     effectiveFileIds?: string[],
     rerankSimilarityThreshold: number = 0.5, // Rerankのしきい値(デフォルト0.5)
+    tenantId?: string, // New
     enableQueryExpansion?: boolean,
     enableHyDE?: boolean,
   ): Promise<RagSearchResult[]> {
@@ -113,13 +114,15 @@ export class RagService {
             effectiveTopK * 4,
             effectiveHybridWeight,
             undefined,
-            effectiveFileIds
+            effectiveFileIds,
+            tenantId,
           );
         } else {
           let vectorSearchResults = await this.elasticsearchService.searchSimilar(
             queryVector,
             userId,
-            effectiveTopK * 4
+            effectiveTopK * 4,
+            tenantId,
           );
           if (effectiveFileIds && effectiveFileIds.length > 0) {
             results = vectorSearchResults.filter(r => effectiveFileIds.includes(r.fileId));
@@ -286,9 +289,9 @@ ${answerHeader}`;
   /**
    * クエリを拡張してバリエーションを生成
    */
-  async expandQuery(query: string, userId: string): Promise<string[]> {
+  async expandQuery(query: string, userId: string, tenantId?: string): Promise<string[]> {
     try {
-      const llm = await this.getInternalLlm(userId);
+      const llm = await this.getInternalLlm(userId, tenantId || 'default');
       if (!llm) return [query];
 
       const userSettings = await this.userSettingService.findOrCreate(userId);
@@ -315,9 +318,9 @@ ${answerHeader}`;
   /**
    * 仮想的なドキュメント(HyDE)を生成
    */
-  async generateHyDE(query: string, userId: string): Promise<string> {
+  async generateHyDE(query: string, userId: string, tenantId?: string): Promise<string> {
     try {
-      const llm = await this.getInternalLlm(userId);
+      const llm = await this.getInternalLlm(userId, tenantId || 'default');
       if (!llm) return query;
 
       const userSettings = await this.userSettingService.findOrCreate(userId);
@@ -338,9 +341,9 @@ ${answerHeader}`;
   /**
    * 内部タスク用の LLM インスタンスを取得
    */
-  private async getInternalLlm(userId: string): Promise<ChatOpenAI | null> {
+  private async getInternalLlm(userId: string, tenantId: string): Promise<ChatOpenAI | null> {
     try {
-      const models = await this.modelConfigService.findAll(userId);
+      const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
       const defaultLlm = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
 
       if (!defaultLlm) {

+ 2 - 1
server/src/rag/rerank.service.ts

@@ -33,6 +33,7 @@ export class RerankService {
         userId: string,
         rerankModelId: string,
         topN: number = 5,
+        tenantId?: string,
     ): Promise<{ index: number; score: number }[]> {
         if (!documents || documents.length === 0) {
             return [];
@@ -41,7 +42,7 @@ export class RerankService {
         let modelConfig;
         try {
             // 1. モデル設定の取得
-            modelConfig = await this.modelConfigService.findOne(rerankModelId, userId);
+            modelConfig = await this.modelConfigService.findOne(rerankModelId, userId, tenantId || 'default');
 
             if (!modelConfig || modelConfig.type !== ModelType.RERANK) {
                 this.logger.warn(`Invalid rerank model config: ${rerankModelId}`);

+ 8 - 7
server/src/search-history/search-history.controller.ts

@@ -9,13 +9,13 @@ import {
   UseGuards,
   Request,
 } from '@nestjs/common';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { SearchHistoryService } from './search-history.service';
 
 @Controller('search-history')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class SearchHistoryController {
-  constructor(private readonly searchHistoryService: SearchHistoryService) {}
+  constructor(private readonly searchHistoryService: SearchHistoryService) { }
 
   @Get()
   async findAll(
@@ -25,13 +25,13 @@ export class SearchHistoryController {
   ) {
     const pageNum = parseInt(page, 10) || 1;
     const limitNum = parseInt(limit, 10) || 20;
-    
-    return await this.searchHistoryService.findAll(req.user.id, pageNum, limitNum);
+
+    return await this.searchHistoryService.findAll(req.user.id, req.user.tenantId, pageNum, limitNum);
   }
 
   @Get(':id')
   async findOne(@Param('id') id: string, @Request() req) {
-    return await this.searchHistoryService.findOne(id, req.user.id);
+    return await this.searchHistoryService.findOne(id, req.user.id, req.user.tenantId);
   }
 
   @Post()
@@ -41,6 +41,7 @@ export class SearchHistoryController {
   ) {
     const history = await this.searchHistoryService.create(
       req.user.id,
+      req.user.tenantId,
       body.title,
       body.selectedGroups,
     );
@@ -49,7 +50,7 @@ export class SearchHistoryController {
 
   @Delete(':id')
   async remove(@Param('id') id: string, @Request() req) {
-    await this.searchHistoryService.remove(id, req.user.id);
+    await this.searchHistoryService.remove(id, req.user.id, req.user.tenantId);
     return { message: '对话历史删除成功' };
   }
 }

+ 18 - 7
server/src/search-history/search-history.service.ts

@@ -46,11 +46,12 @@ export class SearchHistoryService {
 
   async findAll(
     userId: string,
+    tenantId: string,
     page: number = 1,
     limit: number = 20,
   ): Promise<PaginatedSearchHistory> {
     const [histories, total] = await this.searchHistoryRepository.findAndCount({
-      where: { userId },
+      where: { userId, tenantId },
       order: { updatedAt: 'DESC' },
       skip: (page - 1) * limit,
       take: limit,
@@ -86,9 +87,13 @@ export class SearchHistoryService {
     };
   }
 
-  async findOne(id: string, userId: string): Promise<SearchHistoryDetail> {
+  async findOne(id: string, userId: string, tenantId?: string): Promise<SearchHistoryDetail> {
+    const whereClause: any = { id, userId };
+    if (tenantId) {
+      whereClause.tenantId = tenantId;
+    }
     const history = await this.searchHistoryRepository.findOne({
-      where: { id, userId },
+      where: whereClause,
       relations: ['messages'],
       order: { messages: { createdAt: 'ASC' } },
     });
@@ -113,11 +118,13 @@ export class SearchHistoryService {
 
   async create(
     userId: string,
+    tenantId: string,
     title: string,
     selectedGroups?: string[],
   ): Promise<SearchHistory> {
     const history = this.searchHistoryRepository.create({
       userId,
+      tenantId,
       title: title.length > 50 ? title.substring(0, 50) + '...' : title,
       selectedGroups: selectedGroups ? JSON.stringify(selectedGroups) : undefined,
     });
@@ -148,9 +155,9 @@ export class SearchHistoryService {
     return savedMessage;
   }
 
-  async remove(id: string, userId: string): Promise<void> {
+  async remove(id: string, userId: string, tenantId: string): Promise<void> {
     const history = await this.searchHistoryRepository.findOne({
-      where: { id, userId },
+      where: { id, userId, tenantId },
     });
 
     if (!history) {
@@ -160,7 +167,11 @@ export class SearchHistoryService {
     await this.searchHistoryRepository.remove(history);
   }
 
-  async updateTitle(id: string, title: string): Promise<void> {
-    await this.searchHistoryRepository.update(id, { title });
+  async updateTitle(id: string, title: string, tenantId?: string): Promise<void> {
+    const whereClause: any = { id };
+    if (tenantId) {
+      whereClause.tenantId = tenantId;
+    }
+    await this.searchHistoryRepository.update(whereClause, { title });
   }
 }

+ 39 - 0
server/src/super-admin/super-admin.controller.ts

@@ -0,0 +1,39 @@
+import { Controller, Get, Post, Put, Body, UseGuards, Param } from '@nestjs/common';
+import { SuperAdminService } from './super-admin.service';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user.entity';
+
+@Controller('v1/super-admin')
+@UseGuards(CombinedAuthGuard, RolesGuard)
+@Roles(UserRole.SUPER_ADMIN)
+export class SuperAdminController {
+    constructor(private readonly superAdminService: SuperAdminService) { }
+
+    @Get('tenants')
+    async getTenants() {
+        return this.superAdminService.getAllTenants();
+    }
+
+    @Post('tenants')
+    async createTenant(@Body() body: { name: string; domain?: string; adminUserId?: string }) {
+        return this.superAdminService.createTenant(body.name, body.domain, body.adminUserId);
+    }
+
+    @Put('tenants/:tenantId/admin')
+    async bindTenantAdmin(
+        @Param('tenantId') tenantId: string,
+        @Body() body: { userId: string }
+    ) {
+        return this.superAdminService.assignUserToTenant(body.userId, tenantId);
+    }
+
+    @Post('tenants/:tenantId/admin/new')
+    async createTenantAdmin(
+        @Param('tenantId') tenantId: string,
+        @Body() body: { username: string; password?: string }
+    ) {
+        return this.superAdminService.createTenantAdmin(tenantId, body.username, body.password);
+    }
+}

+ 12 - 0
server/src/super-admin/super-admin.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { SuperAdminController } from './super-admin.controller';
+import { SuperAdminService } from './super-admin.service';
+import { TenantModule } from '../tenant/tenant.module';
+import { UserModule } from '../user/user.module';
+
+@Module({
+    imports: [TenantModule, UserModule],
+    controllers: [SuperAdminController],
+    providers: [SuperAdminService],
+})
+export class SuperAdminModule { }

+ 48 - 0
server/src/super-admin/super-admin.service.ts

@@ -0,0 +1,48 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { TenantService } from '../tenant/tenant.service';
+import { UserService } from '../user/user.service';
+import { UserRole } from '../user/user.entity';
+
+@Injectable()
+export class SuperAdminService {
+    constructor(
+        private readonly tenantService: TenantService,
+        private readonly userService: UserService,
+    ) { }
+
+    async getAllTenants() {
+        return this.tenantService.findAll();
+    }
+
+    async createTenant(name: string, domain?: string, adminUserId?: string) {
+        const tenant = await this.tenantService.create(name, domain);
+        if (adminUserId) {
+            await this.assignUserToTenant(adminUserId, tenant.id, UserRole.TENANT_ADMIN);
+        }
+        return tenant;
+    }
+
+    async assignUserToTenant(userId: string, tenantId: string, role: UserRole = UserRole.TENANT_ADMIN) {
+        return this.userService.updateUser(userId, {
+            tenantId,
+            role,
+        });
+    }
+
+    async createTenantAdmin(tenantId: string, username: string, password?: string) {
+        const defaultPassword = password || Math.random().toString(36).slice(-8);
+        const result = await this.userService.createUser(
+            username,
+            defaultPassword,
+            false, // isAdmin
+            tenantId,
+            UserRole.TENANT_ADMIN
+        );
+        return {
+            user: result.user,
+            defaultPassword: defaultPassword
+        };
+    }
+
+    // NOTE: Model Management would be added here depending on ModelService functionality
+}

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

@@ -66,6 +66,9 @@ export class TenantSetting {
     @Column({ type: 'boolean', default: false })
     enableHyDE: boolean;
 
+    @Column({ type: 'boolean', default: true })
+    isNotebookEnabled: boolean;
+
     // LLM generation defaults
     @Column({ type: 'real', default: 0.7 })
     temperature: number;
@@ -73,6 +76,10 @@ export class TenantSetting {
     @Column({ type: 'integer', default: 2048 })
     maxTokens: number;
 
+    // The model IDs that the Tenant Admin has enabled for this tenant
+    @Column({ type: 'simple-array', nullable: true })
+    enabledModelIds: string[];
+
     @CreateDateColumn({ name: 'created_at' })
     createdAt: Date;
 

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

@@ -11,11 +11,11 @@ import {
     UseGuards,
 } from '@nestjs/common';
 import { TenantService } from './tenant.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { SuperAdminGuard } from '../auth/super-admin.guard';
 
 @Controller('tenants')
-@UseGuards(JwtAuthGuard, SuperAdminGuard)
+@UseGuards(CombinedAuthGuard, SuperAdminGuard)
 export class TenantController {
     constructor(private readonly tenantService: TenantService) { }
 

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

@@ -16,11 +16,14 @@ export class Tenant {
     @Column({ type: 'text', unique: true })
     name: string;
 
-    @Column({ type: 'text', nullable: true })
-    description: string;
+    @Column({ type: 'text', unique: true, nullable: true })
+    domain: string;
 
-    @Column({ type: 'boolean', default: true })
-    isActive: boolean;
+    @Column({ type: 'text', default: '{}' })
+    settings: string;
+
+    @Column({ name: 'default_model_id', type: 'text', nullable: true })
+    defaultModelId: string;
 
     @OneToMany(() => User, (user) => user.tenant)
     users: User[];

+ 3 - 3
server/src/tenant/tenant.service.ts

@@ -31,11 +31,11 @@ export class TenantService {
         return this.tenantRepository.findOneBy({ name });
     }
 
-    async create(name: string, description?: string): Promise<Tenant> {
+    async create(name: string, domain?: string): Promise<Tenant> {
         const existing = await this.findByName(name);
         if (existing) throw new BadRequestException(`Tenant name "${name}" already exists`);
 
-        const tenant = this.tenantRepository.create({ name, description });
+        const tenant = this.tenantRepository.create({ name, domain, settings: '{}' });
         const saved = await this.tenantRepository.save(tenant);
 
         // Auto-create default TenantSettings
@@ -82,7 +82,7 @@ export class TenantService {
     async ensureDefaultTenant(): Promise<Tenant> {
         let defaultTenant = await this.findByName('Default');
         if (!defaultTenant) {
-            defaultTenant = await this.create('Default', 'Default tenant for existing data');
+            defaultTenant = await this.create('Default', 'default.localhost');
         }
         return defaultTenant;
     }

+ 6 - 4
server/src/upload/upload.controller.ts

@@ -12,7 +12,7 @@ import {
 import { FileInterceptor } from '@nestjs/platform-express';
 import { UploadService } from './upload.service';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { AdminGuard } from '../auth/admin.guard';
 import { errorMessages } from '../i18n/messages';
 
@@ -36,7 +36,7 @@ export interface UploadConfigDto {
 }
 
 @Controller('upload')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class UploadController {
   private readonly logger = new Logger(UploadController.name);
 
@@ -117,10 +117,11 @@ export class UploadController {
     const kb = await this.knowledgeBaseService.createAndIndex(
       fileInfo,
       req.user.id,
+      req.user.tenantId, // Ensure tenantId is passed down
       {
         ...indexingConfig,
         mode: config.mode || 'fast',
-      },
+      } as any, // Bypass strict type check for now or cast to correct type
     );
 
     return {
@@ -196,10 +197,11 @@ export class UploadController {
     const kb = await this.knowledgeBaseService.createAndIndex(
       fileInfo,
       req.user.id,
+      req.user.tenantId,
       {
         ...indexingConfig,
         mode: body.mode || 'fast',
-      }
+      } as any
     );
 
     return {

+ 10 - 2
server/src/upload/upload.module.ts

@@ -7,10 +7,12 @@ import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module'; /
 import * as multer from 'multer';
 import * as fs from 'fs';
 import * as path from 'path';
+import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
     KnowledgeBaseModule, // Add to
+    UserModule,
     MulterModule.registerAsync({
       imports: [ConfigModule],
       useFactory: (configService: ConfigService) => {
@@ -31,8 +33,14 @@ import * as path from 'path';
 
         return {
           storage: multer.diskStorage({
-            destination: (req, file, cb) => {
-              cb(null, uploadPath);
+            destination: (req: any, file, cb) => {
+              const tenantId = req.user?.tenantId || 'default';
+              const fullPath = path.join(uploadPath, tenantId);
+
+              if (!fs.existsSync(fullPath)) {
+                fs.mkdirSync(fullPath, { recursive: true });
+              }
+              cb(null, fullPath);
             },
             filename: (req, file, cb) => {
               // 中国語ファイル名の文字化け問題を解決

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

@@ -11,13 +11,13 @@ import {
 } from '@nestjs/common';
 import { UserSettingService } from './user-setting.service';
 import { UpdateUserSettingDto } from './dto/update-user-setting.dto';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { UserSettingResponseDto } from './dto/user-setting-response.dto';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { plainToClass } from 'class-transformer';
 import { AdminGuard } from '../auth/admin.guard';
 
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 @Controller('settings') // Global prefix /api/settings
 export class UserSettingController {
   constructor(
@@ -65,7 +65,7 @@ export class UserSettingController {
   @Get('vision-models')
   async getVisionModels(@Req() req: any) {
     const userId = req.user.id;
-    const models = await this.modelConfigService.findByType(userId, 'vision');
+    const models = await this.modelConfigService.findByType(userId, req.user.tenantId, 'vision');
     return models;
   }
 

+ 5 - 1
server/src/user/dto/create-user.dto.ts

@@ -1,4 +1,4 @@
-import { IsNotEmpty, IsString, MinLength } from 'class-validator';
+import { IsNotEmpty, IsString, MinLength, IsOptional } from 'class-validator';
 
 export class CreateUserDto {
   @IsString()
@@ -9,4 +9,8 @@ export class CreateUserDto {
   @IsNotEmpty()
   @MinLength(8, { message: 'Password must be at least 8 characters long' })
   password: string;
+
+  @IsOptional()
+  @IsString()
+  role?: string;
 }

+ 8 - 0
server/src/user/dto/update-user.dto.ts

@@ -5,6 +5,14 @@ export class UpdateUserDto {
   @IsBoolean()
   isAdmin?: boolean;
 
+  @IsOptional()
+  @IsString()
+  tenantId?: string;
+
+  @IsOptional()
+  @IsString()
+  role?: string;
+
   @IsOptional()
   @IsString()
   @MinLength(6)

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

@@ -1,9 +1,13 @@
 // server/src/user/dto/user-safe.dto.ts
 
+import { UserRole } from '../user.entity';
+
 export type SafeUser = {
   id: string;
   username: string;
   isAdmin: boolean;
+  role: UserRole;
+  tenantId: string;
   createdAt: Date;
   updatedAt: Date;
 };

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

@@ -13,12 +13,12 @@ import {
   UseGuards,
 } from '@nestjs/common';
 import { UserService } from './user.service';
-import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { UpdateUserDto } from './dto/update-user.dto';
 import { I18nService } from '../i18n/i18n.service';
 
 @Controller('users')
-@UseGuards(JwtAuthGuard)
+@UseGuards(CombinedAuthGuard)
 export class UserController {
   constructor(
     private readonly userService: UserService,
@@ -38,13 +38,40 @@ export class UserController {
     return { apiKey };
   }
 
+  // --- Profile ---
+  @Get('me')
+  async getMe(@Request() req) {
+    const user = await this.userService.findOneById(req.user.id);
+    if (!user) throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
+
+    let isNotebookEnabled = true;
+    if (user.tenantId) {
+      const settings = await this.userService.getTenantSettings(user.tenantId);
+      isNotebookEnabled = settings?.isNotebookEnabled ?? true;
+    }
+
+    return {
+      id: user.id,
+      username: user.username,
+      role: user.role,
+      tenantId: user.tenantId,
+      isAdmin: user.isAdmin,
+      isNotebookEnabled,
+    };
+  }
+
   @Get()
   async findAll(@Request() req) {
-    const isAdmin = await this.userService.isAdmin(req.user.id);
-    if (!isAdmin) {
+    const callerRole = req.user.role;
+    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyViewList'));
     }
-    return this.userService.findAll();
+
+    if (callerRole === 'SUPER_ADMIN') {
+      return this.userService.findAll();
+    } else {
+      return this.userService.findByTenantId(req.user.tenantId);
+    }
   }
 
   @Put('password')
@@ -72,14 +99,14 @@ export class UserController {
   @Post()
   async createUser(
     @Request() req,
-    @Body() body: { username: string; password: string; isAdmin?: boolean },
+    @Body() body: { username: string; password: string; role?: string },
   ) {
-    const isAdmin = await this.userService.isAdmin(req.user.id);
-    if (!isAdmin) {
+    const callerRole = req.user.role;
+    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyCreateUser'));
     }
 
-    const { username, password, isAdmin: userIsAdmin } = body;
+    const { username, password } = body;
 
     if (!username || !password) {
       throw new BadRequestException(this.i18nService.getErrorMessage('usernamePasswordRequired'));
@@ -89,7 +116,22 @@ export class UserController {
       throw new BadRequestException(this.i18nService.getErrorMessage('passwordMinLength'));
     }
 
-    return this.userService.createUser(username, password, userIsAdmin);
+    // Determine target role based on caller's role and requested role
+    let targetRole = 'USER';
+    let isAdmin = false;
+
+    if (callerRole === 'SUPER_ADMIN') {
+      // Super Admin can create TENANT_ADMIN or USER. Default to requested role, fallback to USER.
+      targetRole = body.role === 'TENANT_ADMIN' ? 'TENANT_ADMIN' : 'USER';
+      isAdmin = targetRole === 'TENANT_ADMIN';
+    } else if (callerRole === 'TENANT_ADMIN') {
+      // Tenant Admin can ONLY create regular users.
+      targetRole = 'USER';
+      isAdmin = false;
+    }
+
+    // Pass the calculated params to the service
+    return this.userService.createUser(username, password, isAdmin, req.user.tenantId, targetRole as any);
   }
 
   @Put(':id')
@@ -98,8 +140,8 @@ export class UserController {
     @Body() body: UpdateUserDto,
     @Param('id') id: string,
   ) {
-    const callerIsAdmin = await this.userService.isAdmin(req.user.id);
-    if (!callerIsAdmin) {
+    const callerRole = req.user.role;
+    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyUpdateUser'));
     }
 
@@ -109,15 +151,29 @@ export class UserController {
       throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
     }
 
-    // ビルトインadminアカウントの変更を試行しているかチェック
+    if (callerRole === 'TENANT_ADMIN' && userToUpdate.tenantId !== req.user.tenantId) {
+      throw new ForbiddenException('Cannot modify users outside your tenant');
+    }
+
+    // Prevent modifying the builtin admin account
     if (userToUpdate.username === 'admin') {
       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'));
+    // Role modification logic
+    if (body.role && userToUpdate.role !== body.role) {
+      if (callerRole !== 'SUPER_ADMIN') {
+        throw new ForbiddenException('Only Super Admins can change user roles.');
+      }
+      if (userToUpdate.role === 'SUPER_ADMIN') {
+        throw new ForbiddenException('Cannot modify the role of another Super Admin.');
+      }
+
+      // Sync isAdmin based on the newly selected role
+      if (body.role === 'TENANT_ADMIN' || body.role === 'SUPER_ADMIN') {
+        body.isAdmin = true;
+      } else {
+        body.isAdmin = false;
       }
     }
 
@@ -129,8 +185,8 @@ export class UserController {
     @Request() req,
     @Param('id') id: string,
   ) {
-    const callerIsAdmin = await this.userService.isAdmin(req.user.id);
-    if (!callerIsAdmin) {
+    const callerRole = req.user.role;
+    if (callerRole !== 'SUPER_ADMIN' && callerRole !== 'TENANT_ADMIN') {
       throw new ForbiddenException(this.i18nService.getErrorMessage('adminOnlyDeleteUser'));
     }
 
@@ -145,6 +201,10 @@ export class UserController {
       throw new NotFoundException(this.i18nService.getErrorMessage('userNotFound'));
     }
 
+    if (callerRole === 'TENANT_ADMIN' && userToDelete.tenantId !== req.user.tenantId) {
+      throw new ForbiddenException('Cannot delete users outside your tenant');
+    }
+
     // ビルトインadminアカウントの削除を阻止
     if (userToDelete.username === 'admin') {
       throw new ForbiddenException(this.i18nService.getErrorMessage('cannotDeleteBuiltinAdmin'));

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

@@ -14,6 +14,7 @@ import * as bcrypt from 'bcrypt';
 import { ModelConfig } from '../model-config/model-config.entity';
 import { UserSetting } from '../user-setting/user-setting.entity';
 import { Tenant } from '../tenant/tenant.entity';
+import { ApiKey } from '../auth/entities/api-key.entity';
 
 export enum UserRole {
   SUPER_ADMIN = 'SUPER_ADMIN',
@@ -52,9 +53,9 @@ export class User {
   @JoinColumn({ name: 'tenant_id' })
   tenant: Tenant;
 
-  // API Key for external API access
-  @Column({ type: 'text', nullable: true, unique: true, name: 'api_key' })
-  apiKey: string;
+  // API Keys for external API access
+  @OneToMany(() => ApiKey, (apiKey) => apiKey.user)
+  apiKeys: ApiKey[];
 
   // クォータ管理フィールド
   @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })

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

@@ -1,13 +1,19 @@
-import { Module } from '@nestjs/common';
+import { Module, Global } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { User } from './user.entity';
+import { ApiKey } from '../auth/entities/api-key.entity';
 import { UserService } from './user.service';
 import { UserController } from './user.controller';
+import { TenantModule } from '../tenant/tenant.module';
 
+@Global()
 @Module({
-  imports: [TypeOrmModule.forFeature([User])],
+  imports: [
+    TypeOrmModule.forFeature([User, ApiKey]),
+    TenantModule,
+  ],
   controllers: [UserController],
   providers: [UserService],
   exports: [UserService],
 })
-export class UserModule {}
+export class UserModule { }

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

@@ -2,10 +2,12 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException,
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { User, UserRole } from './user.entity';
+import { ApiKey } from '../auth/entities/api-key.entity';
 import * as bcrypt from 'bcrypt';
 import { CreateUserDto } from './dto/create-user.dto';
 import * as crypto from 'crypto';
 import { I18nService } from '../i18n/i18n.service';
+import { TenantService } from '../tenant/tenant.service';
 
 @Injectable()
 export class UserService implements OnModuleInit {
@@ -14,7 +16,10 @@ export class UserService implements OnModuleInit {
   constructor(
     @InjectRepository(User)
     private usersRepository: Repository<User>,
+    @InjectRepository(ApiKey)
+    private apiKeyRepository: Repository<ApiKey>,
     private i18nService: I18nService,
+    private tenantService: TenantService,
   ) { }
 
   async findOneByUsername(username: string): Promise<User | null> {
@@ -22,8 +27,8 @@ export class UserService implements OnModuleInit {
   }
 
   async create(createUserDto: CreateUserDto): Promise<User> {
-    const user = this.usersRepository.create(createUserDto);
-    return this.usersRepository.save(user);
+    const user = this.usersRepository.create(createUserDto as any);
+    return this.usersRepository.save(user as any);
   }
 
   async onModuleInit() {
@@ -34,7 +39,7 @@ export class UserService implements OnModuleInit {
 
   async findAll(): Promise<User[]> {
     return this.usersRepository.find({
-      select: ['id', 'username', 'isAdmin', 'createdAt'],
+      select: ['id', 'username', 'isAdmin', 'role', 'createdAt', 'tenantId'],
       order: { createdAt: 'DESC' },
     });
   }
@@ -84,13 +89,15 @@ export class UserService implements OnModuleInit {
     }
 
     const hashedPassword = await bcrypt.hash(password, 10);
+    const targetRoleValue = role ?? (isAdmin ? UserRole.TENANT_ADMIN : UserRole.USER);
+    console.log(`[UserService] Creating user: ${username}, isAdmin: ${isAdmin}, role: ${targetRoleValue}`);
     const user = await this.usersRepository.save({
       username,
       password: hashedPassword,
       isAdmin,
       tenantId: tenantId ?? undefined,
-      role: role ?? (isAdmin ? UserRole.TENANT_ADMIN : UserRole.USER),
-    });
+      role: targetRoleValue,
+    } as any);
 
     return {
       message: this.i18nService.getMessage('userCreated'),
@@ -102,8 +109,12 @@ 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 findByApiKey(apiKeyValue: string): Promise<User | null> {
+    const apiKey = await this.apiKeyRepository.findOne({
+      where: { key: apiKeyValue },
+      relations: ['user']
+    });
+    return apiKey ? apiKey.user : null;
   }
 
   async findByTenantId(tenantId: string): Promise<User[]> {
@@ -111,49 +122,82 @@ export class UserService implements OnModuleInit {
   }
 
   /**
-   * Generates a new API key for the user, or returns the existing one.
+   * Generates a new API key for the user, or returns the existing one (first one).
    */
   async getOrCreateApiKey(userId: string): Promise<string> {
-    const user = await this.usersRepository.findOne({ where: { id: userId } });
+    const user = await this.usersRepository.findOne({
+      where: { id: userId },
+      relations: ['apiKeys']
+    });
+
     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;
+
+    if (user.apiKeys && user.apiKeys.length > 0) {
+      return user.apiKeys[0].key;
+    }
+
+    const keyString = 'kb_' + crypto.randomBytes(32).toString('hex');
+    const newApiKey = this.apiKeyRepository.create({
+      userId: user.id,
+      key: keyString
+    });
+    await this.apiKeyRepository.save(newApiKey);
+
+    return keyString;
   }
 
   /**
    * Regenerates (rotates) the API key for the user.
+   * This clears existing keys and creates a new one.
    */
   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;
+
+    // Delete existing keys
+    await this.apiKeyRepository.delete({ userId: user.id });
+
+    // Create new key
+    const keyString = 'kb_' + crypto.randomBytes(32).toString('hex');
+    const newApiKey = this.apiKeyRepository.create({
+      userId: user.id,
+      key: keyString
+    });
+    await this.apiKeyRepository.save(newApiKey);
+
+    return keyString;
   }
 
   async updateUser(
     userId: string,
-    updateData: { isAdmin?: boolean; password?: string },
+    updateData: { isAdmin?: boolean; role?: string; password?: string; tenantId?: string },
   ): Promise<{ message: string; user: { id: string; username: string; isAdmin: boolean } }> {
     const user = await this.usersRepository.findOne({ where: { id: userId } });
     if (!user) {
       throw new NotFoundException(this.i18nService.getMessage('userNotFound'));
     }
 
+    // パスワードの更新が必要な場合は、まずハッシュ化する
+    if (updateData.password) {
+      const hashedPassword = await bcrypt.hash(updateData.password, 10);
+      updateData.password = hashedPassword;
+    }
+
     // ユーザー名 "admin" のユーザーに対するいかなる変更も阻止
     if (user.username === 'admin') {
       throw new ForbiddenException(this.i18nService.getMessage('cannotModifyBuiltinAdmin'));
     }
 
-    // パスワードの更新が必要な場合は、まずハッシュ化する
-    if (updateData.password) {
-      const hashedPassword = await bcrypt.hash(updateData.password, 10);
-      updateData.password = hashedPassword;
+    // Apply role update logic
+    if (updateData.role) {
+      if (updateData.role === UserRole.TENANT_ADMIN || updateData.role === UserRole.SUPER_ADMIN) {
+        updateData.isAdmin = true;
+      } else {
+        updateData.isAdmin = false;
+      }
     }
 
-    await this.usersRepository.update(userId, updateData);
+    await this.usersRepository.update(userId, updateData as any);
 
     const updatedUser = await this.usersRepository.findOne({
       where: { id: userId },
@@ -188,6 +232,10 @@ export class UserService implements OnModuleInit {
     };
   }
 
+  async getTenantSettings(tenantId: string) {
+    return this.tenantService.getSettings(tenantId);
+  }
+
   private async createAdminIfNotExists() {
     const adminUser = await this.findOneByUsername('admin');
     if (!adminUser) {
@@ -198,6 +246,7 @@ export class UserService implements OnModuleInit {
         username: 'admin',
         password: hashedPassword,
         isAdmin: true,
+        role: UserRole.SUPER_ADMIN,
       });
 
       console.log('\n=== 管理者アカウントが作成されました ===');

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

@@ -97,7 +97,7 @@ export class VisionPipelineCostAwareService {
       }
 
       // ステップ 4: Vision モデル設定の取得
-      const modelConfig = await this.getVisionModelConfig(options.userId, options.modelId);
+      const modelConfig = await this.getVisionModelConfig(options.userId, options.modelId, options.tenantId);
 
       // ステップ 5: VL モデル分析
       this.updateStatus('analyzing', 50, 'ビジョンモデルを使用してページを分析中...');
@@ -184,8 +184,8 @@ export class VisionPipelineCostAwareService {
   /**
    * Vision モデル設定の取得
    */
-  private async getVisionModelConfig(userId: string, modelId: string): Promise<VisionModelConfig> {
-    const config = await this.modelConfigService.findOne(modelId, userId);
+  private async getVisionModelConfig(userId: string, modelId: string, tenantId?: string): Promise<VisionModelConfig> {
+    const config = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
 
     if (!config) {
       throw new Error(`モデル設定が見つかりません: ${modelId}`);

+ 1 - 0
server/src/vision-pipeline/vision-pipeline.interface.ts

@@ -6,6 +6,7 @@ import { VisionAnalysisResult } from '../vision/vision.interface';
 
 export interface PreciseModeOptions {
   userId: string;
+  tenantId: string;
   modelId: string;
   fileId: string;
   fileName: string;

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

@@ -3,7 +3,6 @@ import { VisionPipelineService } from './vision-pipeline.service';
 import { LibreOfficeModule } from '../libreoffice/libreoffice.module';
 import { Pdf2ImageModule } from '../pdf2image/pdf2image.module';
 import { VisionModule } from '../vision/vision.module';
-import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
 import { ModelConfigModule } from '../model-config/model-config.module';
 
 @Module({
@@ -11,10 +10,9 @@ import { ModelConfigModule } from '../model-config/model-config.module';
     LibreOfficeModule,
     Pdf2ImageModule,
     VisionModule,
-    ElasticsearchModule,
     ModelConfigModule,
   ],
   providers: [VisionPipelineService],
   exports: [VisionPipelineService],
 })
-export class VisionPipelineModule {}
+export class VisionPipelineModule { }

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

@@ -5,7 +5,6 @@ import * as path from 'path';
 import { LibreOfficeService } from '../libreoffice/libreoffice.service';
 import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
 import { VisionService } from '../vision/vision.service';
-import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
 import { ModelConfigService } from '../model-config/model-config.service';
 import {
   ModeRecommendation,
@@ -26,7 +25,6 @@ export class VisionPipelineService {
     private libreOffice: LibreOfficeService,
     private pdf2Image: Pdf2ImageService,
     private vision: VisionService,
-    private elasticsearch: ElasticsearchService,
     private modelConfigService: ModelConfigService,
     private configService: ConfigService,
   ) { }
@@ -89,6 +87,7 @@ export class VisionPipelineService {
       const modelConfig = await this.getVisionModelConfig(
         options.userId,
         options.modelId,
+        options.tenantId,
       );
       this.logger.log(`✅ Vision モデル設定完了: ${modelConfig.modelId}`);
 
@@ -195,8 +194,9 @@ export class VisionPipelineService {
   private async getVisionModelConfig(
     userId: string,
     modelId: string,
+    tenantId?: string,
   ): Promise<VisionModelConfig> {
-    const config = await this.modelConfigService.findOne(modelId, userId);
+    const config = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
 
     if (!config) {
       throw new Error(`モデル設定が見つかりません: ${modelId}`);

+ 11 - 215
web/App.tsx

@@ -1,215 +1,11 @@
-import React, { useEffect, useState } from 'react'
-import { setDragDropEnabled } from './components/GlobalDragDropOverlay'
-import { setNotebookDragDropEnabled } from './components/NotebookGlobalDragDropOverlay'
-import LoginPage from './components/LoginPage'
-import { SidebarRail, ViewType } from './components/layouts/SidebarRail'
-import { ChatView } from './components/views/ChatView'
-import { KnowledgeBaseView } from './components/views/KnowledgeBaseView'
-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'
-import { ModelConfig, DEFAULT_MODELS } from './types'
-
-const AppContent: React.FC = () => {
-    const [authToken, setAuthToken] = useState<string | null>(null)
-    const [currentUser, setCurrentUser] = useState<any>(null);  // Add current user state
-    const [currentView, setCurrentView] = useState<ViewType>('chat')
-    const [isVerifying, setIsVerifying] = useState(true)
-
-    // Chat Context State
-    const [chatContext, setChatContext] = useState<{ selectedGroups?: string[], selectedFiles?: string[] } | null>(null)
-
-    // Model State
-    const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>(DEFAULT_MODELS)
-
-    // Disable drag drop when view changes
-    useEffect(() => {
-        // ビュー切り替え時にドラッグアンドドロップ機能を一時的に無効にして、ナビゲーション時の点滅を防ぐ
-        setDragDropEnabled(false);
-        setNotebookDragDropEnabled(false);
-
-        // 次のイベントループで有効にして、ビュー切り替えが完了することを確認
-        const timer = setTimeout(() => {
-            setDragDropEnabled(true);
-            setNotebookDragDropEnabled(true);
-        }, 0);
-
-        return () => {
-            clearTimeout(timer);
-        };
-    }, [currentView]);
-
-    // Load token from localStorage on initial render
-    useEffect(() => {
-        const verifyToken = async () => {
-            const storedToken = localStorage.getItem('authToken')
-            if (storedToken) {
-                try {
-                    const userProfile = await authService.getProfile(storedToken)  // Get full profile
-                    setAuthToken(storedToken)
-                    setCurrentUser(userProfile)  // Store user profile
-                } catch (error) {
-                    console.error('Invalid token, logging out:', error)
-                    localStorage.removeItem('authToken')
-                    setAuthToken(null)
-                    setCurrentUser(null)
-                }
-            }
-            setIsVerifying(false)
-        }
-        verifyToken()
-    }, [])
-
-    const handleLoginSuccess = async (token: string) => {
-        setAuthToken(token)
-        localStorage.setItem('authToken', token)
-        try {
-            const userProfile = await authService.getProfile(token)
-            setCurrentUser(userProfile)
-        } catch (error) {
-            console.error('Failed to fetch user profile:', error)
-        }
-    }
-
-    const handleLogout = () => {
-        setAuthToken(null)
-        setCurrentUser(null)
-        localStorage.removeItem('authToken')
-    }
-
-    // Fetch Models
-    const fetchAndSetModels = React.useCallback(async () => {
-        if (!authToken) return
-        try {
-            const backendModels = await modelConfigService.getAll(authToken)
-            const mergedModelsMap = new Map<string, ModelConfig>()
-            DEFAULT_MODELS.forEach(m => mergedModelsMap.set(m.id, m))
-            backendModels.forEach(bm => {
-                mergedModelsMap.set(bm.id, { ...bm })
-            })
-            const mergedModels = Array.from(mergedModelsMap.values())
-            setModelConfigs(mergedModels)
-        } catch (error) {
-            console.error('Failed to fetch model configs:', error)
-            setModelConfigs(DEFAULT_MODELS)
-        }
-    }, [authToken])
-
-    useEffect(() => {
-        if (authToken) {
-            fetchAndSetModels()
-        }
-    }, [authToken, fetchAndSetModels])
-
-    const handleUpdateModels = React.useCallback(async (action: 'create' | 'update' | 'delete', model: ModelConfig) => {
-        if (!authToken) return
-        try {
-            if (action === 'create') {
-                await modelConfigService.create(authToken, model)
-            } else if (action === 'update') {
-                await modelConfigService.update(authToken, model.id, model)
-            } else if (action === 'delete') {
-                await modelConfigService.remove(authToken, model.id)
-            }
-            await fetchAndSetModels()
-        } catch (error) {
-            console.error(`Failed to perform ${action} on model config:`, error)
-            throw error
-        }
-    }, [authToken, fetchAndSetModels])
-
-
-    const handleChatWithContext = (context: { selectedGroups?: string[], selectedFiles?: string[] }) => {
-        setChatContext(context)
-        setCurrentView('chat')
-    }
-
-    // Login Flow
-    if (isVerifying) {
-        return (
-            <div className="h-screen w-full flex items-center justify-center bg-slate-50">
-                <div className="flex flex-col items-center gap-4">
-                    <div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
-                    <p className="text-slate-600 font-medium">Verifying session...</p>
-                </div>
-            </div>
-        )
-    }
-
-    if (!authToken) {
-        return <LoginPage onLoginSuccess={handleLoginSuccess} />
-    }
-
-    // Main Layout (Rail + View)
-    return (
-        <div className='flex h-screen w-full bg-slate-50 overflow-hidden relative'>
-            <SidebarRail
-                currentView={currentView}
-                onViewChange={setCurrentView}
-                onLogout={handleLogout}
-                currentUser={currentUser}
-            />
-
-            <div className="flex-1 overflow-hidden relative">
-                {currentView === 'chat' && (
-                    <ChatView
-                        authToken={authToken}
-                        onLogout={handleLogout}
-                        modelConfigs={modelConfigs}
-                        onNavigate={(view) => setCurrentView(view)}
-                        initialChatContext={chatContext}
-                        onClearContext={() => setChatContext(null)}
-                        isAdmin={!!currentUser?.isAdmin}
-                    />
-                )}
-
-                {currentView === 'knowledge' && (
-                    <KnowledgeBaseView
-                        authToken={authToken}
-                        onLogout={handleLogout}
-                        modelConfigs={modelConfigs}
-                        onNavigate={(view) => setCurrentView(view)}
-                        isAdmin={!!currentUser?.isAdmin}
-                    />
-                )}
-
-                {currentView === 'notebooks' && (
-                    <NotebooksView
-                        authToken={authToken}
-                        onChatWithContext={handleChatWithContext}
-                        isAdmin={!!currentUser?.isAdmin}
-                    />
-                )}
-
-                {currentView === 'settings' && (
-                    <SettingsView
-                        models={modelConfigs}
-                        onUpdateModels={handleUpdateModels}
-                        authToken={authToken}
-                        isAdmin={!!currentUser?.isAdmin}  // Pass isAdmin status
-                        currentUser={currentUser}  // Pass current user
-                    />
-                )}
-            </div>
-        </div>
-    )
-}
-
-const App: React.FC = () => {
-    return (
-        <LanguageProvider>
-            <ToastProvider>
-                <ConfirmProvider>
-                    <AppContent />
-                </ConfirmProvider>
-            </ToastProvider>
-        </LanguageProvider>
-    )
-}
-
-export default App
+/**
+ * @deprecated This file is the LEGACY entry point for the original single-page App.
+ * It has been SUPERSEDED by `index.tsx` + the new react-router-dom routing structure.
+ *
+ * The new entry point is: `index.html` → `index.tsx` → `AuthProvider` → `BrowserRouter`
+ *
+ * DO NOT import from this file. It is preserved only as a reference/archive.
+ * Safe to delete once the V2 migration is fully validated.
+ */
+
+export { };

+ 90 - 91
web/README.md

@@ -1,119 +1,118 @@
-# Simple Knowledge Base - フロントエンド界面 (Web)
+# AuraK V2 — Frontend (web)
 
-React 19 と Vite で構築されたモダンな RAG ナレッジベースQ&Aインターフェースです。直感的なドキュメント管理、ビジュアル化されたモデル設定、そしてスムーズな AI 対話体験を提供します。
+A modern multi-tenant knowledge base and AI assistant platform built with React 19, Vite, and Tailwind CSS.
 
-## ✨ 機能のハイライト
+## 🏗️ Architecture
 
-- **🤖 没入型対話**: ChatGPT 風のインターフェース。ストリーミング応答、Markdown レンダリング、コードハイライトをサポート。
-- **📚 ドキュメントナレッジベース**:
-  - サイドバーでアップロードされたファイルを管理。
-  - ファイルのインデックス状態 (解析中/インデックス済み/失敗) をリアルタイムで確認可能。
-  - ファイルのチャンク設定 (Chunk Size/Overlap) の再構成が可能。
-- **⚙️ モデルとパラメータのチューニング**:
-  - **モデル管理**: モデルプロバイダー (OpenAI, Gemini, ローカル) の追加/編集/削除を視覚的に操作可能。
-  - **パラメータ設定**: Temperature, Max Tokens, Top K (取得数), 類似度しきい値をリアルタイムで調整可能。
-  - **ハイブリッド検索**: 全文検索機能のオン/オフを切り替え可能。
-- **🔍 検索の透明性**:
-  - 回答ごとに引用元のソースファイルを表示。
-  - 「検索結果を表示」パネルで、具体的な RAG 取得セグメントとその類似度スコアを確認でき、検索精度のデバッグに役立ちます。
-- **🌍 多言語対応**: 日本語、中国語、英語のインターフェース切り替えを内蔵。
+The frontend uses **React Router** for navigation with a clean role-based routing structure:
 
-## 🛠️ 技術スタック
+| Route | Access | Description |
+|-------|--------|-------------|
+| `/login` | Public | Password or API Key sign-in |
+| `/` | All users | Workspace overview |
+| `/chat` | All users | AI Chat with scope selection |
+| `/knowledge` | All users | Document (Knowledge Base) management |
+| `/notebooks` | All users | Notebooks and notes |
+| `/settings` | All users | Model and app settings |
+| `/admin` | TENANT_ADMIN+ | Team management, share approvals |
+| `/admin/tenants` | SUPER_ADMIN | Global tenant management |
+| `/admin/models` | TENANT_ADMIN+ | Model configuration |
 
-- **コア**: React 19, TypeScript
-- **ビルド**: Vite
-- **UI フレームワーク**: TailwindCSS
-- **アイコン**: Lucide React
-- **HTTP 通信**: Fetch API
+## 🔐 Authentication
 
-## 🚀 クイックスタート
+The app supports **two login methods**:
+1. **Password login** — POST to `/api/v1/auth/login`, receives an `apiKey` back
+2. **Direct API Key** — paste the API key directly
 
-### 1. 依存関係のインストール
+All API calls include `x-api-key: <key>` in the request headers automatically via `services/apiClient.ts`.
 
-`web` ディレクトリに移動します:
+## 🛠️ Tech Stack
 
+- **React 19 + TypeScript**
+- **Vite** (dev server + build)
+- **TailwindCSS v3** (styling)
+- **React Router v6** (routing)
+- **framer-motion** (animations)
+- **Lucide React** (icons)
+- **clsx + tailwind-merge** (`cn()` utility)
+
+## 🚀 Quick Start
+
+### 1. Install dependencies
 ```bash
 cd web
 yarn install
 ```
 
-### 2. 環境設定
-
-フロントエンドは主に Vite のプロキシ設定を介してバックエンドに接続します。`vite.config.ts` を開き、プロキシ設定を確認してください:
-
-```typescript
-// vite.config.ts
-server: {
-  port: 13001, // フロントエンドの実行ポート
-  proxy: {
-    '/api': {
-      target: 'http://localhost:13000', // バックエンドサービスの実行アドレス
-      changeOrigin: true,
-    },
-  },
-}
+### 2. Configure environment
+Copy `.env.example` to `.env` and set the backend URL if needed:
+```
+VITE_API_BASE_URL=http://localhost:13000
 ```
 
-バックエンドが他のアドレスで実行されている場合は、`target` フィールドを変更してください。
-
-### 3. 開発サーバーの起動
+The Vite dev server proxies `/api/*` to `http://localhost:13000` by default (see `vite.config.ts`).
 
+### 3. Start the dev server
 ```bash
 yarn dev
+# → http://localhost:13001
 ```
 
-ブラウザで **<http://localhost:13001>** にアクセスしてください。
-
-## 📖 利用ガイド
-
-### 1. システムへのログイン
-
-- 初回利用時は、デフォルトの管理者アカウント(バックエンドが初期化済みの場合)を使用するか、**「新規登録」**をクリックして新しいアカウントを作成してください。
-- **デフォルトテストアカウント** (バックエンドの初期化状況によります):
-  - ユーザー名: `admin`
-  - パスワード: `123456`
-
-### 2. モデルの設定
-
-システムにログイン後、サイドバー下部の **「システム設定」** -> **「モデルプロバイダーの管理」** をクリックします。
-少なくとも1つの LLM モデル(対話用)と1つの Embedding モデル(インデックス用)を追加する必要があります。
-
-**設定例 (OpenAI):**
-
-- **名称**: GPT-4o
-- **プロバイダー**: OpenAI Compatible
-- **Model ID**: `gpt-4o`
-- **Base URL**: `https://api.openai.com/v1`
-- **API Key**: `sk-......`
-
-### 3. ナレッジベースの構築
-
-1. サイドバーの **「ファイルを追加」** をクリックします。
-2. ローカルの PDF, TXT, または Markdown ファイルを選択します。
-3. 表示される **「インデックス設定」** ウィンドウで、Embedding モデルを選択し、チャンク分割パラメータを調整します(デフォルトのままでも構いません)。
-4. **「インデックス開始」** をクリックし、ファイルの状態が緑色のチェックマーク(インデックス済み)になるまで待ちます。
-
-### 4. 質問を開始
-
-右側のチャットボックスに質問を入力します。システムは以下の処理を行います:
+### 4. Build for production
+```bash
+yarn build
+# Output: dist/
+```
 
-1. 質問をベクトルに変換します。
-2. ナレッジベースから関連するセグメントを検索します。
-3. セグメントをコンテキストとして LLM に送信し、回答を生成します。
-4. 回答の下に引用元を表示します。
+## 📁 Project Structure
 
-## 📦 ビルドとデプロイ
+```
+web/
+├── index.tsx              # App root — BrowserRouter + all routes
+├── index.html
+├── src/
+│   ├── contexts/
+│   │   └── AuthContext.tsx        # User auth state (apiKey, user, role)
+│   ├── components/
+│   │   └── layouts/
+│   │       ├── WorkspaceLayout.tsx  # Sidebar + header for main workspace
+│   │       └── AdminLayout.tsx      # Admin console layout
+│   ├── pages/
+│   │   ├── auth/
+│   │   │   └── Login.tsx            # Dual-mode login (password + API key)
+│   │   ├── workspace/
+│   │   │   ├── ChatPage.tsx
+│   │   │   ├── KnowledgePage.tsx
+│   │   │   ├── NotebooksPage.tsx
+│   │   │   └── SettingsPage.tsx
+│   │   └── admin/
+│   │       ├── SuperAdminPage.tsx   # Tenant management (SUPER_ADMIN)
+│   │       └── TenantAdminPage.tsx  # Team management (TENANT_ADMIN)
+│   └── utils/
+│       └── cn.ts                    # clsx + twMerge helper
+├── components/            # Legacy rich view components (still used)
+│   └── views/
+│       ├── ChatView.tsx
+│       ├── KnowledgeBaseView.tsx
+│       ├── NotebooksView.tsx
+│       └── SettingsView.tsx
+└── services/              # API service layer
+    ├── apiClient.ts       # Base HTTP client (auto-injects x-api-key)
+    ├── chatService.ts     # Streaming chat
+    ├── knowledgeBaseService.ts
+    └── ...
+```
 
-本番環境用の静的ファイルを生成します:
+## 🔑 Default Credentials (Development)
 
-```bash
-yarn build
-```
+After running the backend migration, a default super admin is seeded:
+- **Email**: `admin@system.local`
+- **Password**: `admin123`
 
-構築されたファイルは `dist` ディレクトリに生成されます。Nginx やその他の静的ファイルサーバーを使用してデプロイできます。
+The `/api/v1/auth/api-key` endpoint returns your API key after login.
 
-## ⚠️ FAQ
+## ⚠️ Troubleshooting
 
-- **アップロードに失敗する?**: バックエンドサービスが起動しているか、また `uploads` ディレクトリに書き込み権限があるか確認してください。
-- **「テキスト返信を生成できません」と表示される?**: 「システム設定」で正しい LLM モデルが選択されているか、また API キーが有効か確認してください。
-- **コンテンツが検索されない?**: 設定で「類似度しきい値」を下げるか、Embedding モデルの設定が正しいか確認してください。
+- **401 errors**: Your session expired. You will be redirected to `/login` automatically.
+- **Upload fails**: Ensure the backend is running and the `uploads/` directory is writable.
+- **No AI response**: Configure at least one LLM model in `/settings` or `/admin/models`.

+ 32 - 27
web/components/ChatInterface.tsx

@@ -293,8 +293,8 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
   };
 
   return (
-    <div className="flex flex-col h-full bg-slate-50 relative overflow-hidden">
-      <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 scrollbar-hide">
+    <div className="flex flex-col h-full bg-transparent relative overflow-hidden">
+      <div className="flex-1 overflow-y-auto px-4 md:px-8 pt-6 pb-32 space-y-8 scrollbar-hide">
         {messages.map((msg) => (
           <ChatMessage
             key={msg.id}
@@ -305,13 +305,20 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
         ))}
 
         {isLoading && (
-          <div className="flex justify-start animate-in fade-in duration-300">
-            <div className="flex flex-row gap-3">
-              <div className="w-8 h-8 rounded-full bg-white border border-slate-200 flex items-center justify-center shadow-sm">
-                <Loader2 className="w-4 h-4 text-purple-600 animate-spin" />
+          <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
+            <div className="flex flex-row gap-4 items-start translate-x-1">
+              <div className="w-9 h-9 rounded-xl bg-white/80 backdrop-blur-sm border border-slate-200/50 flex items-center justify-center shadow-sm">
+                <Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
               </div>
-              <div className="bg-white border border-slate-100 px-4 py-3 rounded-2xl rounded-tl-none shadow-sm flex items-center">
-                <span className="text-sm text-slate-500">{t('analyzing')}</span>
+              <div className="bg-white/80 backdrop-blur-md border border-white/40 px-5 py-3.5 rounded-2xl rounded-tl-none shadow-sm flex items-center">
+                <div className="flex items-center gap-2">
+                  <div className="flex gap-1">
+                    <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
+                    <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
+                    <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
+                  </div>
+                  <span className="text-sm font-medium text-slate-500 ml-2 tracking-wide uppercase text-[10px]">{t('analyzing')}</span>
+                </div>
               </div>
             </div>
           </div>
@@ -323,21 +330,21 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
 
 
 
-      <div className="p-4 bg-gradient-to-t from-slate-50 via-slate-50 to-transparent shrink-0">
-        <div className="max-w-3xl mx-auto">
+      <div className="absolute bottom-6 left-0 right-0 px-4 md:px-8 pointer-events-none">
+        <div className="max-w-4xl mx-auto pointer-events-auto">
           {((selectedFiles && selectedFiles.length > 0) || true) && (
-            <div className="mb-2 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2">
+            <div className="mb-3 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2 duration-300">
               {/* Group Selection Button */}
               <button
                 type="button"
                 onClick={onOpenGroupSelection}
-                className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium transition-colors border ${selectedGroups.length > 0
-                  ? 'bg-slate-100 text-slate-700 border-slate-300 hover:bg-slate-200'
-                  : 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'
+                className={`flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold transition-all border shadow-sm ${selectedGroups.length > 0
+                  ? 'bg-blue-600 text-white border-blue-500 hover:bg-blue-700'
+                  : 'bg-white/90 backdrop-blur-md text-slate-600 border-slate-200/60 hover:bg-white'
                   }`}
                 title={t('selectKnowledgeGroup')}
               >
-                <Database size={12} />
+                <Database size={13} className={selectedGroups.length > 0 ? "text-blue-100" : "text-blue-500"} />
                 <span className="truncate max-w-[150px]">
                   {selectedGroups.length === 0
                     ? t('allKnowledgeGroups')
@@ -348,11 +355,11 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
               </button>
 
               {files.filter(f => selectedFiles?.includes(f.id)).map(file => (
-                <div key={file.id} className="flex items-center gap-1 bg-blue-100 text-blue-700 px-2 py-1 rounded-full text-xs font-medium border border-blue-200">
+                <div key={file.id} className="flex items-center gap-1.5 bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-full text-xs font-semibold border border-indigo-100 shadow-sm animate-in zoom-in-95">
                   <span className="truncate max-w-[150px]">{file.title || file.name}</span>
                   <button
                     onClick={onClearFileSelection}
-                    className="hover:bg-blue-200 rounded-full p-0.5 transition-colors"
+                    className="hover:bg-indigo-200/50 rounded-full p-0.5 transition-colors"
                   >
                     <X size={12} />
                   </button>
@@ -361,12 +368,10 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
             </div>
           )}
 
-          <div className="bg-white rounded-xl shadow-lg border border-slate-200 flex items-end p-2 transition-shadow focus-within:shadow-xl focus-within:border-blue-300">
-
-
+          <div className="bg-white rounded-2xl border border-slate-200 shadow-sm flex items-end p-2.5 transition-all duration-300 focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-400 group/input">
             <button
               onClick={onMobileUploadClick}
-              className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
+              className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-colors"
             >
               <Paperclip className="w-5 h-5" />
             </button>
@@ -377,16 +382,16 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
               onChange={handleInputResize}
               onKeyDown={handleKeyDown}
               placeholder={files.length > 0 ? t('placeholderWithFiles') : t('placeholderEmpty')}
-              className="flex-1 max-h-40 min-h-[50px] bg-transparent border-none focus:ring-0 text-slate-700 placeholder:text-slate-400 resize-none py-3 px-3"
+              className="flex-1 max-h-[250px] min-h-[48px] bg-transparent border-none focus:ring-0 text-slate-800 placeholder:text-slate-400/80 resize-none py-3 px-4 text-[15px] leading-relaxed"
               rows={1}
-              disabled={files.length === 0 && messages.length < 2 && false} // Disable logic might need review, relaxed for now
+              disabled={files.length === 0 && messages.length < 2 && false}
             />
             <button
               onClick={handleSend}
               disabled={!input.trim() || isLoading}
-              className={`p - 3 rounded - lg mb - 0.5 ml - 2 transition - all ${input.trim() && !isLoading
-                ? 'bg-blue-600 text-white hover:bg-blue-700 shadow-md transform hover:scale-105'
-                : 'bg-slate-100 text-slate-400 cursor-not-allowed'
+              className={`p-3 rounded-xl mb-0.5 ml-2 transition-all duration-300 ${input.trim() && !isLoading
+                ? 'bg-gradient-to-br from-blue-600 to-indigo-600 text-white hover:shadow-lg hover:shadow-blue-500/30 transform hover:-translate-y-0.5 active:translate-y-0 active:scale-95'
+                : 'bg-slate-100 text-slate-300 cursor-not-allowed'
                 } `}
               type="button"
             >
@@ -397,7 +402,7 @@ const ChatInterface: React.FC<ChatInterfaceProps> = ({
               )}
             </button>
           </div>
-          <p className="text-center text-[10px] text-slate-400 mt-2">
+          <p className="text-center text-[10px] text-slate-400/80 mt-3 font-medium tracking-tight uppercase">
             {t('aiDisclaimer')}
           </p>
         </div>

+ 112 - 89
web/components/ChatMessage.tsx

@@ -3,7 +3,8 @@ import { copyToClipboard } from '../utils/clipboard';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 import { Message, Role } from '../types';
-import { Bot, User, AlertCircle, Copy, Check, Search, ChevronDown, ChevronRight } from 'lucide-react';
+import { Bot, User, AlertCircle, Copy, Check, Search, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
 import { useLanguage } from '../contexts/LanguageContext';
 import { ChatSource } from '../types';
 
@@ -29,7 +30,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onO
 
   const renderContent = (content: string) => {
     return (
-      <div className="markdown-body text-sm leading-relaxed">
+      <div className="markdown-body text-[15px] leading-[1.6] tracking-tight">
         <ReactMarkdown
           remarkPlugins={[remarkGfm]}
           components={{
@@ -37,28 +38,28 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onO
               const match = /language-(\w+)/.exec(className || '');
               const language = match ? match[1] : '';
 
-              // Check if it's a Mermaid diagram
               if (language === 'mermaid') {
                 return (
-                  <div className="my-4 p-4 bg-slate-50 border border-slate-200 rounded-lg overflow-x-auto">
+                  <div className="my-6 p-5 bg-slate-900/5 border border-slate-200 rounded-2xl overflow-x-auto shadow-inner">
                     <pre className="text-xs text-slate-600 font-mono whitespace-pre-wrap">
                       {String(children).replace(/\n$/, '')}
                     </pre>
-                    <div className="text-xs text-slate-400 mt-2">
-                      💡 Mermaid diagram (rendering requires mermaid.js)
+                    <div className="flex items-center gap-2 text-[10px] text-slate-400 mt-4 font-bold uppercase tracking-wider">
+                      <div className="w-4 h-4 bg-slate-200 rounded flex items-center justify-center text-[8px]">M</div>
+                      Mermaid diagram rendering
                     </div>
                   </div>
                 );
               }
 
-              // Code block with language
               if (!inline && match) {
                 return (
-                  <div className="my-3">
-                    <div className="flex items-center justify-between bg-slate-700 text-slate-200 px-3 py-1 rounded-t text-xs">
+                  <div className="my-5 group/code">
+                    <div className="flex items-center justify-between bg-slate-800 text-slate-300 px-4 py-2 rounded-t-xl text-[10px] font-bold uppercase tracking-widest border-b border-white/5">
                       <span className="font-mono">{language}</span>
+                      <span className="opacity-0 group-hover/code:opacity-100 transition-opacity text-[10px]">Code Block</span>
                     </div>
-                    <pre className="bg-slate-800 text-slate-100 p-3 rounded-b overflow-x-auto">
+                    <pre className="bg-[#1E293B] text-slate-100 p-4 rounded-b-xl overflow-x-auto border border-t-0 border-slate-800 shadow-lg">
                       <code className={className} {...props}>
                         {children}
                       </code>
@@ -67,42 +68,51 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onO
                 );
               }
 
-              // Inline code
               return (
-                <code className="bg-slate-100 text-slate-800 rounded px-1.5 py-0.5 text-xs font-mono" {...props}>
+                <code className="bg-blue-50 text-blue-700 rounded-md px-1.5 py-0.5 text-xs font-mono font-bold" {...props}>
                   {children}
                 </code>
               );
             },
-            // Style headings
+            h1: ({ children }) => (
+              <h1 className="text-2xl font-bold mt-8 mb-4 text-slate-900 border-b pb-2 border-slate-100">{children}</h1>
+            ),
             h2: ({ children }) => (
-              <h2 className="text-lg font-semibold mt-4 mb-2 text-slate-800">{children}</h2>
+              <h2 className="text-xl font-bold mt-6 mb-3 text-slate-800 flex items-start gap-2">
+                <div className="w-1 h-5 bg-blue-500 rounded-full mt-1 shrink-0" />
+                <span className="break-words min-w-0">{children}</span>
+              </h2>
             ),
             h3: ({ children }) => (
-              <h3 className="text-base font-semibold mt-3 mb-2 text-slate-700">{children}</h3>
+              <h3 className="text-lg font-bold mt-5 mb-2 text-slate-800 tracking-tight">{children}</h3>
             ),
-            // Style lists
             ul: ({ children }) => (
-              <ul className="list-disc list-inside space-y-1 my-2">{children}</ul>
+              <ul className="list-disc list-outside ml-5 space-y-2 my-4 text-slate-700">{children}</ul>
             ),
             ol: ({ children }) => (
-              <ol className="list-decimal list-inside space-y-1 my-2">{children}</ol>
+              <ol className="list-decimal list-outside ml-5 space-y-2 my-4 text-slate-700">{children}</ol>
+            ),
+            li: ({ children }) => (
+              <li className="pl-1">{children}</li>
             ),
-            // Style paragraphs
             p: ({ children }) => (
-              <p className="my-2 leading-relaxed">{children}</p>
+              <p className="my-3 leading-[1.7] last:mb-0">{children}</p>
             ),
-            // Style tables
             table: ({ children }) => (
-              <div className="overflow-x-auto my-3">
-                <table className="min-w-full border border-slate-200 rounded">{children}</table>
+              <div className="overflow-x-auto my-6 border border-slate-200 rounded-xl shadow-sm">
+                <table className="min-w-full divide-y divide-slate-200">{children}</table>
               </div>
             ),
             th: ({ children }) => (
-              <th className="border border-slate-200 bg-slate-50 px-3 py-2 text-left text-sm font-semibold">{children}</th>
+              <th className="border-b border-slate-200 bg-slate-50/80 px-4 py-3 text-left text-xs font-bold uppercase tracking-wider text-slate-500">{children}</th>
             ),
             td: ({ children }) => (
-              <td className="border border-slate-200 px-3 py-2 text-sm">{children}</td>
+              <td className="px-4 py-3 text-sm text-slate-600 border-b border-slate-50">{children}</td>
+            ),
+            blockquote: ({ children }) => (
+              <blockquote className="border-l-4 border-blue-200 bg-blue-50/30 px-5 py-3 my-5 rounded-r-xl italic text-slate-600">
+                {children}
+              </blockquote>
             ),
           }}
         >
@@ -113,114 +123,127 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, onPreviewSource, onO
   };
 
   return (
-    <div className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-2 duration-300`}>
-      <div className={`flex max-w-[85%] md:max-w-[75%] gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
+    <motion.div
+      initial={{ opacity: 0, y: 10 }}
+      animate={{ opacity: 1, y: 0 }}
+      transition={{ duration: 0.4, ease: "easeOut" }}
+      className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'} mb-2`}
+    >
+      <div className={`flex max-w-[90%] md:max-w-[85%] gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} items-start`}>
 
         {/* Avatar */}
-        <div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-sm ${isUser ? 'bg-blue-600 text-white' : 'bg-white border border-slate-200 text-purple-600'
+        <div className={`shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all ${isUser ? 'bg-gradient-to-br from-blue-600 to-indigo-600 text-white shadow-blue-200 shadow-md' : 'bg-white border border-slate-200 text-indigo-600 shadow-sm'
           }`}>
-          {isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
+          {isUser ? <User className="w-5 h-5" /> : <Sparkles className="w-5 h-5" />}
         </div>
 
         {/* Message Bubble + Sources */}
         <div className={`flex flex-col ${isUser ? 'items-end' : 'items-start'} group max-w-full`}>
-          <div className={`relative px-5 py-3.5 rounded-2xl shadow-sm ${isUser
-            ? 'bg-blue-600 text-white rounded-tr-none'
+          <div className={`relative px-6 py-4 rounded-[24px] shadow-sm transition-all duration-300 ${isUser
+            ? 'bg-gradient-to-br from-blue-600 to-indigo-700 text-white rounded-tr-none shadow-blue-200/50 hover:shadow-lg hover:shadow-blue-200/50'
             : message.isError
               ? 'bg-red-50 border border-red-200 text-red-700 rounded-tl-none'
-              : 'bg-white border border-slate-200 text-slate-800 rounded-tl-none'
+              : 'bg-white/80 backdrop-blur-md border border-slate-200/60 text-slate-800 rounded-tl-none hover:shadow-md hover:border-slate-300/50'
             }`}>
             {message.isError && (
-              <div className="flex items-center gap-2 mb-2 text-red-600 font-semibold">
+              <div className="flex items-center gap-2 mb-3 text-red-600 font-bold uppercase text-[10px] tracking-widest">
                 <AlertCircle className="w-4 h-4" />
                 <span>{t('errorLabel')}</span>
               </div>
             )}
-            <div className={`${isUser ? 'text-white' : 'text-slate-800'}`}>
+            <div className={`${isUser ? 'text-blue-50' : 'text-slate-800'}`}>
               {renderContent(message.text)}
             </div>
 
             {/* Copy Button (Always visible, icon only) */}
-            <div className={`flex justify-end mt-2 pt-2 border-t ${isUser ? 'border-white/20' : 'border-slate-100/50'}`}>
+            <div className={`flex justify-end mt-3 pt-3 border-t ${isUser ? 'border-white/10' : 'border-slate-100/50'}`}>
               <button
                 onClick={handleCopy}
-                className={`flex items-center justify-center p-1.5 rounded transition-colors ${isUser
-                  ? 'text-blue-100 hover:bg-blue-700'
-                  : 'text-slate-400 hover:bg-slate-100 text-slate-500'
+                className={`flex items-center justify-center p-2 rounded-lg transition-all ${isUser
+                  ? 'text-blue-200 hover:bg-white/10 hover:text-white'
+                  : 'text-slate-400 hover:bg-slate-50 hover:text-indigo-600'
                   }`}
                 title={copied ? t('copied') : t('copy')}
               >
-                {copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
+                {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
               </button>
             </div>
           </div>
 
           {/* Timestamp */}
-          <span className="text-[10px] text-slate-400 mt-1 px-1">
+          <span className="text-[10px] font-bold text-slate-400/80 mt-1.5 px-2 uppercase tracking-tight">
             {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
           </span>
 
-          {/* Sources (Collapsible) */}
           {!isUser && message.sources && message.sources.length > 0 && (
-            <div className="mt-3 w-full border-t border-slate-200 pt-3">
+            <div className="mt-4 w-full pt-2">
               <button
                 onClick={() => setSourcesExpanded(!sourcesExpanded)}
-                className="flex items-center gap-2 text-sm text-slate-600 hover:text-slate-800 transition-colors mb-2"
+                className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-slate-500 hover:text-indigo-600 transition-all mb-3 group/btn"
               >
-                {sourcesExpanded ? (
-                  <ChevronDown className="w-4 h-4" />
-                ) : (
-                  <ChevronRight className="w-4 h-4" />
-                )}
-                <Search className="w-4 h-4" />
-                <span className="font-medium">{t('citationSources')} ({message.sources.length})</span>
+                <div className={`flex items-center justify-center w-5 h-5 rounded-full border border-slate-200 transition-transform ${sourcesExpanded ? 'rotate-90' : 'rotate-0'}`}>
+                  <ChevronRight className="w-3 h-3" />
+                </div>
+                <div className="flex items-center gap-1.5">
+                  <Search className="w-3 h-3" />
+                  <span>{t('citationSources')} ({message.sources.length})</span>
+                </div>
               </button>
 
-              {sourcesExpanded && (
-                <div className="grid gap-3 pl-6 animate-in slide-in-from-top-2 duration-200">
-                  {message.sources.map((source, index) => (
-                    <div
-                      key={`${source.fileName}-${source.chunkIndex}-${index}`}
-                      className="bg-slate-50 border border-slate-200 rounded-lg p-3 hover:shadow-sm transition-all cursor-pointer hover:border-blue-300 group/source"
-                      onClick={() => onPreviewSource?.(source)}
-                    >
-                      <div className="flex justify-between items-start mb-2">
-                        {source.fileId ? (
-                          <button
-                            onClick={(e) => {
-                              e.stopPropagation();
-                              onOpenFile?.(source);
-                            }}
-                            className="font-medium text-slate-800 text-sm truncate pr-2 hover:text-blue-600 hover:underline text-left pointer-events-auto relative z-10"
-                            title={source.fileName}
-                          >
-                            {source.fileName}
-                          </button>
-                        ) : (
-                          <div className="font-medium text-slate-800 text-sm truncate pr-2" title={source.fileName}>{source.fileName}</div>
-                        )}
-                        <div className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full shrink-0">
-                          {(source.score * 100).toFixed(1)}%
+              <AnimatePresence>
+                {sourcesExpanded && (
+                  <motion.div
+                    initial={{ opacity: 0, height: 0 }}
+                    animate={{ opacity: 1, height: 'auto' }}
+                    exit={{ opacity: 0, height: 0 }}
+                    transition={{ duration: 0.3, ease: "easeInOut" }}
+                    className="grid gap-3 w-full overflow-hidden"
+                  >
+                    {message.sources.map((source, index) => (
+                      <div
+                        key={`${source.fileName}-${source.chunkIndex}-${index}`}
+                        className="bg-white/60 hover:bg-white border border-slate-200/60 rounded-xl p-4 transition-all cursor-pointer hover:shadow-lg hover:shadow-indigo-500/5 hover:border-indigo-200 group/source relative overflow-hidden"
+                        onClick={() => onPreviewSource?.(source)}
+                      >
+                        <div className="absolute left-0 top-0 bottom-0 w-1 bg-indigo-500 opacity-0 group-hover/source:opacity-100 transition-opacity" />
+                        <div className="flex justify-between items-start mb-2.5">
+                          {source.fileId ? (
+                            <button
+                              onClick={(e) => {
+                                e.stopPropagation();
+                                onOpenFile?.(source);
+                              }}
+                              className="font-bold text-slate-900 text-[13px] truncate pr-3 hover:text-indigo-600 transition-colors text-left"
+                              title={source.fileName}
+                            >
+                              {source.fileName}
+                            </button>
+                          ) : (
+                            <div className="font-bold text-slate-900 text-[13px] truncate pr-3" title={source.fileName}>{source.fileName}</div>
+                          )}
+                          <div className="text-[10px] font-black bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-full shrink-0 border border-indigo-100 shadow-sm uppercase tracking-tighter">
+                            {(source.score * 100).toFixed(1)}%
+                          </div>
+                        </div>
+                        <div className="text-slate-600 text-sm leading-relaxed line-clamp-2 italic font-medium">
+                          &ldquo;{source.content}&rdquo;
+                        </div>
+                        <div className="text-[10px] font-bold text-slate-400 mt-3 flex justify-between items-center uppercase tracking-widest">
+                          <span>{t('chunkNumber')} #{source.chunkIndex + 1}</span>
+                          <span className="text-indigo-600 opacity-0 group-hover/source:opacity-100 transition-all transform translate-x-2 group-hover/source:translate-x-0 flex items-center gap-1">
+                            {t('sourcePreview')} &rarr;
+                          </span>
                         </div>
                       </div>
-                      <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">
-                        <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>
-                      </div>
-                    </div>
-                  ))}
-                </div>
-              )}
+                    ))}
+                  </motion.div>
+                )}
+              </AnimatePresence>
             </div>
           )}
         </div>
       </div>
-    </div>
+    </motion.div>
   );
 };
 

+ 0 - 294
web/components/ConfigPanel.tsx

@@ -1,294 +0,0 @@
-
-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';
-
-interface ConfigPanelProps {
-  settings: AppSettings;
-  models: ModelConfig[];
-  onSettingsChange: (newSettings: AppSettings) => void;
-  onOpenSettings: () => void;
-  mode?: 'chat' | 'kb' | 'all';
-  isAdmin?: boolean;
-}
-
-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({
-      ...settings,
-      [key]: value,
-    });
-  };
-
-  const llmModels = models.filter(m => m.type === ModelType.LLM && m.isEnabled !== false && !m.supportsVision);
-  const embeddingModels = models.filter(m => m.type === ModelType.EMBEDDING && m.isEnabled !== false);
-  const rerankModels = models.filter(m => m.type === ModelType.RERANK && m.isEnabled !== false);
-
-  const showChatSettings = mode === 'chat' || mode === 'all';
-  const showKbSettings = mode === 'kb' || mode === 'all';
-
-  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 && (
-        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
-          <div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2">
-            <div className="flex items-center gap-2 text-slate-800 font-semibold">
-              <Cpu className="w-4 h-4 text-blue-600" />
-              {t('headerModelSelection')}
-            </div>
-          </div>
-
-          <div className="space-y-4">
-            <div>
-              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('selectLLMModel')}</label>
-              <select
-                value={settings.selectedLLMId}
-                onChange={(e) => handleChange('selectedLLMId', e.target.value)}
-                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 => (
-                  <option key={m.id} value={m.id}>
-                    {m.name} ({m.modelId})
-                  </option>
-                ))}
-              </select>
-            </div>
-          </div>
-        </div>
-      )}
-
-      {/* Embedding Model Selection - KB Mode Only */}
-      {showKbSettings && (
-        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
-          <div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2">
-            <div className="flex items-center gap-2 text-slate-800 font-semibold">
-              <Cpu className="w-4 h-4 text-blue-600" />
-              {t('lblEmbedding')}
-            </div>
-          </div>
-
-          <div className="space-y-4">
-            <div>
-              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblEmbedding')}</label>
-              <select
-                value={settings.selectedEmbeddingId}
-                onChange={async (e) => {
-                  const newId = e.target.value;
-                  if (newId !== settings.selectedEmbeddingId && settings.selectedEmbeddingId) {
-                    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);
-                  }
-                }}
-                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 => (
-                  <option key={m.id} value={m.id}>{m.name}</option>
-                ))}
-              </select>
-              <p className="text-[10px] text-slate-400 mt-1">{t('defaultForUploads')}</p>
-              <p className="text-[10px] text-orange-500 mt-1">{t('embeddingModelWarning') || "Changing this setting may require clearing and re-importing your knowledge base."}</p>
-            </div>
-          </div>
-        </div>
-      )}
-
-      {/* Hyperparameters - Chat Mode Only */}
-      {showChatSettings && (
-        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
-          <div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
-            <Sliders className="w-4 h-4 text-pink-500" />
-            {t('headerHyperparams')}
-          </div>
-
-          <div className="space-y-4">
-            <div>
-              <div className="flex justify-between mb-1.5">
-                <label className="text-xs font-medium text-slate-500">{t('lblTemperature')}</label>
-                <span className="text-xs text-blue-600 font-bold">{settings.temperature}</span>
-              </div>
-              <input
-                type="range"
-                min="0"
-                max="1"
-                step="0.1"
-                value={settings.temperature}
-                onChange={(e) => handleChange('temperature', 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'}`}
-              />
-            </div>
-            <div>
-              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblMaxTokens')}</label>
-              <input
-                type="number"
-                value={settings.maxTokens}
-                onChange={(e) => handleChange('maxTokens', parseInt(e.target.value))}
-                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>
-        </div>
-      )}
-
-      {/* Vision Model Settings - Chat Mode Only? Or both? Assuming Chat */}
-      {/* Vision Model Settings - KB Only */}
-      {showKbSettings && <VisionModelSelector isAdmin={isAdmin} />}
-
-      {/* Retrieval Settings - KB Mode Only */}
-      {showKbSettings && (
-        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
-          <div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
-            <Database className="w-4 h-4 text-green-600" />
-            {t('headerRetrieval')}
-          </div>
-
-          <div className="space-y-4">
-            <div>
-              <div className="flex justify-between mb-1.5">
-                <label className="text-xs font-medium text-slate-500">{t('lblTopK')}</label>
-                <span className="text-xs text-blue-600 font-bold">{settings.topK}</span>
-              </div>
-              <input
-                type="range"
-                min="1"
-                max="20"
-                step="1"
-                value={settings.topK}
-                onChange={(e) => handleChange('topK', parseInt(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'}`}
-              />
-            </div>
-
-            <div>
-              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblRerankRef')}</label>
-              <select
-                value={settings.selectedRerankId}
-                onChange={(e) => handleChange('selectedRerankId', e.target.value)}
-                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 => (
-                  <option key={m.id} value={m.id}>{m.name}</option>
-                ))}
-              </select>
-            </div>
-
-            <div>
-              <div className="flex justify-between mb-1.5">
-                <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.0"
-                max="1.0"
-                step="0.05"
-                value={settings.similarityThreshold}
-                onChange={(e) => handleChange('similarityThreshold', 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('filterLowResults')}</p>
-            </div>
-
-            {settings.enableRerank && (
-              <div>
-                <div className="flex justify-between mb-1.5">
-                  <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.0"
-                  max="1.0"
-                  step="0.05"
-                  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"
-                />
-              </div>
-            )}
-
-            <div className="flex items-center justify-between pt-2">
-              <label className="text-sm text-slate-700">{t('lblRerank')}</label>
-              <button
-                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'
-                    } inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
-                />
-              </button>
-            </div>
-
-            <div className="flex items-center justify-between pt-2">
-              <label className="text-sm text-slate-700">{t('fullTextSearch')}</label>
-              <button
-                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'
-                    } inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
-                />
-              </button>
-            </div>
-
-            {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>
-            )}
-          </div>
-        </div>
-      )}
-    </div>
-  );
-};
-
-export default ConfigPanel;

+ 1 - 1
web/components/CreateNoteFromPDFDialog.tsx

@@ -78,7 +78,7 @@ export const CreateNoteFromPDFDialog: React.FC<CreateNoteFromPDFDialogProps> = (
     };
 
     return (
-        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
+        <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
             <div className="bg-white rounded-lg w-full max-w-3xl max-h-[90vh] flex flex-col">
                 {/* Header */}
                 <div className="flex items-center justify-between p-4 border-b">

+ 79 - 72
web/components/DragDropUpload.tsx

@@ -1,11 +1,12 @@
 import React, { useCallback, useState } from 'react';
-import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder } from 'lucide-react';
+import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder, FileUp, ShieldCheck } from 'lucide-react';
 import { useLanguage } from '../contexts/LanguageContext';
+import { motion, AnimatePresence } from 'framer-motion';
 
 interface DragDropUploadProps {
   onFilesSelected: (files: FileList) => void;
   isAdmin: boolean;
-  globalMode?: boolean; // グローバルモードかどうかを制御するための追加属性
+  globalMode?: boolean;
 }
 
 export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected, isAdmin, globalMode = false }) => {
@@ -23,8 +24,7 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
   const handleDragLeave = useCallback((e: React.DragEvent) => {
     e.preventDefault();
     e.stopPropagation();
-    // マウスが実際にドラッグ領域を離れた場合のみfalseに設定
-    setTimeout(() => setIsDragging(false), 100);
+    setIsDragging(false);
   }, []);
 
   const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -47,83 +47,90 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
   const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
     if (e.target.files && e.target.files.length > 0) {
       onFilesSelected(e.target.files);
-      // Reset the input so the same file can be selected again
       e.target.value = '';
     }
   };
 
-  if (!isAdmin) {
-    return null;
-  }
+  if (!isAdmin) return null;
 
-  // モードに応じて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'
-    }`;
+  return (
+    <motion.div
+      initial={{ opacity: 0, scale: 0.98 }}
+      animate={{ opacity: 1, scale: 1 }}
+      className={`relative w-full overflow-hidden rounded-2xl border-2 border-dashed transition-all duration-300 ${isDragging
+        ? 'border-blue-500 bg-blue-50/50 shadow-[0_0_25px_rgba(59,130,246,0.1)]'
+        : 'border-slate-200 bg-slate-50/50 hover:border-slate-300 hover:bg-slate-50'
+        }`}
+      onDragEnter={handleDragEnter}
+      onDragOver={handleDragOver}
+      onDragLeave={handleDragLeave}
+      onDrop={handleDrop}
+      onClick={() => document.getElementById('file-upload-input')?.click()}
+    >
+      <div className="flex flex-col items-center justify-center py-12 px-6 text-center cursor-pointer">
+        <div className={`p-4 rounded-2xl mb-6 transition-all duration-300 ${isDragging ? 'bg-blue-600 text-white scale-110' : 'bg-white text-blue-600 shadow-sm border border-slate-100'}`}>
+          <FileUp size={32} />
+        </div>
 
-  const contentClass = globalMode
-    ? "w-3/4 max-w-2xl"
-    : "";
+        <div className="space-y-1 mb-8">
+          <h3 className="text-lg font-bold text-slate-900 tracking-tight">
+            {t('dragDropUploadTitle')}
+          </h3>
+          <p className="text-sm text-slate-500 font-medium">
+            {t('dragDropUploadDesc')}
+          </p>
+        </div>
 
-  return (
-    <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'
-            }`}
-          onDragEnter={handleDragEnter}
-          onDragOver={handleDragOver}
-          onDragLeave={handleDragLeave}
-          onDrop={handleDrop}
-          onClick={() => document.getElementById('file-upload-input')?.click()}
-        >
-          <div className="flex flex-col items-center justify-center gap-6">
-            <div className="p-4 bg-blue-100 rounded-full">
-              <UploadIcon className="w-10 h-10 text-blue-600" />
-            </div>
-            <div className="space-y-2">
-              <h3 className="text-lg font-semibold text-slate-700">
-                {t('dragDropUploadTitle')}
-              </h3>
-              <p className="text-sm text-slate-500">
-                {t('dragDropUploadDesc')}
-              </p>
-            </div>
-            <div className="flex items-center gap-6 mt-2">
-              <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">
-                <FileText className="w-4 h-4" />
-                <span>{t('supportedFormats')}</span>
-              </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>
-              </div>
-            </div>
-            <div className="pt-2">
-              <button
-                type="button"
-                className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-sm"
-              >
-                <Folder className="w-4 h-4" />
-                {t('browseFiles')}
-              </button>
-              <input
-                type="file"
-                multiple
-                onChange={handleFileInput}
-                className="hidden"
-                id="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/*"
-              />
-            </div>
+        <div className="flex flex-wrap items-center justify-center gap-4 mb-8">
+          <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
+            <ShieldCheck size={14} className="text-emerald-500" />
+            <span>Secure Ingestion</span>
+          </div>
+          <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
+            <FileText size={14} className="text-blue-500" />
+            <span>Documents & Text</span>
+          </div>
+          <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
+            <ImageIcon size={14} className="text-purple-500" />
+            <span>Images & Vision</span>
           </div>
         </div>
+
+        <button
+          type="button"
+          className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl shadow-md shadow-blue-100 transition-all font-semibold text-sm active:scale-95 flex items-center gap-2"
+        >
+          <Folder size={18} />
+          {t('browseFiles')}
+        </button>
+
+        <input
+          type="file"
+          multiple
+          onChange={handleFileInput}
+          className="hidden"
+          id="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/*"
+        />
       </div>
-    </div>
+
+      <AnimatePresence>
+        {isDragging && (
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            exit={{ opacity: 0 }}
+            className="absolute inset-0 bg-blue-600/5 backdrop-blur-[2px] pointer-events-none flex items-center justify-center border-2 border-blue-500 rounded-2xl"
+          >
+            <div className="bg-white p-6 rounded-3xl shadow-2xl flex flex-col items-center gap-3">
+              <div className="w-12 h-12 bg-blue-600 text-white rounded-2xl flex items-center justify-center animate-bounce">
+                <FileUp size={24} />
+              </div>
+              <span className="text-blue-600 font-bold">Drop to Ingest</span>
+            </div>
+          </motion.div>
+        )}
+      </AnimatePresence>
+    </motion.div>
   );
 };

+ 1 - 1
web/components/FileGroupTags.tsx

@@ -91,7 +91,7 @@ export const FileGroupTags: React.FC<FileGroupTagsProps> = ({
               <button
                 onClick={() => handleRemoveFromGroup(group.id)}
                 disabled={loading}
-                className="hover:bg-black hover:bg-opacity-10 rounded-full p-0.5"
+                className="hover:bg-black/10 rounded-full p-0.5"
               >
                 <X size={10} />
               </button>

+ 57 - 132
web/components/GlobalDragDropOverlay.tsx

@@ -1,21 +1,18 @@
 import { useLayoutEffect, useRef, useState, useCallback } from 'react';
 import { useLanguage } from '../contexts/LanguageContext';
+import { motion, AnimatePresence } from 'framer-motion';
+import { FileUp, ShieldCheck, FileText, Image as ImageIcon } from 'lucide-react';
 
 interface GlobalDragDropProps {
   onFilesSelected: (files: FileList) => void;
   isAdmin: boolean;
 }
 
-// ドラッグドロップオーバーレイの表示を許可するかどうかを追跡するモジュールレベルの変数
 let isDragDropEnabled = true;
-
-// 強制的に非表示にするコールバック関数を保存するモジュールレベルの変数
 let forceHideCallback: (() => void) | null = null;
 
-// 外部からこの状態を制御するための関数を提供
 export const setDragDropEnabled = (enabled: boolean) => {
   isDragDropEnabled = enabled;
-  // 無効化された場合、直ちにオーバーレイを強制的に非表示にする
   if (!enabled && forceHideCallback) {
     forceHideCallback();
   }
@@ -30,103 +27,53 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
 
   const hasFiles = useCallback((dt: DataTransfer | null) => {
     if (!dt) return false;
-
-    // より厳格なチェック: typesにFilesが含まれることを確認
     const hasFileType = dt.types && dt.types.includes('Files');
     if (!hasFileType) return false;
-
-    // itemsが存在する場合、実際にファイルがあるかチェック
     if (dt.items && dt.items.length > 0) {
-      // 少なくとも1つのファイルアイテムがあることを確認
       for (let i = 0; i < dt.items.length; i++) {
-        if (dt.items[i].kind === 'file') {
-          return true;
-        }
+        if (dt.items[i].kind === 'file') return true;
       }
       return false;
     }
-
-    // itemsがない場合はtypesのみで判断(後方互換性)
     return hasFileType;
   }, []);
 
   const handleDragEnter = useCallback((e: DragEvent) => {
-    // ドラッグドロップ機能が無効な場合はイベントを無視
-    if (!isDragDropEnabled) return;
-
-    // データ転送にファイルが含まれている場合のみ処理
-    if (!hasFiles(e.dataTransfer)) return;
-
+    if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
-
     dragCounterRef.current++;
     if (dragCounterRef.current === 1) {
-      // 直ちにオーバーレイを表示し、誤作動は強制非表示メカニズムに依存
       setIsVisible(true);
-      if (overlayRef.current) {
-        overlayRef.current.style.opacity = '1';
-        overlayRef.current.style.visibility = 'visible';
-      }
       isDragActiveRef.current = true;
     }
   }, [hasFiles]);
 
   const handleDragOver = useCallback((e: DragEvent) => {
-    // ドラッグドロップ機能が無効な場合はイベントを無視
-    if (!isDragDropEnabled) return;
-
-    // データ転送にファイルが含まれている場合のみ処理
-    if (!hasFiles(e.dataTransfer)) return;
-
+    if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
     e.dataTransfer!.dropEffect = 'copy';
   }, [hasFiles]);
 
   const handleDragLeave = useCallback((e: DragEvent) => {
-    // ドラッグドロップ機能が無効な場合はイベントを無視
-    if (!isDragDropEnabled) return;
-
-    // データ転送にファイルが含まれている場合のみ処理
-    if (!hasFiles(e.dataTransfer)) return;
-
+    if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
-
     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;
-
+    if (!isDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
     dragCounterRef.current = 0;
-
-    // 全域ドラッグアップロードオーバーレイを非表示にする
-    if (overlayRef.current) {
-      overlayRef.current.style.opacity = '0';
-      overlayRef.current.style.visibility = 'hidden';
-    }
     setIsVisible(false);
-
     isDragActiveRef.current = false;
-
-    // ファイルを処理
     if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
       onFilesSelected(e.dataTransfer.files);
     }
@@ -134,104 +81,82 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
 
   useLayoutEffect(() => {
     if (!isAdmin) return;
-
-    // 初期化時にdragCounterとisDragActiveが初期値であることを確認
     dragCounterRef.current = 0;
     isDragActiveRef.current = false;
-
-    // 強制非表示コールバックを登録
     forceHideCallback = () => {
       dragCounterRef.current = 0;
       isDragActiveRef.current = false;
       setIsVisible(false);
-      if (overlayRef.current) {
-        overlayRef.current.style.opacity = '0';
-        overlayRef.current.style.visibility = 'hidden';
-      }
     };
-
-    // 全域イベントリスナーを追加
     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) {
-        overlayRef.current.style.opacity = '0';
-        overlayRef.current.style.visibility = 'hidden';
-      }
       setIsVisible(false);
     };
   }, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
 
-  // ランタイムチェックを追加し、適切な場合のみレンダリングすることを保証
-  if (!isAdmin || typeof window === 'undefined') {
-    return null;
-  }
-
-  // isVisible が true の場合のみコンポーネントの内容をレンダリング
-  if (!isVisible) {
-    return null;
-  }
+  if (!isAdmin || typeof window === 'undefined') return null;
 
   return (
-    <div
-      ref={overlayRef}
-      id="global-drag-overlay"
-      className="fixed inset-0 bg-black bg-opacity-50 items-center justify-center z-50 transition-opacity duration-300 pointer-events-none"
-      style={{ opacity: 1, visibility: 'visible', display: 'flex' }}
-    >
-      <div className="w-3/4 max-w-2xl pointer-events-auto">
-        <div className="border-2 border-dashed border-blue-500 bg-blue-50 rounded-xl p-8 text-center cursor-pointer">
-          <div className="flex flex-col items-center justify-center gap-6">
-            <div className="p-4 bg-blue-100 rounded-full">
-              <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-10 h-10 text-blue-600">
-                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
-                <polyline points="17 8 12 3 7 8"></polyline>
-                <line x1="12" x2="12" y1="3" y2="15"></line>
-              </svg>
-            </div>
-            <div className="space-y-2">
-              <h3 className="text-lg font-semibold text-slate-700">
-                {t('dragDropUploadTitle')}
-              </h3>
-              <p className="text-sm text-slate-500">
-                {t('dragDropUploadDesc')}
-              </p>
-            </div>
-            <div className="flex items-center gap-6 mt-2">
-              <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">
-                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
-                  <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
-                  <polyline points="14 2 14 8 20 8"></polyline>
-                </svg>
-                <span>{t('supportedFormats')}</span>
+    <AnimatePresence>
+      {isVisible && (
+        <motion.div
+          initial={{ opacity: 0 }}
+          animate={{ opacity: 1 }}
+          exit={{ opacity: 0 }}
+          className="fixed inset-0 bg-blue-600/10 backdrop-blur-md items-center justify-center z-[9999] pointer-events-none flex p-8"
+        >
+          <motion.div
+            initial={{ scale: 0.9, y: 20 }}
+            animate={{ scale: 1, y: 0 }}
+            exit={{ scale: 0.9, y: 20 }}
+            className="w-full max-w-2xl bg-white rounded-[2.5rem] p-12 text-center shadow-[0_32px_64px_-12px_rgba(0,0,0,0.14)] border border-white pointer-events-auto"
+          >
+            <div className="flex flex-col items-center justify-center gap-8">
+              <div className="w-24 h-24 bg-blue-600 text-white rounded-3xl flex items-center justify-center shadow-xl shadow-blue-200 animate-bounce">
+                <FileUp size={48} />
               </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">
-                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
-                  <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
-                  <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>
+
+              <div className="space-y-3">
+                <h3 className="text-3xl font-black text-slate-900 tracking-tight">
+                  {t('dragDropUploadTitle')}
+                </h3>
+                <p className="text-lg text-slate-500 font-medium">
+                  Drop your files anywhere to start processing
+                </p>
+              </div>
+
+              <div className="flex flex-wrap items-center justify-center gap-6 py-8 border-y border-slate-100 w-full">
+                <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
+                  <ShieldCheck size={20} className="text-emerald-500" />
+                  <span>Secure Processing</span>
+                </div>
+                <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
+                  <FileText size={20} className="text-blue-500" />
+                  <span>All Formats</span>
+                </div>
+                <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
+                  <ImageIcon size={20} className="text-purple-500" />
+                  <span>Visual Vision</span>
+                </div>
+              </div>
+
+              <div className="text-slate-400 font-bold text-xs uppercase tracking-[0.3em]">
+                Release to begin ingestion
               </div>
             </div>
-          </div>
-        </div>
-      </div>
-    </div>
+          </motion.div>
+        </motion.div>
+      )}
+    </AnimatePresence>
   );
 };

+ 1 - 1
web/components/GroupManager.tsx

@@ -155,7 +155,7 @@ export const GroupManager: React.FC<GroupManagerProps> = ({ groups, onGroupsChan
 
       {/* 创建/编辑模态框 */}
       {isModalOpen && (
-        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+        <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
           <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">

+ 17 - 14
web/components/GroupSelectionDrawer.tsx

@@ -1,4 +1,5 @@
 import React, { useState } from 'react';
+import { createPortal } from 'react-dom';
 import { useLanguage } from '../contexts/LanguageContext';
 import { KnowledgeGroup } from '../types';
 import { Check, X, Search, Database } from 'lucide-react';
@@ -41,9 +42,9 @@ export const GroupSelectionDrawer: React.FC<GroupSelectionDrawerProps> = ({
         onSelectionChange([]);
     };
 
-    return (
+    return createPortal(
         <div className="fixed inset-0 z-50 overflow-hidden">
-            <div className="absolute inset-0 bg-black bg-opacity-30 transition-opacity" onClick={onClose} />
+            <div className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onClick={onClose} />
             <div className="absolute inset-y-0 right-0 max-w-md w-full flex">
                 <div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300">
                     <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
@@ -73,18 +74,18 @@ export const GroupSelectionDrawer: React.FC<GroupSelectionDrawerProps> = ({
                         </div>
                     </div>
 
-                    <div className="flex-1 overflow-y-auto p-2">
+                    <div className="flex-1 overflow-y-auto p-4 space-y-1">
                         {!searchTerm && (
                             <div
                                 onClick={handleSelectAll}
-                                className={`flex items-center px-4 py-3 cursor-pointer hover:bg-gray-50 rounded-lg mb-1 transition-colors ${isAllSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
+                                className={`flex items-center px-4 py-3.5 cursor-pointer hover:bg-slate-50 rounded-xl transition-all ${isAllSelected ? 'bg-blue-50/50 text-blue-700 outline outline-1 outline-blue-200' : 'text-slate-700 border border-transparent'
                                     }`}
                             >
-                                <div className={`w-5 h-5 mr-3 border rounded flex items-center justify-center ${isAllSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
+                                <div className={`w-5 h-5 mr-3 border rounded-md flex items-center justify-center transition-colors ${isAllSelected ? 'bg-blue-600 border-blue-600' : 'border-slate-300'
                                     }`}>
                                     {isAllSelected && <Check size={14} className="text-white" />}
                                 </div>
-                                <span className="font-medium">{t('all')}</span>
+                                <span className="font-semibold text-sm">{t('all')}</span>
                             </div>
                         )}
 
@@ -94,22 +95,22 @@ export const GroupSelectionDrawer: React.FC<GroupSelectionDrawerProps> = ({
                                 <div
                                     key={group.id}
                                     onClick={() => handleToggleGroup(group.id)}
-                                    className={`flex items-center px-4 py-3 cursor-pointer hover:bg-gray-50 rounded-lg mb-1 transition-colors ${isSelected ? 'bg-blue-50' : ''
+                                    className={`flex items-center px-4 py-3 cursor-pointer hover:bg-slate-50 rounded-xl transition-all ${isSelected ? 'bg-blue-50/50 outline outline-1 outline-blue-200' : 'border border-transparent'
                                         }`}
                                 >
-                                    <div className={`w-5 h-5 mr-3 border rounded flex items-center justify-center flex-shrink-0 ${isSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
+                                    <div className={`w-5 h-5 mr-3 border rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${isSelected ? 'bg-blue-600 border-blue-600' : 'border-slate-300'
                                         }`}>
                                         {isSelected && <Check size={14} className="text-white" />}
                                     </div>
                                     <div
-                                        className="w-3 h-3 rounded-full mr-3 flex-shrink-0"
+                                        className="w-3 h-3 rounded-full mr-3 flex-shrink-0 shadow-sm"
                                         style={{ backgroundColor: group.color }}
                                     />
                                     <div className="flex-1 min-w-0">
-                                        <div className={`text-sm truncate ${isSelected ? 'text-blue-700 font-medium' : 'text-gray-700'}`}>
+                                        <div className={`text-sm truncate transition-colors ${isSelected ? 'text-blue-700 font-semibold' : 'text-slate-700 font-medium'}`}>
                                             {group.name}
                                         </div>
-                                        <div className="text-xs text-gray-400 mt-0.5">
+                                        <div className={`text-xs mt-0.5 transition-colors ${isSelected ? 'text-blue-500/80' : 'text-slate-400'}`}>
                                             {group.fileCount} 个文件
                                         </div>
                                     </div>
@@ -118,8 +119,9 @@ export const GroupSelectionDrawer: React.FC<GroupSelectionDrawerProps> = ({
                         })}
 
                         {filteredGroups.length === 0 && (
-                            <div className="py-8 text-center text-gray-400 text-sm">
-                                {searchTerm ? t('noGroupsFound') : t('noGroups')}
+                            <div className="py-12 text-center text-slate-400 text-sm flex flex-col items-center justify-center gap-2">
+                                <Database size={32} className="text-slate-200" />
+                                <span>{searchTerm ? t('noGroupsFound') : t('noGroups')}</span>
                             </div>
                         )}
                     </div>
@@ -134,6 +136,7 @@ export const GroupSelectionDrawer: React.FC<GroupSelectionDrawerProps> = ({
                     </div>
                 </div>
             </div>
-        </div>
+        </div>,
+        document.body
     );
 };

+ 5 - 3
web/components/HistoryDrawer.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import { createPortal } from 'react-dom';
 import { KnowledgeGroup } from '../types';
 import { SearchHistoryList } from './SearchHistoryList';
 import { X, History } from 'lucide-react';
@@ -20,9 +21,9 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
     const { t } = useLanguage();
     if (!isOpen) return null;
 
-    return (
+    return createPortal(
         <div className="fixed inset-0 z-50 overflow-hidden">
-            <div className="absolute inset-0 bg-black bg-opacity-30 transition-opacity" onClick={onClose} />
+            <div className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onClick={onClose} />
             <div className="absolute inset-y-0 right-0 max-w-md w-full flex">
                 <div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300">
                     <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
@@ -45,6 +46,7 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
                     </div>
                 </div>
             </div>
-        </div>
+        </div>,
+        document.body
     );
 };

+ 35 - 30
web/components/IndexingModal.tsx

@@ -152,20 +152,21 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
     }
 
     return (
-      <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs">
-        <div className="flex items-center gap-2 mb-2 font-semibold text-blue-800">
-          <Info className="w-4 h-4" />
+      <div className="bg-blue-50/50 backdrop-blur-sm border border-blue-100 rounded-xl p-4 text-xs">
+        <div className="flex items-center gap-2 mb-2 font-bold text-blue-900">
+          <Info className="w-4 h-4 text-blue-600" />
           {t('modelLimitsInfo')}
         </div>
-        <div className="grid grid-cols-2 gap-2 text-blue-700">
-          <div>{t('model')}: <span className="font-medium">{limits.modelInfo.name}</span></div>
-          <div>{t('maxChunkSize')}: <span className="font-medium">{limits.maxChunkSize} tokens</span></div>
-          <div>{t('maxOverlapSize')}: <span className="font-medium">{limits.maxOverlapSize} tokens</span></div>
-          <div>{t('maxBatchSize')}: <span className="font-medium">{limits.modelInfo.maxBatchSize}</span></div>
+        <div className="grid grid-cols-2 gap-y-2 gap-x-4 text-slate-600">
+          <div>{t('model')}: <span className="font-semibold text-slate-900">{limits.modelInfo.name}</span></div>
+          <div>{t('maxChunkSize')}: <span className="font-semibold text-slate-900">{limits.maxChunkSize} tokens</span></div>
+          <div>{t('maxOverlapSize')}: <span className="font-semibold text-slate-900">{limits.maxOverlapSize} tokens</span></div>
+          <div>{t('maxBatchSize')}: <span className="font-semibold text-slate-900">{limits.modelInfo.maxBatchSize}</span></div>
         </div>
         {limits.modelInfo.maxInputTokens > limits.maxChunkSize && (
-          <div className="mt-1 text-blue-600 text-[10px]">
-            ⚠️ {t('envLimitWeaker')}: {limits.maxChunkSize} &lt; {limits.modelInfo.maxInputTokens}
+          <div className="mt-2 text-blue-600/80 text-[10px] flex items-center gap-1">
+            <Info size={10} />
+            {t('envLimitWeaker')}: {limits.maxChunkSize} &lt; {limits.modelInfo.maxInputTokens}
           </div>
         )}
       </div>
@@ -175,22 +176,23 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
   if (!isOpen) return null;
 
   return (
-    <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
-      <div className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
-
+    <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-md p-4 animate-in fade-in duration-300">
+      <div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh] border border-white/20">
         {/* Header */}
-        <div className="p-5 border-b border-slate-100 bg-slate-50">
+        <div className="p-6 border-b border-slate-50 bg-white">
           <div className="flex justify-between items-start">
             <div>
-              <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
-                <Database className="w-5 h-5 text-blue-600" />
+              <h2 className="text-xl font-bold text-slate-900 flex items-center gap-2.5">
+                <div className="p-2 bg-blue-50 rounded-xl">
+                  <Database className="w-5 h-5 text-blue-600" />
+                </div>
                 {isReconfiguring ? t('reconfigureFile') : t('idxModalTitle')}
               </h2>
-              <p className="text-xs text-slate-500 mt-1">
+              <p className="text-[13px] text-slate-500 mt-1 ml-12">
                 {isReconfiguring ? t('modifySettings') : t('idxDesc')}
               </p>
             </div>
-            <button onClick={onClose} className="p-1 hover:bg-slate-200 rounded-full transition-colors">
+            <button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl transition-all active:scale-95">
               <X className="w-5 h-5 text-slate-400" />
             </button>
           </div>
@@ -204,11 +206,11 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
               <Files className="w-4 h-4 text-slate-500" />
               {t('idxFiles')}
             </h3>
-            <div className="space-y-1 max-h-32 overflow-y-auto bg-slate-50 rounded-lg p-2 border border-slate-200">
+            <div className="space-y-1 max-h-32 overflow-y-auto bg-slate-50/50 rounded-xl p-3 border border-slate-100">
               {files.map((file, index) => (
-                <div key={index} className="text-xs text-slate-600 flex items-center justify-between py-1 px-2 hover:bg-white rounded transition-colors">
+                <div key={index} className="text-xs text-slate-600 flex items-center justify-between py-1.5 px-2 hover:bg-white/80 rounded-lg transition-colors">
                   <span className="truncate flex-1">{file.name}</span>
-                  <span className="text-slate-400 ml-2">{formatBytes(file.size)}</span>
+                  <span className="text-slate-400 ml-2 font-medium">{formatBytes(file.size)}</span>
                 </div>
               ))}
             </div>
@@ -221,14 +223,14 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
               {t('idxEmbeddingModel')}
             </h3>
             <select
-              className="w-full text-sm border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
+              className="w-full text-sm border border-slate-100 bg-slate-50/50 rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition-all cursor-pointer"
               value={selectedEmbedding}
               onChange={(e) => setSelectedEmbedding(e.target.value)}
             >
               <option value="">{t('pleaseSelect')}</option>
               {embeddingModels.map(model => (
                 <option key={model.id} value={model.id}>
-                  {model.name} ({model.modelId})
+                  {model.name}
                 </option>
               ))}
             </select>
@@ -291,9 +293,12 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
 
           {/* Optimization Tips */}
           {limits && (
-            <div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
-              <p className="font-medium mb-1">💡 {t('optimizationTips')}</p>
-              <ul className="list-disc list-inside space-y-0.5 text-[11px]">
+            <div className="bg-amber-50/50 backdrop-blur-sm border border-amber-100 rounded-xl p-4 text-xs text-amber-900">
+              <p className="font-bold mb-2 flex items-center gap-1.5">
+                <span className="text-amber-500">💡</span>
+                {t('optimizationTips')}
+              </p>
+              <ul className="list-disc list-inside space-y-1.5 text-[11px] text-amber-800/80">
                 {chunkSize > 800 && <li>{t('tipChunkTooLarge')}</li>}
                 {chunkOverlap < chunkSize * 0.1 && <li>{t('tipOverlapSmall').replace('$1', String(Math.floor(chunkSize * 0.1)))}</li>}
                 {chunkSize === limits.maxChunkSize && <li>{t('tipMaxValues')}</li>}
@@ -304,10 +309,10 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
         </div>
 
         {/* Footer Buttons */}
-        <div className="p-4 border-t border-slate-100 bg-slate-50 flex justify-end gap-2">
+        <div className="p-6 border-t border-slate-50 bg-white flex justify-end gap-3">
           <button
             onClick={onClose}
-            className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
+            className="px-6 py-2.5 text-sm font-semibold text-slate-600 hover:bg-slate-50 rounded-xl transition-all active:scale-95"
           >
             {t('idxCancel')}
           </button>
@@ -324,9 +329,9 @@ const IndexingModal: React.FC<IndexingModalProps> = ({
               });
             }}
             disabled={!selectedEmbedding || isLoadingLimits}
-            className="px-4 py-2 text-sm bg-blue-600 text-white hover:bg-blue-700 rounded-lg shadow-sm flex items-center gap-2 transition-transform active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
+            className="px-8 py-2.5 text-sm font-bold bg-blue-600 text-white hover:bg-blue-700 rounded-xl shadow-lg shadow-blue-200 flex items-center gap-2 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
           >
-            <Database className="w-4 h-4" />
+            <ArrowRight className="w-4 h-4" />
             {t('idxStart')}
           </button>
         </div>

+ 62 - 57
web/components/IndexingModalWithMode.tsx

@@ -221,20 +221,21 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
     }
 
     return (
-      <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-xs">
-        <div className="flex items-center gap-2 mb-2 font-semibold text-blue-800">
-          <Info className="w-4 h-4" />
+      <div className="bg-blue-50/50 backdrop-blur-sm border border-blue-100 rounded-xl p-4 text-xs mt-2">
+        <div className="flex items-center gap-2 mb-2 font-bold text-blue-900">
+          <Info className="w-4 h-4 text-blue-600" />
           {t('modelLimitsInfo')}
         </div>
-        <div className="grid grid-cols-2 gap-2 text-blue-700">
-          <div>{t('model')}: <span className="font-medium">{limits.modelInfo.name}</span></div>
-          <div>{t('maxChunkSize')}: <span className="font-medium">{limits.maxChunkSize} tokens</span></div>
-          <div>{t('maxOverlapSize')}: <span className="font-medium">{limits.maxOverlapSize} tokens</span></div>
-          <div>{t('maxBatchSize')}: <span className="font-medium">{limits.modelInfo.maxBatchSize}</span></div>
+        <div className="grid grid-cols-2 gap-y-2 gap-x-4 text-slate-600">
+          <div>{t('model')}: <span className="font-semibold text-slate-900">{limits.modelInfo.name}</span></div>
+          <div>{t('maxChunkSize')}: <span className="font-semibold text-slate-900">{limits.maxChunkSize} tokens</span></div>
+          <div>{t('maxOverlapSize')}: <span className="font-semibold text-slate-900">{limits.maxOverlapSize} tokens</span></div>
+          <div>{t('maxBatchSize')}: <span className="font-semibold text-slate-900">{limits.modelInfo.maxBatchSize}</span></div>
         </div>
         {limits.modelInfo.maxInputTokens > limits.maxChunkSize && (
-          <div className="mt-1 text-blue-600 text-[10px]">
-            ⚠️ {t('envLimitWeaker')}: {limits.maxChunkSize} &lt; {limits.modelInfo.maxInputTokens}
+          <div className="mt-2 text-blue-600/80 text-[10px] flex items-center gap-1">
+            <Info size={10} />
+            {t('envLimitWeaker')}: {limits.maxChunkSize} &lt; {limits.modelInfo.maxInputTokens}
           </div>
         )}
       </div>
@@ -248,19 +249,19 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
     }
 
     return (
-      <div className="space-y-2 p-3 bg-purple-50 border border-purple-200 rounded-lg text-xs">
-        <div className="font-semibold text-purple-800 flex items-center gap-2">
-          <Target className="w-4 h-4" />
+      <div className="space-y-2 p-4 bg-purple-50/50 backdrop-blur-sm border border-purple-100 rounded-xl text-xs">
+        <div className="font-bold text-purple-900 flex items-center gap-2">
+          <Target className="w-4 h-4 text-purple-600" />
           {t('processingMode')}
         </div>
-        <div className="text-purple-700">
-          <strong>{t('recommendationReason')}:</strong> {t(modeRecommendation.reason, ...(modeRecommendation.reasonArgs || []))}
+        <div className="text-slate-600">
+          <strong className="text-purple-900/70">{t('recommendationReason')}:</strong> {t(modeRecommendation.reason, ...(modeRecommendation.reasonArgs || []))}
         </div>
         {modeRecommendation.warnings && modeRecommendation.warnings.length > 0 && (
-          <div className="mt-1 space-y-1">
+          <div className="mt-2 space-y-1.5 border-t border-purple-100 pt-2">
             {modeRecommendation.warnings.map((warning: string, idx: number) => (
-              <div key={idx} className="text-purple-800 flex items-start gap-1">
-                <AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+              <div key={idx} className="text-purple-800/80 flex items-start gap-1.5 leading-relaxed">
+                <AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-purple-500" />
                 <span>{t(warning as any)}</span>
               </div>
             ))}
@@ -274,35 +275,34 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
   const renderModeDescription = () => {
     if (mode === 'fast') {
       return (
-        <div className="text-xs text-slate-600 bg-slate-50 p-2 rounded border border-slate-200">
-          <div className="font-semibold text-slate-700 mb-1 flex items-center gap-1">
-            <Zap className="w-3 h-3 text-yellow-600" />
+        <div className="text-xs text-slate-600 bg-slate-50/50 p-3 rounded-xl border border-slate-100">
+          <div className="font-bold text-slate-900 mb-2 flex items-center gap-2">
+            <Zap className="w-4 h-4 text-yellow-500" />
             {t('fastModeFeatures')}
           </div>
-          <ul className="list-disc list-inside space-y-0.5 text-[11px]">
-            <li>{t('fastFeature1')}</li>
-            <li>{t('fastFeature2')}</li>
-            <li>{t('fastFeature3')}</li>
-            <li>{t('fastFeature4')}</li>
-            <li>{t('fastFeature5')}</li>
+          <ul className="grid grid-cols-1 gap-1.5 text-[11px] text-slate-500">
+            {['fastFeature1', 'fastFeature2', 'fastFeature3', 'fastFeature4', 'fastFeature5'].map((feature) => (
+              <li key={feature} className="flex items-center gap-2 before:content-[''] before:w-1 before:h-1 before:bg-slate-300 before:rounded-full">
+                {t(feature as any)}
+              </li>
+            ))}
           </ul>
         </div>
       );
     }
 
     return (
-      <div className="text-xs text-slate-600 bg-slate-50 p-2 rounded border border-slate-200">
-        <div className="font-semibold text-slate-700 mb-1 flex items-center gap-1">
-          <Target className="w-3 h-3 text-blue-600" />
+      <div className="text-xs text-slate-600 bg-slate-50/50 p-3 rounded-xl border border-slate-100">
+        <div className="font-bold text-slate-900 mb-2 flex items-center gap-2">
+          <Target className="w-4 h-4 text-blue-600" />
           {t('preciseModeFeatures')}
         </div>
-        <ul className="list-disc list-inside space-y-0.5 text-[11px]">
-          <li>{t('preciseFeature1')}</li>
-          <li>{t('preciseFeature2')}</li>
-          <li>{t('preciseFeature3')}</li>
-          <li>{t('preciseFeature4')}</li>
-          <li>{t('preciseFeature5')}</li>
-          <li>{t('preciseFeature6')}</li>
+        <ul className="grid grid-cols-1 gap-1.5 text-[11px] text-slate-500">
+          {['preciseFeature1', 'preciseFeature2', 'preciseFeature3', 'preciseFeature4', 'preciseFeature5', 'preciseFeature6'].map((feature) => (
+            <li key={feature} className="flex items-center gap-2 before:content-[''] before:w-1 before:h-1 before:bg-slate-300 before:rounded-full">
+              {t(feature as any)}
+            </li>
+          ))}
         </ul>
       </div>
     );
@@ -313,20 +313,22 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
   return createPortal(
     <>
       <div
-        className="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity"
+        className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-md transition-opacity"
         onClick={onClose}
       />
-      <div className="fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-2xl z-[101] transform transition-transform duration-300 ease-in-out animate-in slide-in-from-right flex flex-col">
+      <div className="fixed right-0 top-0 h-full w-full max-w-lg bg-white shadow-2xl z-[101] transform transition-transform duration-500 ease-out animate-in slide-in-from-right flex flex-col border-l border-slate-50">
 
         {/* Header */}
-        <div className="p-5 border-b border-slate-100 bg-slate-50 shrink-0">
+        <div className="p-6 border-b border-slate-50 bg-white shrink-0">
           <div className="flex justify-between items-start">
             <div>
-              <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
-                <Database className="w-5 h-5 text-blue-600" />
+              <h2 className="text-xl font-bold text-slate-900 flex items-center gap-2.5">
+                <div className="p-2 bg-blue-50 rounded-xl">
+                  <Database className="w-5 h-5 text-blue-600" />
+                </div>
                 {isReconfiguring ? t('reconfigureTitle') : t('indexingConfigTitle')}
               </h2>
-              <p className="text-xs text-slate-500 mt-1">
+              <p className="text-[13px] text-slate-500 mt-1 ml-12">
                 {isReconfiguring ? t('reconfigureDesc') : t('indexingConfigDesc')}
               </p>
             </div>
@@ -335,9 +337,9 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
                 e.stopPropagation();
                 onClose();
               }}
-              className="p-2 hover:bg-slate-200 rounded-full transition-colors active:scale-90"
+              className="p-2 hover:bg-slate-100 rounded-xl transition-all active:scale-95"
             >
-              <X className="w-5 h-5 text-slate-500" />
+              <X className="w-5 h-5 text-slate-400" />
             </button>
           </div>
         </div>
@@ -351,11 +353,11 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
                 <Files className="w-4 h-4 text-slate-500" />
                 {t('pendingFiles')}
               </h3>
-              <div className="space-y-1 max-h-32 overflow-y-auto bg-slate-50 rounded-lg p-2 border border-slate-200">
+              <div className="space-y-1 max-h-32 overflow-y-auto bg-slate-50/50 rounded-xl p-3 border border-slate-100">
                 {files.map((file, index) => (
-                  <div key={index} className="text-xs text-slate-600 flex items-center justify-between py-1 px-2 hover:bg-white rounded transition-colors">
+                  <div key={index} className="text-xs text-slate-600 flex items-center justify-between py-1.5 px-2 hover:bg-white/80 rounded-lg transition-colors">
                     <span className="truncate flex-1">{file.name}</span>
-                    <span className="text-slate-400 ml-2">{formatBytes(file.size)}</span>
+                    <span className="text-slate-400 ml-2 font-medium">{formatBytes(file.size)}</span>
                   </div>
                 ))}
               </div>
@@ -441,14 +443,14 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
               {t('embeddingModel')}
             </h3>
             <select
-              className="w-full text-sm border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
+              className="w-full text-sm border border-slate-100 bg-slate-50/50 rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-blue-100 focus:border-blue-400 outline-none transition-all cursor-pointer"
               value={selectedEmbedding}
               onChange={(e) => setSelectedEmbedding(e.target.value)}
             >
               <option value="">{t('pleaseSelect')}</option>
               {embeddingModels.filter(m => m.isEnabled !== false).map(model => (
                 <option key={model.id} value={model.id}>
-                  {model.name} ({model.modelId})
+                  {model.name}
                 </option>
               ))}
             </select>
@@ -510,9 +512,12 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
 
           {/* Optimization tips */}
           {limits && (
-            <div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
-              <p className="font-medium mb-1">💡 {t('optimizationTips')}</p>
-              <ul className="list-disc list-inside space-y-0.5 text-[11px]">
+            <div className="bg-amber-50/50 backdrop-blur-sm border border-amber-100 rounded-xl p-4 text-xs text-amber-900">
+              <p className="font-bold mb-2 flex items-center gap-1.5">
+                <span className="text-amber-500">💡</span>
+                {t('optimizationTips')}
+              </p>
+              <ul className="list-disc list-inside space-y-1.5 text-[11px] text-amber-800/80">
                 {chunkSize > 800 && <li>{t('tipChunkTooLarge')}</li>}
                 {chunkOverlap < chunkSize * 0.1 && <li>{t('tipOverlapSmall').replace('$1', `${Math.floor(chunkSize * 0.1)}`)}</li>}
                 {chunkSize === limits.maxChunkSize && <li>{t('tipMaxValues')}</li>}
@@ -524,13 +529,13 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
         </div>
 
         {/* Footer buttons */}
-        <div className="p-4 border-t border-slate-100 bg-slate-50 flex justify-end gap-2 shrink-0">
+        <div className="p-6 border-t border-slate-50 bg-white flex justify-end gap-3 shrink-0">
           <button
             onClick={(e) => {
               e.stopPropagation();
               onClose();
             }}
-            className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-200 rounded-lg transition-colors active:scale-95"
+            className="px-6 py-2.5 text-sm font-semibold text-slate-600 hover:bg-slate-50 rounded-xl transition-all active:scale-95"
           >
             {t('cancel')}
           </button>
@@ -554,9 +559,9 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
               });
             }}
             disabled={isLoadingLimits}
-            className="px-4 py-2 text-sm bg-blue-600 text-white hover:bg-blue-700 rounded-lg shadow-sm flex items-center gap-2 transition-transform active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
+            className="px-8 py-2.5 text-sm font-bold bg-blue-600 text-white hover:bg-blue-700 rounded-xl shadow-lg shadow-blue-200 flex items-center gap-2 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
           >
-            <Database className="w-4 h-4" />
+            <ArrowRight className="w-4 h-4" />
             {t('startProcessing')}
           </button>
         </div>

+ 5 - 3
web/components/InputDrawer.tsx

@@ -1,4 +1,5 @@
 import React, { useState, useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
 import { X, Check } from 'lucide-react';
 
 interface InputDrawerProps {
@@ -42,9 +43,9 @@ export const InputDrawer: React.FC<InputDrawerProps> = ({
 
     if (!isOpen) return null;
 
-    return (
+    return createPortal(
         <div className="fixed inset-0 z-50 overflow-hidden">
-            <div className="absolute inset-0 bg-black bg-opacity-30 transition-opacity" onClick={onClose} />
+            <div className="absolute inset-0 bg-black/30 backdrop-blur-sm transition-opacity" onClick={onClose} />
             <div className="absolute inset-y-0 right-0 max-w-sm w-full flex pointer-events-none">
                 {/* pointer-events-none on wrapper, auto on content to allow closing by clicking left side */}
                 <div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300 pointer-events-auto">
@@ -89,6 +90,7 @@ export const InputDrawer: React.FC<InputDrawerProps> = ({
                     </form>
                 </div>
             </div>
-        </div>
+        </div>,
+        document.body
     );
 };

+ 47 - 72
web/components/NotebookDragDropUpload.tsx

@@ -1,15 +1,17 @@
 import React, { useCallback, useState } from 'react';
-import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder } from 'lucide-react';
+import { Upload as UploadIcon, FileText, Image as ImageIcon, Folder, FileUp, ShieldCheck } from 'lucide-react';
 import { useLanguage } from '../contexts/LanguageContext';
 import { GROUP_ALLOWED_EXTENSIONS, IMAGE_MIME_TYPES } from '../constants/fileSupport';
+import { motion, AnimatePresence } from 'framer-motion';
 
 interface NotebookDragDropUploadProps {
   onFilesSelected: (files: FileList) => void;
   isAdmin: boolean;
   globalMode?: boolean;
+  children?: React.ReactNode;
 }
 
-export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({ onFilesSelected, isAdmin, globalMode = false }) => {
+export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({ onFilesSelected, isAdmin, globalMode = false, children }) => {
   const { t } = useLanguage();
   const [isDragging, setIsDragging] = useState(false);
 
@@ -24,8 +26,7 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
   const handleDragLeave = useCallback((e: React.DragEvent) => {
     e.preventDefault();
     e.stopPropagation();
-    // マウスが実際にドラッグ領域を離れた場合のみfalseに設定
-    setTimeout(() => setIsDragging(false), 100);
+    setIsDragging(false);
   }, []);
 
   const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -48,83 +49,57 @@ export const NotebookDragDropUpload: React.FC<NotebookDragDropUploadProps> = ({
   const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
     if (e.target.files && e.target.files.length > 0) {
       onFilesSelected(e.target.files);
-      // Reset the input so the same file can be selected again
       e.target.value = '';
     }
   };
 
-  if (!isAdmin) {
-    return null;
-  }
-
-  // モードに応じて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'
-    }`;
-
-  const contentClass = globalMode
-    ? "w-3/4 max-w-2xl"
-    : "";
+  if (!isAdmin) return <>{children}</>;
 
   return (
-    <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'
-            }`}
-          onDragEnter={handleDragEnter}
-          onDragOver={handleDragOver}
-          onDragLeave={handleDragLeave}
-          onDrop={handleDrop}
-          onClick={() => document.getElementById('notebook-file-upload-input')?.click()}
-        >
-          <div className="flex flex-col items-center justify-center gap-6">
-            <div className="p-4 bg-blue-100 rounded-full">
-              <UploadIcon className="w-10 h-10 text-blue-600" />
-            </div>
-            <div className="space-y-2">
-              <h3 className="text-lg font-semibold text-slate-700">
-                {t('dragDropUploadTitle')}
-              </h3>
-              <p className="text-sm text-slate-500">
-                {t('dragDropUploadDesc')}
-              </p>
-            </div>
-            <div className="flex items-center gap-6 mt-2">
-              <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">
-                <FileText className="w-4 h-4" />
-                <span>{t('supportedFormats')}</span>
+    <div className="relative h-full flex flex-col">
+      <AnimatePresence>
+        {isDragging && (
+          <motion.div
+            initial={{ opacity: 0 }}
+            animate={{ opacity: 1 }}
+            exit={{ opacity: 0 }}
+            className="absolute inset-0 z-[100] bg-blue-600/10 backdrop-blur-sm flex items-center justify-center p-8 pointer-events-none"
+          >
+            <motion.div
+              initial={{ scale: 0.9 }}
+              animate={{ scale: 1 }}
+              className="bg-white rounded-[2rem] p-10 text-center shadow-2xl border border-blue-100 flex flex-col items-center gap-6"
+            >
+              <div className="w-16 h-16 bg-blue-600 text-white rounded-2xl flex items-center justify-center animate-bounce">
+                <FileUp size={32} />
               </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>{GROUP_ALLOWED_EXTENSIONS.slice(0, 10).join(', ').toUpperCase()}...</span>
+              <div className="space-y-1">
+                <h3 className="text-xl font-bold text-slate-900">Ingest into Group</h3>
+                <p className="text-slate-500 font-medium text-sm">Release to start processing</p>
               </div>
-            </div>
-            <div className="pt-2">
-              <button
-                type="button"
-                className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-sm"
-              >
-                <Folder className="w-4 h-4" />
-                {t('browseFiles')}
-              </button>
-              <input
-                type="file"
-                multiple
-                onChange={handleFileInput}
-                className="hidden"
-                id="notebook-file-upload-input"
-                accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
-              />
-            </div>
-          </div>
-        </div>
+            </motion.div>
+          </motion.div>
+        )}
+      </AnimatePresence>
+
+      <div
+        className="flex-1 flex flex-col"
+        onDragEnter={handleDragEnter}
+        onDragOver={handleDragOver}
+        onDragLeave={handleDragLeave}
+        onDrop={handleDrop}
+      >
+        {children}
       </div>
+
+      <input
+        type="file"
+        multiple
+        onChange={handleFileInput}
+        className="hidden"
+        id="notebook-file-upload-input"
+        accept={GROUP_ALLOWED_EXTENSIONS.map(ext => `.${ext}`).join(',') + ',' + IMAGE_MIME_TYPES.join(',')}
+      />
     </div>
   );
 };

+ 53 - 131
web/components/NotebookGlobalDragDropOverlay.tsx

@@ -1,21 +1,19 @@
 import { useLayoutEffect, useRef, useState, useCallback } from 'react';
 import { useLanguage } from '../contexts/LanguageContext';
 import { GROUP_ALLOWED_EXTENSIONS } from '../constants/fileSupport';
+import { motion, AnimatePresence } from 'framer-motion';
+import { FileUp, ShieldCheck, FileText, Image as ImageIcon } from 'lucide-react';
 
 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();
   }
@@ -30,103 +28,53 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
 
   const hasFiles = useCallback((dt: DataTransfer | null) => {
     if (!dt) return false;
-
-    // より厳格なチェック: typesにFilesが含まれることを確認
     const hasFileType = dt.types && dt.types.includes('Files');
     if (!hasFileType) return false;
-
-    // itemsが存在する場合、実際にファイルがあるかチェック
     if (dt.items && dt.items.length > 0) {
-      // 少なくとも1つのファイルアイテムがあることを確認
       for (let i = 0; i < dt.items.length; i++) {
-        if (dt.items[i].kind === 'file') {
-          return true;
-        }
+        if (dt.items[i].kind === 'file') return true;
       }
       return false;
     }
-
-    // itemsがない場合はtypesのみで判断(後方互換性)
     return hasFileType;
   }, []);
 
   const handleDragEnter = useCallback((e: DragEvent) => {
-    // ドラッグドロップ機能が無効な場合はイベントを無視
-    if (!isNotebookDragDropEnabled) return;
-
-    // データ転送にファイルが含まれている場合のみ処理
-    if (!hasFiles(e.dataTransfer)) return;
-
+    if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
-
     dragCounterRef.current++;
     if (dragCounterRef.current === 1) {
-      // 直ちにオーバーレイを表示し、誤作動は強制非表示メカニズムに依存
       setIsVisible(true);
-      if (overlayRef.current) {
-        overlayRef.current.style.opacity = '1';
-        overlayRef.current.style.visibility = 'visible';
-      }
       isDragActiveRef.current = true;
     }
   }, [hasFiles]);
 
   const handleDragOver = useCallback((e: DragEvent) => {
-    // ドラッグドロップ機能が無効な場合はイベントを無視
-    if (!isNotebookDragDropEnabled) return;
-
-    // データ転送にファイルが含まれている場合のみ処理
-    if (!hasFiles(e.dataTransfer)) return;
-
+    if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
     e.dataTransfer!.dropEffect = 'copy';
   }, [hasFiles]);
 
   const handleDragLeave = useCallback((e: DragEvent) => {
-    // ドラッグドロップ機能が無効な場合はイベントを無視
-    if (!isNotebookDragDropEnabled) return;
-
-    // データ転送にファイルが含まれている場合のみ処理
-    if (!hasFiles(e.dataTransfer)) return;
-
+    if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
-
     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;
-
+    if (!isNotebookDragDropEnabled || !hasFiles(e.dataTransfer)) return;
     e.preventDefault();
     e.stopPropagation();
     dragCounterRef.current = 0;
-
-    // 全域ドラッグアップロードオーバーレイを非表示にする
-    if (overlayRef.current) {
-      overlayRef.current.style.opacity = '0';
-      overlayRef.current.style.visibility = 'hidden';
-    }
     setIsVisible(false);
-
     isDragActiveRef.current = false;
-
-    // ファイルを処理
     if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
       onFilesSelected(e.dataTransfer.files);
     }
@@ -134,104 +82,78 @@ export const NotebookGlobalDragDropOverlay: React.FC<NotebookGlobalDragDropProps
 
   useLayoutEffect(() => {
     if (!isAdmin) return;
-
-    // 初期化時にdragCounterとisDragActiveが初期値であることを確認
     dragCounterRef.current = 0;
     isDragActiveRef.current = false;
-
-    // 強制非表示コールバックを登録
     notebookForceHideCallback = () => {
       dragCounterRef.current = 0;
       isDragActiveRef.current = false;
       setIsVisible(false);
-      if (overlayRef.current) {
-        overlayRef.current.style.opacity = '0';
-        overlayRef.current.style.visibility = 'hidden';
-      }
     };
-
-    // 全域イベントリスナーを追加
     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) {
-        overlayRef.current.style.opacity = '0';
-        overlayRef.current.style.visibility = 'hidden';
-      }
       setIsVisible(false);
     };
   }, [isAdmin, handleDragEnter, handleDragOver, handleDragLeave, handleDrop]);
 
-  // ランタイムチェックを追加し、適切な場合のみレンダリングすることを保証
-  if (!isAdmin || typeof window === 'undefined') {
-    return null;
-  }
-
-  // isVisible が true の場合のみコンポーネントの内容をレンダリング
-  if (!isVisible) {
-    return null;
-  }
+  if (!isAdmin || typeof window === 'undefined') return null;
 
   return (
-    <div
-      ref={overlayRef}
-      id="notebook-global-drag-overlay"
-      className="fixed inset-0 bg-black bg-opacity-50 items-center justify-center z-50 transition-opacity duration-300 pointer-events-none"
-      style={{ opacity: 1, visibility: 'visible', display: 'flex' }}
-    >
-      <div className="w-3/4 max-w-2xl pointer-events-auto">
-        <div className="border-2 border-dashed border-blue-500 bg-blue-50 rounded-xl p-8 text-center cursor-pointer">
-          <div className="flex flex-col items-center justify-center gap-6">
-            <div className="p-4 bg-blue-100 rounded-full">
-              <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-10 h-10 text-blue-600">
-                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
-                <polyline points="17 8 12 3 7 8"></polyline>
-                <line x1="12" x2="12" y1="3" y2="15"></line>
-              </svg>
-            </div>
-            <div className="space-y-2">
-              <h3 className="text-lg font-semibold text-slate-700">
-                {t('dragDropUploadTitle')}
-              </h3>
-              <p className="text-sm text-slate-500">
-                {t('dragDropUploadDesc')}
-              </p>
-            </div>
-            <div className="flex items-center gap-6 mt-2">
-              <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">
-                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
-                  <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
-                  <polyline points="14 2 14 8 20 8"></polyline>
-                </svg>
-                <span>{t('supportedFormats')}</span>
+    <AnimatePresence>
+      {isVisible && (
+        <motion.div
+          initial={{ opacity: 0 }}
+          animate={{ opacity: 1 }}
+          exit={{ opacity: 0 }}
+          className="fixed inset-0 bg-blue-600/10 backdrop-blur-md items-center justify-center z-[9999] pointer-events-none flex p-8"
+        >
+          <motion.div
+            initial={{ scale: 0.9, y: 20 }}
+            animate={{ scale: 1, y: 0 }}
+            exit={{ scale: 0.9, y: 20 }}
+            className="w-full max-w-2xl bg-white rounded-[2.5rem] p-12 text-center shadow-[0_32px_64px_-12px_rgba(0,0,0,0.14)] border border-white pointer-events-auto"
+          >
+            <div className="flex flex-col items-center justify-center gap-8">
+              <div className="w-24 h-24 bg-blue-600 text-white rounded-3xl flex items-center justify-center shadow-xl shadow-blue-200 animate-bounce">
+                <FileUp size={48} />
               </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">
-                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
-                  <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
-                  <circle cx="8.5" cy="8.5" r="1.5"></circle>
-                  <path d="M21 15l-5-5L5 21"></path>
-                </svg>
-                <span>{GROUP_ALLOWED_EXTENSIONS.slice(0, 10).join(', ').toUpperCase()}...</span>
+
+              <div className="space-y-3">
+                <h3 className="text-3xl font-black text-slate-900 tracking-tight">
+                  {t('dragDropUploadTitle')}
+                </h3>
+                <p className="text-lg text-slate-500 font-medium">
+                  Release to ingest files into this Group
+                </p>
+              </div>
+
+              <div className="flex flex-wrap items-center justify-center gap-6 py-8 border-y border-slate-100 w-full">
+                <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
+                  <ShieldCheck size={20} className="text-emerald-500" />
+                  <span>Group Specific</span>
+                </div>
+                <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
+                  <FileText size={20} className="text-blue-500" />
+                  <span>{GROUP_ALLOWED_EXTENSIONS.slice(0, 3).join(', ').toUpperCase()}...</span>
+                </div>
+              </div>
+
+              <div className="text-slate-400 font-bold text-xs uppercase tracking-[0.3em]">
+                Drop anywhere to begin
               </div>
             </div>
-          </div>
-        </div>
-      </div>
-    </div>
+          </motion.div>
+        </motion.div>
+      )}
+    </AnimatePresence>
   );
 };

+ 1 - 1
web/components/PDFPreview.tsx

@@ -549,7 +549,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={`fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-4'
       }`}>
       <div className={`bg-white rounded-lg overflow-hidden flex flex-col ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
         }`}>

+ 1 - 1
web/components/PDFSelectionTool.tsx

@@ -429,7 +429,7 @@ export const PDFSelectionTool: React.FC<PDFSelectionToolProps> = ({
                 ref={overlayCanvasRef}
                 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]">
+            <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-4 py-2 rounded-lg text-sm z-[60]">
                 {t('dragToSelect')}
             </div>
         </div>

+ 5 - 3
web/components/SettingsDrawer.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import { createPortal } from 'react-dom';
 import ConfigPanel from './ConfigPanel';
 import { AppSettings, ModelConfig } from '../types';
 import { X } from 'lucide-react';
@@ -46,9 +47,9 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
         onClose();
     };
 
-    return (
+    return createPortal(
         <div className="fixed inset-0 z-50 overflow-hidden">
-            <div className="absolute inset-0 bg-black bg-opacity-30 transition-opacity" onClick={onClose} />
+            <div className="absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity" onClick={onClose} />
             <div className="absolute inset-y-0 right-0 max-w-md w-full flex">
                 <div className="flex-1 flex flex-col bg-white shadow-xl animate-in slide-in-from-right duration-300">
                     <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
@@ -80,6 +81,7 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
                     </div>
                 </div>
             </div>
-        </div>
+        </div>,
+        document.body
     );
 };

+ 16 - 6
web/components/SettingsModal.tsx

@@ -54,6 +54,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
         id: string;
         username: string;
         isAdmin: boolean;
+        role?: string;
         createdAt: string;
     }
     const [users, setUsers] = useState<UserType[]>([]);
@@ -70,6 +71,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
     const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
     const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
     const [isSettingsLoading, setIsSettingsLoading] = useState(false);
+    const [currentUser, setCurrentUser] = useState<UserType | null>(null);
 
     // Reset state on open
     useEffect(() => {
@@ -95,12 +97,18 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
         if (!authToken) return;
         setIsSettingsLoading(true);
         try {
-            const [settings, groups] = await Promise.all([
+            const [settings, groups, users] = await Promise.all([
                 userSettingService.get(authToken),
-                knowledgeGroupService.getGroups()
+                knowledgeGroupService.getGroups(),
+                userService.getUsers().catch(() => []) // Regular users might fail this
             ]);
             setAppSettings(settings);
             setKnowledgeGroups(groups);
+
+            // Temporary way to get current user details since we lack a /me endpoint hook here
+            const tokenPayload = JSON.parse(atob(authToken.split('.')[1]));
+            const me = users.find(u => u.id === tokenPayload.sub) || { isAdmin: tokenPayload.role === 'SUPER_ADMIN' || tokenPayload.role === 'TENANT_ADMIN', role: tokenPayload.role };
+            setCurrentUser(me as any);
         } catch (error) {
             console.error('Failed to fetch settings or groups:', error);
         } finally {
@@ -501,9 +509,11 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
                         <button onClick={() => setActiveTab('user')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'user' ? 'bg-white text-purple-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
                             <User className="w-5 h-5" /> {t('userManagement')}
                         </button>
-                        <button onClick={() => setActiveTab('model')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'model' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
-                            <Cpu className="w-5 h-5" /> {t('modelManagement')}
-                        </button>
+                        {currentUser?.role !== 'USER' && (
+                            <button onClick={() => setActiveTab('model')} className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${activeTab === 'model' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-600 hover:bg-slate-100'}`}>
+                                <Cpu className="w-5 h-5" /> {t('modelManagement')}
+                            </button>
+                        )}
                     </nav>
                 </div>
 
@@ -524,7 +534,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
                         )}
                         {activeTab === 'general' && renderGeneralTab()}
                         {activeTab === 'user' && renderUserTab()}
-                        {activeTab === 'model' && renderModelTab()}
+                        {activeTab === 'model' && currentUser?.role !== 'USER' && renderModelTab()}
                     </div>
                 </div>
             </div>

Некоторые файлы не были показаны из-за большого количества измененных файлов