anhuiqiang 2 недель назад
Родитель
Сommit
81634421a2
40 измененных файлов с 857 добавлено и 320 удалено
  1. 7 0
      .gitignore
  2. 9 6
      server/src/auth/admin.guard.ts
  3. 16 6
      server/src/auth/combined-auth.guard.ts
  4. 10 24
      server/src/chat/chat.controller.ts
  5. 27 49
      server/src/chat/chat.service.ts
  6. 3 0
      server/src/common/constants.ts
  7. 3 0
      server/src/defaults.ts
  8. 5 3
      server/src/import-task/import-task.controller.ts
  9. 22 4
      server/src/knowledge-base/chunk-config.service.ts
  10. 1 0
      server/src/knowledge-base/knowledge-base.controller.ts
  11. 2 0
      server/src/knowledge-base/knowledge-base.module.ts
  12. 18 9
      server/src/knowledge-base/knowledge-base.service.ts
  13. 1 1
      server/src/model-config/model-config.controller.ts
  14. 8 5
      server/src/model-config/model-config.module.ts
  15. 50 19
      server/src/model-config/model-config.service.ts
  16. 17 1
      server/src/note/note.service.ts
  17. 30 2
      server/src/super-admin/super-admin.controller.ts
  18. 6 0
      server/src/tenant/tenant-setting.entity.ts
  19. 23 0
      server/src/tenant/tenant.controller.ts
  20. 26 4
      server/src/tenant/tenant.service.ts
  21. 3 0
      server/src/types.ts
  22. 6 4
      server/src/upload/upload.controller.ts
  23. 8 0
      server/src/user-setting/dto/create-user-setting.dto.ts
  24. 15 3
      server/src/user-setting/user-setting.controller.ts
  25. 6 0
      server/src/user-setting/user-setting.entity.ts
  26. 3 2
      server/src/user-setting/user-setting.module.ts
  27. 1 0
      server/src/user-setting/user-setting.service.ts
  28. 17 1
      server/src/user/user.service.ts
  29. 3 1
      web/components/IndexingModalWithMode.tsx
  30. 1 1
      web/components/PDFPreview.tsx
  31. 17 19
      web/components/views/ChatView.tsx
  32. 41 36
      web/components/views/KnowledgeBaseView.tsx
  33. 30 27
      web/components/views/NotebookDetailView.tsx
  34. 344 51
      web/components/views/SettingsView.tsx
  35. 8 28
      web/services/pdfPreviewService.ts
  36. 15 0
      web/services/userSettingService.ts
  37. 8 10
      web/src/components/layouts/WorkspaceLayout.tsx
  38. 23 2
      web/src/pages/workspace/ChatPage.tsx
  39. 19 2
      web/src/pages/workspace/KnowledgePage.tsx
  40. 5 0
      web/types.ts

+ 7 - 0
.gitignore

@@ -47,3 +47,10 @@ coverage
 analyze_translations.py
 web/dist-check/
 web2
+db_output_utf8.json
+db_output.json
+server/check_db_v2.js
+server/check_models.js
+server/aurak.sqlite
+server/models_list.json
+server/models_status.json

+ 9 - 6
server/src/auth/admin.guard.ts

@@ -1,15 +1,18 @@
 import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
-import { Observable } from 'rxjs';
+import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class AdminGuard implements CanActivate {
-  canActivate(
-    context: ExecutionContext,
-  ): boolean | Promise<boolean> | Observable<boolean> {
+  canActivate(context: ExecutionContext): boolean {
     const request = context.switchToHttp().getRequest();
     const user = request.user;
 
-    // Check if user exists and has admin privileges
-    return user && user.isAdmin === true;
+    // Check if user exists and has admin privileges (Super Admin or Tenant Admin)
+    return !!(
+      user &&
+      (user.role === UserRole.SUPER_ADMIN ||
+        user.role === UserRole.TENANT_ADMIN ||
+        user.isAdmin === true)
+    );
   }
 }

+ 16 - 6
server/src/auth/combined-auth.guard.ts

@@ -3,6 +3,7 @@ import { Reflector } from '@nestjs/core';
 import { AuthGuard } from '@nestjs/passport';
 import { UserService } from '../user/user.service';
 import { Request } from 'express';
+import { lastValueFrom, Observable } from 'rxjs';
 import { IS_PUBLIC_KEY } from './public.decorator';
 import { tenantStore } from '../tenant/tenant.store';
 
@@ -49,12 +50,11 @@ export class CombinedAuthGuard implements CanActivate {
                 if (requestedTenantId) {
                     const memberships = await this.userService.getUserTenants(user.id);
                     const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
-                    if (hasAccess) {
+
+                    if (hasAccess || user.role === 'SUPER_ADMIN') {
                         activeTenantId = requestedTenantId;
-                    } else if (user.role !== 'SUPER_ADMIN') {
-                        throw new UnauthorizedException('User does not belong to the requested tenant');
                     } else {
-                        activeTenantId = requestedTenantId; // Super Admin can access any tenant
+                        throw new UnauthorizedException('User does not belong to the requested tenant');
                     }
                 }
 
@@ -80,13 +80,22 @@ export class CombinedAuthGuard implements CanActivate {
 
         // --- Fall back to JWT ---
         try {
-            const hasJwtSession = await (this.jwtGuard as any).canActivate(context);
+            const result = await (this.jwtGuard as any).canActivate(context);
+            let hasJwtSession = false;
+
+            if (result instanceof Observable) {
+                hasJwtSession = await lastValueFrom(result);
+            } else {
+                hasJwtSession = result;
+            }
+
             if (hasJwtSession) {
                 const user = request.user;
+                if (!user) return false;
+
                 const requestedTenantId = request.headers['x-tenant-id'] as string;
 
                 if (requestedTenantId && user.tenantId !== requestedTenantId) {
-                    // Refresh membership check for JWT users if switching tenant
                     const memberships = await this.userService.getUserTenants(user.id);
                     const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
 
@@ -110,6 +119,7 @@ export class CombinedAuthGuard implements CanActivate {
             }
             return false;
         } catch (e) {
+            console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
             throw e instanceof UnauthorizedException ? e : new UnauthorizedException('Authentication required');
         }
     }

+ 10 - 24
server/src/chat/chat.controller.ts

@@ -11,6 +11,7 @@ import { ChatMessage, ChatService } from './chat.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { TenantService } from '../tenant/tenant.service';
+import { ModelType } from '../types';
 
 class StreamChatDto {
   message: string;
@@ -84,21 +85,15 @@ export class ChatController {
 
       let llmModel;
       if (selectedLLMId) {
-        llmModel = models.find(m => m.id === selectedLLMId && m.type === 'llm');
-        if (llmModel) {
-          console.log('使用选中的LLM模型:', llmModel.name);
-        } else {
-          console.warn('未找到选中的LLM模型:', selectedLLMId, '回退到默认');
-        }
+        // Find specifically selected model
+        llmModel = await this.modelConfigService.findOne(selectedLLMId, userId, tenantId);
+        console.log('使用选中的LLM模型:', llmModel.name);
+      } else {
+        // Use organization's default LLM from Index Chat Config (strict)
+        llmModel = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
+        console.log('最终使用的LLM模型 (默认):', llmModel ? llmModel.name : '无');
       }
 
-      // Fallback: 仅在未选择时尝试获取标记为默认的模型
-      if (!llmModel && selectedLLMId === undefined) {
-        llmModel = models.find((m) => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
-      }
-
-      console.log('最终使用的LLM模型:', llmModel ? llmModel.name : '无');
-
       // 设置 SSE 响应头
       res.setHeader('Content-Type', 'text/event-stream');
       res.setHeader('Cache-Control', 'no-cache');
@@ -168,17 +163,8 @@ export class ChatController {
       const tenantId = req.user.tenantId;
       const role = req.user.role;
 
-      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);
+      // Use organization's default LLM from Index Chat Config (strict)
+      const llmModel = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
 
       res.setHeader('Content-Type', 'text/event-stream');
       res.setHeader('Cache-Control', 'no-cache');

+ 27 - 49
server/src/chat/chat.service.ts

@@ -7,7 +7,7 @@ import { EmbeddingService } from '../knowledge-base/embedding.service';
 import { ModelConfigService } from '../model-config/model-config.service';
 import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
 import { SearchHistoryService } from '../search-history/search-history.service';
-import { ModelConfig } from '../types';
+import { ModelConfig, ModelType } from '../types';
 import { RagService } from '../rag/rag.service';
 
 import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
@@ -109,36 +109,14 @@ export class ChatService {
       // ユーザーメッセージを保存
       await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
       // 1. ユーザーの埋め込みモデル設定を取得
-      const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
+      let embeddingModel: any;
 
-      // ユーザーが選択した埋め込みモデルIDを優先し、そうでない場合は最初のものを使用
-      let embeddingModel;
       if (selectedEmbeddingId) {
-        embeddingModel = models.find(
-          (m) =>
-            m.id === selectedEmbeddingId &&
-            m.type === 'embedding' &&
-            m.isEnabled !== false,
-        );
-        console.log(
-          'selectedEmbeddingId に基づいてモデルを検索:',
-          selectedEmbeddingId,
-        );
-        console.log(this.i18nService.getMessage('searchingModelById', effectiveUserLanguage) + selectedEmbeddingId);
-      }
-
-      // 見つからない場合は、デフォルトの埋め込みモデルに戻る
-      if (!embeddingModel && selectedEmbeddingId === undefined) {
-        console.log('デフォルトの埋め込みモデルを検索中...');
-        embeddingModel = models.find(
-          (m) => m.type === 'embedding' && m.isDefault && m.isEnabled !== false,
-        );
-      }
-
-      if (!embeddingModel) {
-        console.log(this.i18nService.getMessage('noEmbeddingModelFound', effectiveUserLanguage));
-        yield { type: 'content', data: this.i18nService.getMessage('noEmbeddingModel', effectiveUserLanguage) };
-        return;
+        // Find specifically selected model
+        embeddingModel = await this.modelConfigService.findOne(selectedEmbeddingId, userId, tenantId || 'default');
+      } else {
+        // Use organization's default from Index Chat Config (strict)
+        embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
       }
 
       console.log(this.i18nService.getMessage('usingEmbeddingModel', effectiveUserLanguage) + embeddingModel.name + ' ' + embeddingModel.modelId + ' ID:' + embeddingModel.id);
@@ -218,7 +196,11 @@ export class ChatService {
       }
 
       // 5. ストリーム回答生成
-      this.logger.log(`${this.i18nService.getMessage('modelCall', effectiveUserLanguage)} タイプ: LLM, モデル: ${modelConfig.name} (${modelConfig.modelId}), ユーザー: ${userId}`);
+      this.logger.log(this.i18nService.formatMessage('modelCall', {
+        type: 'LLM',
+        model: `${modelConfig.name} (${modelConfig.modelId})`,
+        user: userId
+      }, effectiveUserLanguage));
       const llm = new ChatOpenAI({
         apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
@@ -302,7 +284,11 @@ export class ChatService {
     modelConfig: ModelConfig,
   ): AsyncGenerator<{ type: 'content'; data: any }> {
     try {
-      this.logger.log(`${this.i18nService.getMessage('modelCall', 'ja')} タイプ: LLM (Assist), モデル: ${modelConfig.name} (${modelConfig.modelId})`);
+      this.logger.log(this.i18nService.formatMessage('modelCall', {
+        type: 'LLM (Assist)',
+        model: `${modelConfig.name} (${modelConfig.modelId})`,
+        user: 'N/A'
+      }, 'ja'));
       const llm = new ChatOpenAI({
         apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
@@ -412,11 +398,8 @@ ${instruction}`;
   }
   async getContextForTopic(topic: string, userId: string, tenantId?: string, groupId?: string, fileIds?: string[]): Promise<string> {
     try {
-      const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
-
-      // デフォルトの埋め込みモデルを優先
-      const embeddingModel = models.find(m => m.type === 'embedding' && m.isDefault && m.isEnabled !== false);
-      if (!embeddingModel) return '';
+      // Use organization's default embedding from Index Chat Config (strict)
+      const embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
 
       const results = await this.hybridSearch(
         [topic],
@@ -443,21 +426,16 @@ ${instruction}`;
     try {
       let config = modelConfig;
       if (!config) {
-        // Find default LLM
-        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);
-        if (found) {
-          config = found as unknown as ModelConfig;
-        }
-
-        if (!config) {
-          throw new Error(this.i18nService.getMessage('noLLMConfigured', 'ja'));
-        }
+        // Use organization's default LLM from Index Chat Config (strict)
+        const found = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.LLM);
+        config = found as unknown as ModelConfig;
       }
 
-      this.logger.log(`${this.i18nService.getMessage('modelCall', 'ja')} タイプ: LLM (Simple), モデル: ${config.name} (${config.modelId}), ユーザー: ${userId}`);
+      this.logger.log(this.i18nService.formatMessage('modelCall', {
+        type: 'LLM (Simple)',
+        model: `${config.name} (${config.modelId})`,
+        user: userId
+      }, 'ja'));
       const settings = await this.userSettingService.findOrCreate(userId);
       const llm = new ChatOpenAI({
         apiKey: config.apiKey || 'ollama',

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

@@ -21,3 +21,6 @@ export const DEFAULT_MAX_BATCH_SIZE = 2048;
 
 // デフォルト言語
 export const DEFAULT_LANGUAGE = 'ja';
+
+// システム全体の共通テナントID(シードデータなどで使用)
+export const GLOBAL_TENANT_ID = '00000000-0000-0000-0000-000000000000';

+ 3 - 0
server/src/defaults.ts

@@ -21,5 +21,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
   enableQueryExpansion: false,
   enableHyDE: false,
 
+  chunkSize: 1000,
+  chunkOverlap: 100,
+
   language: 'ja',
 };

+ 5 - 3
server/src/import-task/import-task.controller.ts

@@ -1,15 +1,17 @@
 import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common';
 import { ImportTaskService } from './import-task.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
-import { AdminGuard } from '../auth/admin.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user-role.enum';
 
 @Controller('import-tasks')
-@UseGuards(CombinedAuthGuard)
+@UseGuards(CombinedAuthGuard, RolesGuard)
 export class ImportTaskController {
     constructor(private readonly taskService: ImportTaskService) { }
 
     @Post()
-    @UseGuards(AdminGuard)  // Only admin users can create import tasks
+    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
     async create(@Request() req, @Body() body: any) {
         return this.taskService.create({
             sourcePath: body.sourcePath,

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

@@ -1,6 +1,8 @@
 import { Injectable, Logger, BadRequestException } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { ModelConfigService } from '../model-config/model-config.service';
+import { TenantService } from '../tenant/tenant.service';
+import { UserSettingService } from '../user-setting/user-setting.service';
 
 /**
  * チャンク設定サービス
@@ -45,6 +47,8 @@ export class ChunkConfigService {
     private configService: ConfigService,
     private modelConfigService: ModelConfigService,
     private i18nService: I18nService,
+    private tenantService: TenantService,
+    private userSettingService: UserSettingService,
   ) {
     // 環境変数からグローバルな上限設定を読み込む
     this.envMaxChunkSize = parseInt(
@@ -69,7 +73,7 @@ export class ChunkConfigService {
     providerName: string;
     isVectorModel: boolean;
   }> {
-    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
+    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || '');
 
     if (!modelConfig || modelConfig.type !== 'embedding') {
       throw new BadRequestException(this.i18nService.formatMessage('embeddingModelNotFound', { id: modelId }));
@@ -346,15 +350,29 @@ export class ChunkConfigService {
     );
 
     // モデル設定名を取得
-    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || 'default');
+    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || '');
     const modelName = modelConfig?.name || 'Unknown';
 
+    // テナントまたはユーザー設定からデフォルト値を取得
+    let defaultChunkSize = this.DEFAULTS.chunkSize;
+    let defaultOverlapSize = this.DEFAULTS.chunkOverlap;
+
+    if (tenantId) {
+      const tenantSettings = await this.tenantService.getSettings(tenantId);
+      if (tenantSettings.chunkSize) defaultChunkSize = tenantSettings.chunkSize;
+      if (tenantSettings.chunkOverlap) defaultOverlapSize = tenantSettings.chunkOverlap;
+    } else {
+      const userSettings = await this.userSettingService.findOrCreate(userId);
+      if (userSettings.chunkSize) defaultChunkSize = userSettings.chunkSize;
+      if (userSettings.chunkOverlap) defaultOverlapSize = userSettings.chunkOverlap;
+    }
+
     return {
       maxChunkSize,
       maxOverlapSize,
       minOverlapSize: this.DEFAULTS.minChunkOverlap,
-      defaultChunkSize: Math.min(this.DEFAULTS.chunkSize, maxChunkSize),
-      defaultOverlapSize: Math.max(this.DEFAULTS.minChunkOverlap, Math.min(this.DEFAULTS.chunkOverlap, maxOverlapSize)),
+      defaultChunkSize: Math.min(defaultChunkSize, maxChunkSize),
+      defaultOverlapSize: Math.max(this.DEFAULTS.minChunkOverlap, Math.min(defaultOverlapSize, maxOverlapSize)),
       modelInfo: {
         name: modelName,
         maxInputTokens: limits.maxInputTokens,

+ 1 - 0
server/src/knowledge-base/knowledge-base.controller.ts

@@ -130,6 +130,7 @@ export class KnowledgeBaseController {
     return await this.chunkConfigService.getFrontendLimits(
       embeddingModelId,
       req.user.id,
+      req.user.tenantId,
     );
   }
 

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

@@ -19,6 +19,7 @@ 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 { TenantModule } from '../tenant/tenant.module';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 
 @Module({
@@ -36,6 +37,7 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
     forwardRef(() => KnowledgeGroupModule),
     forwardRef(() => ChatModule),
     UserModule,
+    TenantModule,
   ],
   controllers: [KnowledgeBaseController],
   providers: [

+ 18 - 9
server/src/knowledge-base/knowledge-base.service.ts

@@ -120,9 +120,11 @@ export class KnowledgeBaseService {
   }
 
   async findAll(userId: string, tenantId?: string): Promise<KnowledgeBase[]> {
-    const where: any = { userId };
+    const where: any = {};
     if (tenantId) {
       where.tenantId = tenantId;
+    } else {
+      where.userId = userId;
     }
     return this.kbRepository.find({
       where,
@@ -148,7 +150,7 @@ export class KnowledgeBaseService {
         queryVector,
         userId,
         topK,
-        tenantId, // Ensure tenant isolation in ES
+        tenantId, // Ensure shared visibility within tenant
       );
 
       // 3. Get file information from database
@@ -180,8 +182,8 @@ export class KnowledgeBaseService {
       };
     } catch (error) {
       this.logger.error(
-        this.i18nService.formatMessage('searchMetadataFailed', { userId }),
-        error,
+        `Metadata search failed for tenant ${tenantId}:`,
+        error.stack || error.message,
       );
       throw error;
     }
@@ -205,7 +207,7 @@ export class KnowledgeBaseService {
         undefined,
         undefined,
         settings.rerankSimilarityThreshold,
-        tenantId, // Ensure tenant isolation in RAG
+        tenantId, // Ensure shared visibility within tenant for RAG
       );
 
       const sources = this.ragService.extractSources(ragResults);
@@ -1417,10 +1419,17 @@ export class KnowledgeBaseService {
       const prompt = this.i18nService.getDocumentTitlePrompt(language, contentSample);
 
       // LLMを呼び出してタイトルを生成
-      const generatedTitle = await this.chatService.generateSimpleChat(
-        [{ role: 'user', content: prompt }],
-        kb.userId
-      );
+      let generatedTitle: string | undefined;
+      try {
+        generatedTitle = await this.chatService.generateSimpleChat(
+          [{ role: 'user', content: prompt }],
+          kb.userId,
+          kb.tenantId
+        );
+      } catch (err) {
+        this.logger.warn(`Failed to generate title for document ${kbId} due to LLM configuration issue: ${err.message}`);
+        return null; // Skip title generation if LLM is not configured for this tenant
+      }
 
       if (generatedTitle && generatedTitle.trim().length > 0) {
         // 余分な引用符や改行を除去

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

@@ -54,7 +54,7 @@ export class ModelConfigController {
     @Req() req,
     @Param('id') id: string,
   ): Promise<ModelConfigResponseDto> {
-    const modelConfig = await this.modelConfigService.findOne(req.user.id, id, req.user.tenantId);
+    const modelConfig = await this.modelConfigService.findOne(id, req.user.id, req.user.tenantId);
     return plainToClass(ModelConfigResponseDto, modelConfig);
   }
 

+ 8 - 5
server/src/model-config/model-config.module.ts

@@ -1,14 +1,17 @@
-// server/src/model-config/model-config.module.ts
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { ModelConfig } from './model-config.entity';
 import { ModelConfigService } from './model-config.service';
 import { ModelConfigController } from './model-config.controller';
+import { TenantModule } from '../tenant/tenant.module';
 
 @Module({
-  imports: [TypeOrmModule.forFeature([ModelConfig])],
+  imports: [
+    TypeOrmModule.forFeature([ModelConfig]),
+    forwardRef(() => TenantModule),
+  ],
   providers: [ModelConfigService],
   controllers: [ModelConfigController],
-  exports: [ModelConfigService], // Export if other modules need to use ModelConfigService
+  exports: [ModelConfigService],
 })
-export class ModelConfigModule {}
+export class ModelConfigModule { }

+ 50 - 19
server/src/model-config/model-config.service.ts

@@ -1,17 +1,20 @@
-// server/src/model-config/model-config.service.ts
-import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
+import { Injectable, NotFoundException, ForbiddenException, BadRequestException, forwardRef, Inject } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { ModelConfig } from './model-config.entity';
 import { CreateModelConfigDto } from './dto/create-model-config.dto';
 import { UpdateModelConfigDto } from './dto/update-model-config.dto';
-import { User } from '../user/user.entity';
+import { GLOBAL_TENANT_ID } from '../common/constants';
+import { TenantService } from '../tenant/tenant.service';
+import { ModelType } from '../types';
 
 @Injectable()
 export class ModelConfigService {
   constructor(
     @InjectRepository(ModelConfig)
     private modelConfigRepository: Repository<ModelConfig>,
+    @Inject(forwardRef(() => TenantService))
+    private readonly tenantService: TenantService,
   ) { }
 
   async create(
@@ -29,14 +32,20 @@ export class ModelConfigService {
 
   async findAll(userId: string, tenantId: string): Promise<ModelConfig[]> {
     return this.modelConfigRepository.createQueryBuilder('model')
-      .where('model.tenantId = :tenantId OR model.tenantId IS NULL', { tenantId })
+      .where('model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId', {
+        tenantId,
+        globalTenantId: GLOBAL_TENANT_ID
+      })
       .getMany();
   }
 
   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 })
+      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)', {
+        tenantId,
+        globalTenantId: GLOBAL_TENANT_ID
+      })
       .getOne();
 
     if (!modelConfig) {
@@ -50,7 +59,10 @@ export class ModelConfigService {
   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 })
+      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)', {
+        tenantId,
+        globalTenantId: GLOBAL_TENANT_ID
+      })
       .getMany();
   }
 
@@ -106,7 +118,10 @@ export class ModelConfigService {
       .update(ModelConfig)
       .set({ isDefault: false })
       .where('type = :type', { type: modelConfig.type })
-      .andWhere('(tenantId = :tenantId OR tenantId IS NULL)', { tenantId })
+      .andWhere('(tenantId = :tenantId OR tenantId IS NULL OR tenantId = :globalTenantId)', {
+        tenantId,
+        globalTenantId: GLOBAL_TENANT_ID
+      })
       .execute();
 
     modelConfig.isDefault = true;
@@ -115,19 +130,35 @@ export class ModelConfigService {
 
   /**
    * 指定されたタイプのデフォルトモデルを取得
-   * テナント固有のデフォルトを優先、なければグローバル
+   * 厳密なルール:Index Chat Configで指定されたモデルのみを返し、なければエラーを投げる
    */
-  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();
+  async findDefaultByType(tenantId: string, type: ModelType): Promise<ModelConfig> {
+    const settings = await this.tenantService.getSettings(tenantId);
+    if (!settings) {
+      throw new BadRequestException(`Organization settings not found for tenant: ${tenantId}`);
+    }
+
+    let modelId: string | undefined;
+    if (type === ModelType.LLM) {
+      modelId = settings.selectedLLMId;
+    } else if (type === ModelType.EMBEDDING) {
+      modelId = settings.selectedEmbeddingId;
+    } else if (type === ModelType.RERANK) {
+      modelId = settings.selectedRerankId;
+    }
+
+    if (!modelId) {
+      throw new BadRequestException(`Model of type "${type}" is not configured in Index Chat Config for this organization.`);
+    }
+
+    const model = await this.modelConfigRepository.findOne({
+      where: { id: modelId, isEnabled: true }
+    });
+
+    if (!model) {
+      throw new BadRequestException(`The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`);
+    }
 
-    // Prefer tenant specific model over global
-    const tenantModel = models.find(m => m.tenantId === tenantId);
-    return tenantModel || models[0] || null;
+    return model;
   }
 }

+ 17 - 1
server/src/note/note.service.ts

@@ -33,6 +33,14 @@ export class NoteService {
     }
 
     async create(userId: string, tenantId: string, data: Partial<Note>): Promise<Note> {
+        // Handle empty strings for foreign keys
+        if (data.groupId === '') {
+            data.groupId = null as any;
+        }
+        if (data.categoryId === '') {
+            data.categoryId = null as any;
+        }
+
         const note = this.noteRepository.create({
             ...data,
             userId,
@@ -90,6 +98,14 @@ export class NoteService {
         delete (data as any).userId;
         delete (data as any).createdAt;
 
+        // Handle empty strings for foreign keys
+        if (data.groupId === '') {
+            data.groupId = null as any;
+        }
+        if (data.categoryId === '') {
+            data.categoryId = null as any;
+        }
+
         Object.assign(note, data);
         return this.noteRepository.save(note);
     }
@@ -137,7 +153,7 @@ export class NoteService {
         // Create note with screenshot and extracted text
         const note = this.noteRepository.create({
             userId,
-            groupId,
+            groupId: groupId || null as any,
             title: this.i18nService.formatMessage('pdfNoteTitle', { date: new Date().toLocaleString() }),
             content: extractedText || this.i18nService.getMessage('noTextExtracted'),
             screenshotPath: `${tenantId}/notes-screenshots/${filename}`,

+ 30 - 2
server/src/super-admin/super-admin.controller.ts

@@ -1,5 +1,6 @@
-import { Controller, Get, Post, Put, Body, UseGuards, Param, Delete } from '@nestjs/common';
+import { Controller, Get, Post, Put, Body, UseGuards, Param, Delete, HttpCode } from '@nestjs/common';
 import { SuperAdminService } from './super-admin.service';
+import { TenantService } from '../tenant/tenant.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
@@ -9,7 +10,10 @@ import { UserRole } from '../user/user-role.enum';
 @UseGuards(CombinedAuthGuard, RolesGuard)
 @Roles(UserRole.SUPER_ADMIN)
 export class SuperAdminController {
-    constructor(private readonly superAdminService: SuperAdminService) { }
+    constructor(
+        private readonly superAdminService: SuperAdminService,
+        private readonly tenantService: TenantService,
+    ) { }
 
     @Get()
     async getTenants() {
@@ -49,4 +53,28 @@ export class SuperAdminController {
     async deleteTenant(@Param('tenantId') tenantId: string) {
         return this.superAdminService.deleteTenant(tenantId);
     }
+
+    // --- Member Management ---
+
+    @Get(':tenantId/members')
+    async getMembers(@Param('tenantId') tenantId: string) {
+        return this.tenantService.getMembers(tenantId);
+    }
+
+    @Post(':tenantId/members')
+    async addMember(
+        @Param('tenantId') tenantId: string,
+        @Body() body: { userId: string; role?: string },
+    ) {
+        return this.tenantService.addMember(tenantId, body.userId, body.role);
+    }
+
+    @Delete(':tenantId/members/:userId')
+    @HttpCode(204)
+    async removeMember(
+        @Param('tenantId') tenantId: string,
+        @Param('userId') userId: string,
+    ) {
+        await this.tenantService.removeMember(tenantId, userId);
+    }
 }

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

@@ -69,6 +69,12 @@ export class TenantSetting {
     @Column({ type: 'boolean', default: true })
     isNotebookEnabled: boolean;
 
+    @Column({ type: 'integer', default: 1000 })
+    chunkSize: number;
+
+    @Column({ type: 'integer', default: 100 })
+    chunkOverlap: number;
+
     // LLM generation defaults
     @Column({ type: 'real', default: 0.7 })
     temperature: number;

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

@@ -9,6 +9,7 @@ import {
     Put,
     Request,
     UseGuards,
+    HttpCode,
 } from '@nestjs/common';
 import { TenantService } from './tenant.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@@ -53,4 +54,26 @@ export class TenantController {
     updateSettings(@Param('id') id: string, @Body() body: any) {
         return this.tenantService.updateSettings(id, body);
     }
+
+    @Get(':id/members')
+    getMembers(@Param('id') id: string) {
+        return this.tenantService.getMembers(id);
+    }
+
+    @Post(':id/members')
+    addMember(
+        @Param('id') id: string,
+        @Body() body: { userId: string; role?: string },
+    ) {
+        return this.tenantService.addMember(id, body.userId, body.role);
+    }
+
+    @Delete(':id/members/:userId')
+    @HttpCode(204)
+    async removeMember(
+        @Param('id') id: string,
+        @Param('userId') userId: string,
+    ) {
+        await this.tenantService.removeMember(id, userId);
+    }
 }

+ 26 - 4
server/src/tenant/tenant.service.ts

@@ -1,5 +1,6 @@
 import {
     BadRequestException,
+    ForbiddenException,
     Injectable,
     NotFoundException,
 } from '@nestjs/common';
@@ -11,6 +12,8 @@ import { TenantMember } from './tenant-member.entity';
 
 @Injectable()
 export class TenantService {
+    public static readonly DEFAULT_TENANT_NAME = 'Default';
+
     constructor(
         @InjectRepository(Tenant)
         private readonly tenantRepository: Repository<Tenant>,
@@ -52,19 +55,34 @@ export class TenantService {
     }
 
     async update(id: string, data: Partial<Tenant>): Promise<Tenant> {
-        await this.findById(id);
+        const tenant = await this.findById(id);
+        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
+            throw new ForbiddenException(`Cannot modify the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+        }
         await this.tenantRepository.update(id, data);
         return this.findById(id);
     }
 
     async remove(id: string): Promise<void> {
-        await this.findById(id);
+        const tenant = await this.findById(id);
+        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
+            throw new ForbiddenException(`Cannot delete the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+        }
         await this.tenantRepository.delete(id);
     }
 
     async getSettings(tenantId: string): Promise<TenantSetting> {
         let setting = await this.tenantSettingRepository.findOneBy({ tenantId });
         if (!setting) {
+            // Defensive: Check if tenant actually exists before creating settings
+            // to avoid FOREIGN KEY constraint failure if tenantId is invalid/legacy
+            const tenantExists = await this.tenantRepository.findOneBy({ id: tenantId });
+            if (!tenantExists) {
+                console.warn(`[TenantService] Attempted to get settings for non-existent tenant: ${tenantId}`);
+                // Return a transient default object without saving to DB
+                return this.tenantSettingRepository.create({ tenantId });
+            }
+
             setting = this.tenantSettingRepository.create({ tenantId });
             setting = await this.tenantSettingRepository.save(setting);
         }
@@ -82,6 +100,10 @@ export class TenantService {
     }
 
     async addMember(tenantId: string, userId: string, role: string = 'USER'): Promise<TenantMember> {
+        const tenant = await this.findById(tenantId);
+        if (tenant.name === TenantService.DEFAULT_TENANT_NAME) {
+            throw new ForbiddenException(`Cannot manually bind members to the "${TenantService.DEFAULT_TENANT_NAME}" organization`);
+        }
         const existing = await this.tenantMemberRepository.findOneBy({ tenantId, userId });
         if (existing) {
             existing.role = role as any;
@@ -107,9 +129,9 @@ export class TenantService {
      * Called during app bootstrap.
      */
     async ensureDefaultTenant(): Promise<Tenant> {
-        let defaultTenant = await this.findByName('Default');
+        let defaultTenant = await this.findByName(TenantService.DEFAULT_TENANT_NAME);
         if (!defaultTenant) {
-            defaultTenant = await this.create('Default', 'default.localhost');
+            defaultTenant = await this.create(TenantService.DEFAULT_TENANT_NAME, 'default.localhost');
         }
         return defaultTenant;
     }

+ 3 - 0
server/src/types.ts

@@ -48,6 +48,9 @@ export interface AppSettings {
   enableQueryExpansion: boolean;
   enableHyDE: boolean;
 
+  chunkSize: number;
+  chunkOverlap: number;
+
   // Language
   language: string;
 }

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

@@ -13,7 +13,9 @@ import { FileInterceptor } from '@nestjs/platform-express';
 import { UploadService } from './upload.service';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
-import { AdminGuard } from '../auth/admin.guard';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user-role.enum';
 import { errorMessages } from '../i18n/messages';
 
 import {
@@ -36,7 +38,7 @@ export interface UploadConfigDto {
 }
 
 @Controller('upload')
-@UseGuards(CombinedAuthGuard)
+@UseGuards(CombinedAuthGuard, RolesGuard)
 export class UploadController {
   private readonly logger = new Logger(UploadController.name);
 
@@ -47,7 +49,7 @@ export class UploadController {
   ) { }
 
   @Post()
-  @UseGuards(AdminGuard)  // Only admin users can upload files
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @UseInterceptors(
     FileInterceptor('file', {
       fileFilter: (req, file, cb) => {
@@ -137,7 +139,7 @@ export class UploadController {
   }
 
   @Post('text')
-  @UseGuards(AdminGuard)  // Only admin users can upload text
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async uploadText(
     @Request() req,
     @Body() body: {

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

@@ -71,6 +71,14 @@ export class CreateUserSettingDto {
   @IsOptional()
   enableHyDE: boolean = DEFAULT_SETTINGS.enableHyDE;
 
+  @IsNumber()
+  @IsOptional()
+  chunkSize: number = DEFAULT_SETTINGS.chunkSize;
+
+  @IsNumber()
+  @IsOptional()
+  chunkOverlap: number = DEFAULT_SETTINGS.chunkOverlap;
+
   @IsString()
   @IsOptional()
   coachKbId?: string;

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

@@ -15,14 +15,18 @@ 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';
+import { RolesGuard } from '../auth/roles.guard';
+import { Roles } from '../auth/roles.decorator';
+import { UserRole } from '../user/user-role.enum';
+import { TenantService } from '../tenant/tenant.service';
 
-@UseGuards(CombinedAuthGuard)
+@UseGuards(CombinedAuthGuard, RolesGuard)
 @Controller('settings') // Global prefix /api/settings
 export class UserSettingController {
   constructor(
     private readonly userSettingService: UserSettingService,
     private readonly modelConfigService: ModelConfigService,
+    private readonly tenantService: TenantService,
   ) { }
 
   @Get('global')
@@ -31,7 +35,15 @@ export class UserSettingController {
     return plainToClass(UserSettingResponseDto, globalSetting);
   }
 
-  @UseGuards(AdminGuard)
+  @Get('tenant')
+  async getTenantSettings(@Req() req) {
+    if (!req.user.tenantId) {
+      return this.userSettingService.getGlobalSettings();
+    }
+    return this.tenantService.getSettings(req.user.tenantId);
+  }
+
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @Put('global')
   @HttpCode(HttpStatus.OK)
   async updateGlobal(

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

@@ -64,6 +64,12 @@ export class UserSetting {
   @Column({ type: 'boolean', default: false })
   enableHyDE: boolean;
 
+  @Column({ type: 'integer', default: 1000 })
+  chunkSize: number;
+
+  @Column({ type: 'integer', default: 100 })
+  chunkOverlap: number;
+
   @Column({ type: 'text', nullable: true })
   defaultVisionModelId: string;
 

+ 3 - 2
server/src/user-setting/user-setting.module.ts

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

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

@@ -166,3 +166,4 @@ export class UserSettingService implements OnModuleInit {
     return this.userSettingRepository.save(updated);
   }
 }
+

+ 17 - 1
server/src/user/user.service.ts

@@ -44,6 +44,7 @@ export class UserService implements OnModuleInit {
   async findAll(): Promise<User[]> {
     return this.usersRepository.find({
       select: ['id', 'username', 'isAdmin', 'role', 'createdAt', 'tenantId'],
+      relations: ['tenantMembers', 'tenantMembers.tenant'],
       order: { createdAt: 'DESC' },
     });
   }
@@ -133,10 +134,25 @@ export class UserService implements OnModuleInit {
   }
 
   async getUserTenants(userId: string): Promise<TenantMember[]> {
-    return this.tenantMemberRepository.find({
+    const user = await this.usersRepository.findOne({ where: { id: userId }, select: ['role'] });
+
+    if (user?.role === UserRole.SUPER_ADMIN) {
+      const allTenants = await this.tenantService.findAll();
+      return allTenants.map(t => ({
+        tenantId: t.id,
+        tenant: t,
+        role: UserRole.SUPER_ADMIN,
+        userId: userId
+      } as TenantMember));
+    }
+
+    const members = await this.tenantMemberRepository.find({
       where: { userId },
       relations: ['tenant']
     });
+
+    // Filter out the "Default" tenant for non-super admins
+    return members.filter(m => m.tenant?.name !== TenantService.DEFAULT_TENANT_NAME);
   }
 
   /**

+ 3 - 1
web/components/IndexingModalWithMode.tsx

@@ -20,6 +20,7 @@ interface IndexingModalWithModeProps {
   embeddingModels: ModelConfig[];
   defaultEmbeddingId: string;
   onConfirm: (config: IndexingConfig) => void;
+  authToken?: string;
   isReconfiguring?: boolean;
 }
 
@@ -30,6 +31,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
   embeddingModels,
   defaultEmbeddingId,
   onConfirm,
+  authToken,
   isReconfiguring = false
 }) => {
   const { t } = useLanguage();
@@ -66,7 +68,7 @@ const IndexingModalWithMode: React.FC<IndexingModalWithModeProps> = ({
 
   // Get auth token
   const getAuthToken = () => {
-    return localStorage.getItem('authToken') || '';
+    return authToken || localStorage.getItem('kb_api_key') || '';
   };
 
   // Load mode recommendation when files change

+ 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/50 backdrop-blur-sm 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-[999] ${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'
         }`}>

+ 17 - 19
web/components/views/ChatView.tsx

@@ -94,29 +94,27 @@ export const ChatView: React.FC<ChatViewProps> = ({
     const fetchAndSetSettings = useCallback(async () => {
         if (!authToken) return
         try {
-            // 個人設定(言語など)とグローバル設定(RAGパラメータなど)を並列で取得
-            const [userSettings, globalSettings] = await Promise.all([
+            const [userSettings, globalSettings, tenantSettings] = await Promise.all([
                 userSettingService.get(authToken),
-                userSettingService.getGlobal(authToken)
+                userSettingService.getGlobal(authToken),
+                userSettingService.getTenant(authToken).catch(() => ({} as Partial<AppSettings>))
             ]);
 
             const appSettings: AppSettings = {
-                // 言語は個人の設定を尊重
-                language: userSettings.language ?? globalSettings.language ?? DEFAULT_SETTINGS.language,
-                // すべてのパラメータをグローバル設定優先にする
-                selectedLLMId: globalSettings.selectedLLMId ?? userSettings.selectedLLMId ?? DEFAULT_SETTINGS.selectedLLMId,
-                selectedEmbeddingId: globalSettings.selectedEmbeddingId ?? userSettings.selectedEmbeddingId ?? DEFAULT_SETTINGS.selectedEmbeddingId,
-                selectedRerankId: globalSettings.selectedRerankId ?? userSettings.selectedRerankId ?? '',
-                temperature: globalSettings.temperature ?? userSettings.temperature ?? DEFAULT_SETTINGS.temperature,
-                maxTokens: globalSettings.maxTokens ?? userSettings.maxTokens ?? DEFAULT_SETTINGS.maxTokens,
-                enableRerank: globalSettings.enableRerank ?? userSettings.enableRerank ?? DEFAULT_SETTINGS.enableRerank,
-                topK: globalSettings.topK ?? userSettings.topK ?? DEFAULT_SETTINGS.topK,
-                similarityThreshold: globalSettings.similarityThreshold ?? userSettings.similarityThreshold ?? DEFAULT_SETTINGS.similarityThreshold,
-                rerankSimilarityThreshold: globalSettings.rerankSimilarityThreshold ?? userSettings.rerankSimilarityThreshold ?? DEFAULT_SETTINGS.rerankSimilarityThreshold,
-                enableFullTextSearch: globalSettings.enableFullTextSearch ?? userSettings.enableFullTextSearch ?? DEFAULT_SETTINGS.enableFullTextSearch,
-                hybridVectorWeight: globalSettings.hybridVectorWeight ?? userSettings.hybridVectorWeight ?? DEFAULT_SETTINGS.hybridVectorWeight,
-                enableQueryExpansion: globalSettings.enableQueryExpansion ?? userSettings.enableQueryExpansion ?? DEFAULT_SETTINGS.enableQueryExpansion,
-                enableHyDE: globalSettings.enableHyDE ?? userSettings.enableHyDE ?? DEFAULT_SETTINGS.enableHyDE
+                language: userSettings.language || tenantSettings.language || globalSettings.language || DEFAULT_SETTINGS.language,
+                selectedLLMId: userSettings.selectedLLMId || tenantSettings.selectedLLMId || globalSettings.selectedLLMId || DEFAULT_SETTINGS.selectedLLMId,
+                selectedEmbeddingId: userSettings.selectedEmbeddingId || tenantSettings.selectedEmbeddingId || globalSettings.selectedEmbeddingId || DEFAULT_SETTINGS.selectedEmbeddingId,
+                selectedRerankId: userSettings.selectedRerankId || tenantSettings.selectedRerankId || globalSettings.selectedRerankId || '',
+                temperature: userSettings.temperature ?? tenantSettings.temperature ?? globalSettings.temperature ?? DEFAULT_SETTINGS.temperature,
+                maxTokens: userSettings.maxTokens ?? tenantSettings.maxTokens ?? globalSettings.maxTokens ?? DEFAULT_SETTINGS.maxTokens,
+                enableRerank: userSettings.enableRerank ?? tenantSettings.enableRerank ?? globalSettings.enableRerank ?? DEFAULT_SETTINGS.enableRerank,
+                topK: userSettings.topK ?? tenantSettings.topK ?? globalSettings.topK ?? DEFAULT_SETTINGS.topK,
+                similarityThreshold: userSettings.similarityThreshold ?? tenantSettings.similarityThreshold ?? globalSettings.similarityThreshold ?? DEFAULT_SETTINGS.similarityThreshold,
+                rerankSimilarityThreshold: userSettings.rerankSimilarityThreshold ?? tenantSettings.rerankSimilarityThreshold ?? globalSettings.rerankSimilarityThreshold ?? DEFAULT_SETTINGS.rerankSimilarityThreshold,
+                enableFullTextSearch: userSettings.enableFullTextSearch ?? tenantSettings.enableFullTextSearch ?? globalSettings.enableFullTextSearch ?? DEFAULT_SETTINGS.enableFullTextSearch,
+                hybridVectorWeight: userSettings.hybridVectorWeight ?? tenantSettings.hybridVectorWeight ?? globalSettings.hybridVectorWeight ?? DEFAULT_SETTINGS.hybridVectorWeight,
+                enableQueryExpansion: userSettings.enableQueryExpansion ?? tenantSettings.enableQueryExpansion ?? globalSettings.enableQueryExpansion ?? DEFAULT_SETTINGS.enableQueryExpansion,
+                enableHyDE: userSettings.enableHyDE ?? tenantSettings.enableHyDE ?? globalSettings.enableHyDE ?? DEFAULT_SETTINGS.enableHyDE
             }
             setSettings(appSettings)
         } catch (error) {

+ 41 - 36
web/components/views/KnowledgeBaseView.tsx

@@ -334,7 +334,7 @@ export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
                         <div className="w-10 h-10 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
                     </div>
                 ) : paginatedFiles.length > 0 ? (
-                    <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
+                    <div className="flex flex-col gap-3">
                         <AnimatePresence mode="popLayout">
                             {paginatedFiles.map((file) => (
                                 <motion.div
@@ -343,53 +343,57 @@ export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
                                     initial={{ opacity: 0, y: 10 }}
                                     animate={{ opacity: 1, y: 0 }}
                                     exit={{ opacity: 0, scale: 0.95 }}
-                                    className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-full"
+                                    className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex items-center gap-4"
                                 >
-                                    <div className="flex items-start justify-between mb-4">
-                                        <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30">
-                                            {getFileIcon(file)}
-                                        </div>
-                                        <div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
-                                            {isFormatSupportedForPreview(file.name) && (
-                                                <button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md">
-                                                    <Eye size={16} />
-                                                </button>
-                                            )}
-                                            {isAdmin && (
-                                                <button onClick={() => handleRemoveFile(file.id)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md">
-                                                    <Trash2 size={16} />
-                                                </button>
-                                            )}
-                                        </div>
+                                    {/* Icon */}
+                                    <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
+                                        {getFileIcon(file)}
                                     </div>
 
-                                    <div className="flex-1">
-                                        <h3
-                                            onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
-                                            className="font-bold text-slate-900 text-[15px] mb-2 leading-snug cursor-pointer hover:text-blue-600 transition-colors line-clamp-2"
-                                        >
-                                            {file.name}
-                                        </h3>
-                                        <p className="text-[13px] text-slate-500 mb-4 line-clamp-3 min-h-[3em] leading-relaxed">
+                                    {/* Name & Desc */}
+                                    <div className="flex-1 min-w-0">
+                                        <div className="flex items-center gap-3 mb-1">
+                                            <h3
+                                                onClick={() => setChunkDrawer({ isOpen: true, fileId: file.id, fileName: file.name })}
+                                                className="font-bold text-slate-900 text-[15px] cursor-pointer hover:text-blue-600 transition-colors truncate"
+                                            >
+                                                {file.name}
+                                            </h3>
+                                            <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
+                                                {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
+                                            </span>
+                                        </div>
+                                        <p className="text-[13px] text-slate-500 truncate">
                                             {file.status === 'ready' || file.status === 'vectorized'
-                                                ? `The ${file.name.split('.').pop()?.toUpperCase()} document has been processed and is ready for use in the Knowledge Base.`
-                                                : `Processing... Currently in ${file.status} state. Results will be available shortly.`
+                                                ? `Document processed and ready.`
+                                                : `Processing... Currently in ${file.status} state.`
                                             }
                                         </p>
                                     </div>
 
-                                    <div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
-                                        <div className="text-[12px] font-medium text-slate-400 flex items-center gap-2">
+                                    {/* Meta & Actions */}
+                                    <div className="flex items-center gap-6 shrink-0">
+                                        <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
                                             <span>{new Date().toLocaleDateString('ja-JP')}</span>
-                                            <span className="w-1 h-1 bg-slate-200 rounded-full" />
                                             <span>{formatBytes(file.size)}</span>
                                         </div>
+
                                         <div className="flex items-center gap-2">
-                                            <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase">
-                                                {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
-                                            </span>
-                                            {file.status !== 'ready' && file.status !== 'vectorized' && (
-                                                <CircleDashed size={14} className="text-blue-400 animate-spin" />
+                                            {file.status !== 'ready' && file.status !== 'vectorized' ? (
+                                                <CircleDashed size={16} className="text-blue-400 animate-spin mr-2" />
+                                            ) : null}
+                                        </div>
+
+                                        <div className="flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity">
+                                            {isFormatSupportedForPreview(file.name) && (
+                                                <button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50" title={t('preview') as string || 'Preview'}>
+                                                    <Eye size={16} />
+                                                </button>
+                                            )}
+                                            {isAdmin && (
+                                                <button onClick={() => handleRemoveFile(file.id)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50" title={t('delete') as string || 'Delete'}>
+                                                    <Trash2 size={16} />
+                                                </button>
                                             )}
                                         </div>
                                     </div>
@@ -434,6 +438,7 @@ export const KnowledgeBaseView: React.FC<KnowledgeBaseViewProps> = (props) => {
                 embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
                 defaultEmbeddingId={settings.selectedEmbeddingId}
                 onConfirm={handleConfirmIndexing}
+                authToken={authToken}
             />
 
             {pdfPreview && (

+ 30 - 27
web/components/views/NotebookDetailView.tsx

@@ -227,7 +227,7 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                         <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
                     </div>
                 ) : filteredFiles.length > 0 ? (
-                    <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-6">
+                    <div className="flex flex-col gap-3">
                         <AnimatePresence mode="popLayout">
                             {filteredFiles.map((file) => (
                                 <motion.div
@@ -235,45 +235,47 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                                     layout
                                     initial={{ opacity: 0, y: 10 }}
                                     animate={{ opacity: 1, y: 0 }}
-                                    className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-full"
+                                    className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex items-center gap-4"
                                 >
-                                    <div className="flex items-start justify-between mb-4">
-                                        <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30">
-                                            {getFileIcon(file)}
+                                    {/* Icon */}
+                                    <div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
+                                        {getFileIcon(file)}
+                                    </div>
+
+                                    {/* Main info */}
+                                    <div className="flex-1 min-w-0">
+                                        <div className="flex items-center gap-3 mb-1">
+                                            <h3 className="font-bold text-slate-900 text-[15px] truncate">
+                                                {file.name}
+                                            </h3>
+                                            <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
+                                                {file.status}
+                                            </span>
                                         </div>
+                                    </div>
+
+                                    {/* Meta info & Actions */}
+                                    <div className="flex items-center gap-6 shrink-0">
+                                        <div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
+                                            <span>{formatBytes(file.size)}</span>
+                                            <span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">
+                                                {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
+                                            </span>
+                                        </div>
+
                                         <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
                                             {isFormatSupportedForPreview(file.name) && (
-                                                <button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md">
+                                                <button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50">
                                                     <Eye size={16} />
                                                 </button>
                                             )}
                                             {isAdmin && (
-                                                <button onClick={() => handleRemoveFile(file.id, file.name)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md">
+                                                <button onClick={() => handleRemoveFile(file.id, file.name)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50">
                                                     <Plus size={16} className="rotate-45" />
                                                 </button>
                                             )}
                                         </div>
                                     </div>
-
-                                    <div className="flex-1">
-                                        <h3 className="font-bold text-slate-900 text-[15px] mb-2 leading-snug line-clamp-2">
-                                            {file.name}
-                                        </h3>
-                                        <div className="flex items-center gap-2 mt-2">
-                                            <span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase">
-                                                {file.status}
-                                            </span>
-                                        </div>
-                                    </div>
-
-                                    <div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
-                                        <div className="text-[12px] font-medium text-slate-400">
-                                            {formatBytes(file.size)}
-                                        </div>
-                                        <span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest">
-                                            {file.name.split('.').pop()?.toUpperCase() || 'FILE'}
-                                        </span>
-                                    </div>
                                 </motion.div>
                             ))}
                         </AnimatePresence>
@@ -294,6 +296,7 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                 embeddingModels={models.filter(m => m.type === 'embedding')}
                 defaultEmbeddingId={models.find(m => m.isDefault)?.id || ''}
                 onConfirm={handleConfirmIndexing}
+                authToken={authToken}
             />
 
             {pdfPreview && (

+ 344 - 51
web/components/views/SettingsView.tsx

@@ -58,6 +58,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         role?: string;
         tenantId?: string;
         createdAt: string;
+        tenantMembers?: Array<{ tenantId: string; role: string; tenant?: { id: string; name: string } }>;
     }
     const [users, setUsers] = useState<UserType[]>([]);
     const [isUserLoading, setIsUserLoading] = useState(false);
@@ -79,6 +80,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
     const [userSearchQuery, setUserSearchQuery] = useState('');
 
+    // --- Manage Members Modal State ---
+    const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
+    const [tenantMembers, setTenantMembers] = useState<any[]>([]);
+    const [memberUserSearch, setMemberUserSearch] = useState('');
+    const [isMembersLoading, setIsMembersLoading] = useState(false);
+
     useEffect(() => {
         if (initialTab) {
             setActiveTab(initialTab);
@@ -273,6 +280,63 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     const [editingTenant, setEditingTenant] = useState<any | null>(null);
     const [newTenant, setNewTenant] = useState({ name: '', domain: '', adminUserId: '' });
 
+    const fetchTenantMembers = async (tenantId: string) => {
+        setIsMembersLoading(true);
+        try {
+            const headers = { 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' };
+            const res = await fetch(`/api/v1/tenants/${tenantId}/members`, { headers });
+            if (res.ok) {
+                setTenantMembers(await res.json());
+            }
+        } catch (e) {
+            console.error(e);
+        } finally {
+            setIsMembersLoading(false);
+        }
+    };
+
+    const handleAddMember = async (tenantId: string, userId: string) => {
+        try {
+            const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' };
+            const res = await fetch(`/api/v1/tenants/${tenantId}/members`, {
+                method: 'POST',
+                headers,
+                body: JSON.stringify({ userId, role: 'USER' }),
+            });
+            if (res.ok) {
+                showSuccess('User added to organization');
+                fetchTenantMembers(tenantId);
+                fetchTenantsData();
+                fetchUsers();
+            } else {
+                const errData = await res.json().catch(() => ({}));
+                showError(errData.message || 'Failed to add member');
+            }
+        } catch (e) {
+            showError('Error adding member');
+        }
+    };
+
+    const handleRemoveMember = async (tenantId: string, userId: string) => {
+        try {
+            const headers = { 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' };
+            const res = await fetch(`/api/v1/tenants/${tenantId}/members/${userId}`, {
+                method: 'DELETE',
+                headers,
+            });
+            if (res.ok || res.status === 204) {
+                showSuccess('User removed from organization');
+                fetchTenantMembers(tenantId);
+                fetchTenantsData();
+                fetchUsers();
+            } else {
+                showError('Failed to remove member');
+            }
+        } catch (e) {
+            showError('Error removing member');
+        }
+    };
+
     const fetchTenantsData = async () => {
         setIsTenantsLoading(true);
         try {
@@ -732,15 +796,31 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 transition={{ delay: index * 0.05 }}
                                 className="flex items-center justify-between p-5 bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl shadow-sm hover:shadow-md hover:border-indigo-200/50 transition-all group"
                             >
-                                <div className="flex items-center gap-4">
-                                    <div className={`w-12 h-12 rounded-xl flex items-center justify-center transition-all ${iconColors}`}>
+                                <div className="flex items-center gap-4 flex-1 min-w-0">
+                                    <div className={`w-12 h-12 rounded-xl flex items-center justify-center transition-all flex-shrink-0 ${iconColors}`}>
                                         <IconComponent size={22} />
                                     </div>
-                                    <div>
-                                        <div className="flex items-center gap-2">
+                                    <div className="flex-1 min-w-0">
+                                        <div className="flex items-center gap-2 flex-wrap">
                                             <p className="font-black text-slate-900">{user.username}</p>
-                                            {(user.role === 'SUPER_ADMIN' || user.isAdmin) && <span className={`text-[9px] font-black px-1.5 py-0.5 rounded-md uppercase tracking-wider ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-700' : 'bg-indigo-100 text-indigo-700'}`}>{user.role === 'SUPER_ADMIN' ? 'SUPER' : 'ADMIN'}</span>}                                    </div>
+                                            {(user.role === 'SUPER_ADMIN' || user.isAdmin) && <span className={`text-[9px] font-black px-1.5 py-0.5 rounded-md uppercase tracking-wider ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-700' : 'bg-indigo-100 text-indigo-700'}`}>{user.role === 'SUPER_ADMIN' ? 'SUPER' : 'ADMIN'}</span>}
+                                        </div>
                                         <p className="text-[10px] font-bold text-slate-400 mt-0.5 uppercase tracking-widest">{t('createdAt')}: {new Date(user.createdAt).toLocaleDateString()}</p>
+                                        {user.tenantMembers && user.tenantMembers.length > 0 && (
+                                            <div className="flex flex-wrap gap-1 mt-1.5">
+                                                {user.tenantMembers
+                                                    .filter((m: any) => m.tenant?.name !== 'Default')
+                                                    .map((m: any) => (
+                                                        <span
+                                                            key={m.tenantId}
+                                                            className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 text-[9px] font-black rounded-md uppercase tracking-wider border border-emerald-100"
+                                                        >
+                                                            <Building2 size={8} />
+                                                            {m.tenant?.name || m.tenantId}
+                                                        </span>
+                                                    ))}
+                                            </div>
+                                        )}
                                     </div>
                                 </div>
                                 <div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all">
@@ -829,6 +909,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <th className="px-6 py-4">Name</th>
                                 <th className="px-6 py-4">Domain</th>
                                 <th className="px-6 py-4">Admin</th>
+                                <th className="px-6 py-4">Members</th>
                                 <th className="px-6 py-4">Features</th>
                                 <th className="px-6 py-4">Created</th>
                                 <th className="px-6 py-4 text-right">Actions</th>
@@ -852,32 +933,38 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                                             <User className="w-3 h-3 text-indigo-500" />
                                                         </div>
                                                         {adminUser.username}
-                                                        <button
-                                                            onClick={() => {
-                                                                setBindingTenantId(t.id);
-                                                                setUserSearchQuery('');
-                                                                fetchUsers();
-                                                            }}
-                                                            className="ml-2 p-1 hover:bg-indigo-50 text-slate-400 hover:text-indigo-600 rounded transition-all"
-                                                            title="Change Admin"
-                                                        >
-                                                            <Edit2 size={12} />
-                                                        </button>
+                                                        {t.name !== 'Default' && (
+                                                            <button
+                                                                onClick={() => {
+                                                                    setBindingTenantId(t.id);
+                                                                    setUserSearchQuery('');
+                                                                    fetchUsers();
+                                                                }}
+                                                                className="ml-2 p-1 hover:bg-indigo-50 text-slate-400 hover:text-indigo-600 rounded transition-all"
+                                                                title="Change Admin"
+                                                            >
+                                                                <Edit2 size={12} />
+                                                            </button>
+                                                        )}
                                                     </div>
                                                 );
                                             } else {
                                                 return (
                                                     <div className="flex items-center gap-2">
-                                                        <button
-                                                            onClick={() => {
-                                                                setBindingTenantId(t.id);
-                                                                setUserSearchQuery('');
-                                                                fetchUsers();
-                                                            }}
-                                                            className="px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
-                                                        >
-                                                            Bind Admin
-                                                        </button>
+                                                        {t.name !== 'Default' ? (
+                                                            <button
+                                                                onClick={() => {
+                                                                    setBindingTenantId(t.id);
+                                                                    setUserSearchQuery('');
+                                                                    fetchUsers();
+                                                                }}
+                                                                className="px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
+                                                            >
+                                                                Bind Admin
+                                                            </button>
+                                                        ) : (
+                                                            <span className="text-[10px] text-slate-400 italic">System Restricted</span>
+                                                        )}
                                                         <span className="text-[10px] text-slate-400 italic">None</span>
                                                     </div>
                                                 );
@@ -889,36 +976,64 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             <div className={`p-1.5 rounded-lg ${t.settings_obj?.isNotebookEnabled ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-50 text-slate-400'}`} title="Notebook Feature">
                                                 <BookOpen size={14} />
                                             </div>
-                                            <button
-                                                onClick={() => handleToggleNotebookFeature(t.id, t.settings_obj?.isNotebookEnabled !== false)}
-                                                className="text-[10px] font-black uppercase tracking-widest text-indigo-600 hover:underline"
-                                            >
-                                                {t.settings_obj?.isNotebookEnabled !== false ? 'Enabled' : 'Disabled'}
-                                            </button>
+                                            {t.name !== 'Default' ? (
+                                                <button
+                                                    onClick={() => handleToggleNotebookFeature(t.id, t.settings_obj?.isNotebookEnabled !== false)}
+                                                    className="text-[10px] font-black uppercase tracking-widest text-indigo-600 hover:underline"
+                                                >
+                                                    {t.settings_obj?.isNotebookEnabled !== false ? 'Enabled' : 'Disabled'}
+                                                </button>
+                                            ) : (
+                                                <span className="text-[10px] font-black uppercase tracking-widest text-slate-400">Fixed</span>
+                                            )}
                                         </div>
                                     </td>
                                     <td className="px-6 py-4 text-xs text-slate-400">{new Date(t.createdAt).toLocaleDateString()}</td>
-                                    <td className="px-6 py-4 text-right">
-                                        <div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
-                                            <button
-                                                onClick={() => {
-                                                    setEditingTenant(t);
-                                                    setNewTenant({ name: t.name, domain: t.domain || '', adminUserId: '' });
-                                                }}
-                                                className="p-1.5 hover:bg-slate-100 text-slate-400 hover:text-indigo-600 rounded-lg transition-all"
-                                                title="Edit Tenant"
-                                            >
-                                                <Edit2 size={14} />
-                                            </button>
-                                            <button
-                                                onClick={() => handleDeleteTenant(t.id)}
-                                                className="p-1.5 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-lg transition-all"
-                                                title="Delete Tenant"
-                                            >
-                                                <Trash2 size={14} />
-                                            </button>
+                                    <td className="px-6 py-4 text-xs">
+                                        {/* Members count + manage button */}
+                                        <div className="flex items-center gap-2">
+                                            <span className="font-bold text-slate-700">
+                                                {(t.members || []).filter((m: any) => m.role !== 'SUPER_ADMIN').length}
+                                            </span>
+                                            <span className="text-slate-400">users</span>
+                                            {t.name !== 'Default' && (
+                                                <button
+                                                    onClick={() => {
+                                                        setManagingMembersTenantId(t.id);
+                                                        setMemberUserSearch('');
+                                                        fetchTenantMembers(t.id);
+                                                        fetchUsers();
+                                                    }}
+                                                    className="ml-1 px-2.5 py-1 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
+                                                >
+                                                    Manage
+                                                </button>
+                                            )}
                                         </div>
                                     </td>
+                                    <td className="px-6 py-4 text-right">
+                                        {t.name !== 'Default' && (
+                                            <div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
+                                                <button
+                                                    onClick={() => {
+                                                        setEditingTenant(t);
+                                                        setNewTenant({ name: t.name, domain: t.domain || '', adminUserId: '' });
+                                                    }}
+                                                    className="p-1.5 hover:bg-slate-100 text-slate-400 hover:text-indigo-600 rounded-lg transition-all"
+                                                    title="Edit Tenant"
+                                                >
+                                                    <Edit2 size={14} />
+                                                </button>
+                                                <button
+                                                    onClick={() => handleDeleteTenant(t.id)}
+                                                    className="p-1.5 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-lg transition-all"
+                                                    title="Delete Tenant"
+                                                >
+                                                    <Trash2 size={14} />
+                                                </button>
+                                            </div>
+                                        )}
+                                    </td>
                                 </tr>
                             ))}
                         </tbody>
@@ -1044,6 +1159,142 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                     </div>
                 )}
             </AnimatePresence>
+
+            {/* Manage Members Modal */}
+            <AnimatePresence>
+                {managingMembersTenantId && (() => {
+                    const tenant = tenants.find(t => t.id === managingMembersTenantId);
+                    const memberUserIds = new Set(tenantMembers.map((m: any) => m.userId));
+                    const availableUsers = users.filter(u =>
+                        !memberUserIds.has(u.id) &&
+                        u.role !== 'SUPER_ADMIN' &&
+                        u.username.toLowerCase().includes(memberUserSearch.toLowerCase())
+                    );
+                    return (
+                        <div className="fixed inset-0 z-[140] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4">
+                            <motion.div
+                                initial={{ scale: 0.9, opacity: 0 }}
+                                animate={{ scale: 1, opacity: 1 }}
+                                exit={{ scale: 0.9, opacity: 0 }}
+                                className="bg-white rounded-[2.5rem] p-8 w-full max-w-2xl shadow-2xl border border-white/20 overflow-hidden relative max-h-[90vh] flex flex-col"
+                            >
+                                <div className="flex items-start justify-between mb-6">
+                                    <div>
+                                        <h3 className="text-2xl font-black text-slate-900 tracking-tight mb-1">Manage Members</h3>
+                                        <p className="text-sm text-slate-500 font-medium">
+                                            Organization: <span className="text-emerald-600 font-bold">{tenant?.name}</span>
+                                        </p>
+                                    </div>
+                                    <button
+                                        onClick={() => { setManagingMembersTenantId(null); setTenantMembers([]); }}
+                                        className="p-2 hover:bg-slate-100 rounded-2xl transition-all"
+                                    >
+                                        <X size={20} className="text-slate-400" />
+                                    </button>
+                                </div>
+
+                                <div className="flex-1 overflow-y-auto flex flex-col gap-6 pr-1 scrollbar-hide">
+                                    {/* Current Members Section */}
+                                    <div>
+                                        <h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">
+                                            Current Members ({tenantMembers.length})
+                                        </h4>
+                                        {isMembersLoading ? (
+                                            <div className="flex items-center justify-center py-8 opacity-40">
+                                                <Loader2 size={24} className="animate-spin" />
+                                            </div>
+                                        ) : tenantMembers.length === 0 ? (
+                                            <div className="py-6 text-center bg-slate-50 rounded-2xl opacity-50">
+                                                <User size={28} className="mx-auto mb-2 text-slate-400" />
+                                                <p className="text-xs font-bold uppercase tracking-widest text-slate-400">No members yet</p>
+                                            </div>
+                                        ) : (
+                                            <div className="space-y-2">
+                                                {tenantMembers.map((m: any) => (
+                                                    <div key={m.id || m.userId} className="flex items-center justify-between p-4 bg-slate-50 rounded-2xl border border-slate-200/60">
+                                                        <div className="flex items-center gap-3">
+                                                            <div className="w-9 h-9 rounded-xl bg-white border border-slate-200 flex items-center justify-center">
+                                                                <User size={16} className="text-slate-400" />
+                                                            </div>
+                                                            <div>
+                                                                <p className="text-sm font-black text-slate-900">{m.user?.username || m.userId}</p>
+                                                                <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{m.role}</p>
+                                                            </div>
+                                                        </div>
+                                                        <button
+                                                            onClick={() => handleRemoveMember(managingMembersTenantId, m.userId)}
+                                                            className="p-2 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-xl transition-all"
+                                                            title="Remove member"
+                                                        >
+                                                            <Trash2 size={14} />
+                                                        </button>
+                                                    </div>
+                                                ))}
+                                            </div>
+                                        )}
+                                    </div>
+
+                                    {/* Add Users Section */}
+                                    <div>
+                                        <h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">Add Users</h4>
+                                        <div className="relative mb-3">
+                                            <div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
+                                                <User size={16} />
+                                            </div>
+                                            <input
+                                                className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-emerald-500/10 focus:border-emerald-500/50 outline-none transition-all"
+                                                placeholder="Search users by name..."
+                                                value={memberUserSearch}
+                                                onChange={e => setMemberUserSearch(e.target.value)}
+                                            />
+                                        </div>
+                                        {availableUsers.length === 0 ? (
+                                            <div className="py-6 text-center bg-slate-50 rounded-2xl opacity-50">
+                                                <p className="text-xs font-bold uppercase tracking-widest text-slate-400">
+                                                    {memberUserSearch ? 'No matching users' : 'All users already added'}
+                                                </p>
+                                            </div>
+                                        ) : (
+                                            <div className="space-y-2 max-h-[220px] overflow-y-auto scrollbar-hide">
+                                                {availableUsers.map(u => (
+                                                    <button
+                                                        key={u.id}
+                                                        onClick={() => handleAddMember(managingMembersTenantId, u.id)}
+                                                        className="w-full flex items-center justify-between p-4 bg-slate-50/50 hover:bg-emerald-50 border border-slate-200/50 hover:border-emerald-200 rounded-2xl transition-all group"
+                                                    >
+                                                        <div className="flex items-center gap-3">
+                                                            <div className="w-9 h-9 rounded-xl bg-white border border-slate-200 flex items-center justify-center group-hover:text-emerald-600 transition-all">
+                                                                <User size={16} className="text-slate-400 group-hover:text-emerald-600" />
+                                                            </div>
+                                                            <div className="text-left">
+                                                                <p className="text-sm font-black text-slate-900">{u.username}</p>
+                                                                <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{u.role || 'User'}</p>
+                                                            </div>
+                                                        </div>
+                                                        <div className="flex items-center gap-1.5 px-3 py-1 bg-emerald-50 group-hover:bg-emerald-100 text-emerald-700 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all">
+                                                            <Plus size={12} />
+                                                            Add
+                                                        </div>
+                                                    </button>
+                                                ))}
+                                            </div>
+                                        )}
+                                    </div>
+                                </div>
+
+                                <div className="pt-6 border-t border-slate-100 flex justify-end mt-4">
+                                    <button
+                                        onClick={() => { setManagingMembersTenantId(null); setTenantMembers([]); }}
+                                        className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700 transition-all"
+                                    >
+                                        Done
+                                    </button>
+                                </div>
+                            </motion.div>
+                        </div>
+                    );
+                })()}
+            </AnimatePresence>
         </div>
     );
     const renderKnowledgeBaseTab = () => (
@@ -1122,6 +1373,48 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                         </div>
                     </section>
 
+                    {/* Indexing & Chunking Configuration */}
+                    <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
+                        <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
+                            <div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
+                                <BookOpen size={16} />
+                            </div>
+                            Indexing & Chunking Configuration
+                        </div>
+                        <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
+                            <div>
+                                <div className="flex justify-between mb-3 px-1">
+                                    <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Chunk Size (Tokens)</label>
+                                    <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
+                                </div>
+                                <input
+                                    type="range"
+                                    min="100"
+                                    max="8192"
+                                    step="100"
+                                    value={localKbSettings.chunkSize || 1000}
+                                    onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
+                                    className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
+                                />
+                            </div>
+                            <div>
+                                <div className="flex justify-between mb-3 px-1">
+                                    <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Chunk Overlap</label>
+                                    <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
+                                </div>
+                                <input
+                                    type="range"
+                                    min="0"
+                                    max="2048"
+                                    step="10"
+                                    value={localKbSettings.chunkOverlap || 100}
+                                    onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
+                                    className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
+                                />
+                            </div>
+                        </div>
+                    </section>
+
                     {/* Chat Hyperparameters */}
                     <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
                         <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">

+ 8 - 28
web/services/pdfPreviewService.ts

@@ -1,52 +1,32 @@
 import { PDFStatus } from '../types';
-
-const API_BASE = '/api';
+import { apiClient } from './apiClient';
 
 export const pdfPreviewService = {
   // PDFプレビューURLの取得
   async getPDFUrl(fileId: string): Promise<{ url: string }> {
-    const response = await fetch(`${API_BASE}/knowledge-bases/${fileId}/pdf-url`, {
-      headers: {
-        'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-      },
-    });
-    if (!response.ok) {
-      const errorData = await response.json().catch(() => ({}));
-      throw new Error(errorData.message || 'Failed to get PDF URL');
-    }
-    return response.json();
+    const { data } = await apiClient.get<{ url: string }>(`/knowledge-bases/${fileId}/pdf-url`);
+    return data;
   },
 
   // PDFステータスの確認
   async getPDFStatus(fileId: string): Promise<PDFStatus> {
-    const response = await fetch(`${API_BASE}/knowledge-bases/${fileId}/pdf-status`, {
-      headers: {
-        'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-      },
-    });
-    if (!response.ok) {
-      const errorData = await response.json().catch(() => ({}));
-      throw new Error(errorData.message || 'Failed to fetch PDF status');
-    }
-    return response.json();
+    const { data } = await apiClient.get<PDFStatus>(`/knowledge-bases/${fileId}/pdf-status`);
+    return data;
   },
 
   // PDFのプリロード(変換のトリガー)
   async preloadPDF(fileId: string, force: boolean = false): Promise<void> {
     try {
-      const url = `${API_BASE}/knowledge-bases/${fileId}/pdf-url` + (force ? '?force=true' : '');
-      const response = await fetch(url, {
+      const url = `/knowledge-bases/${fileId}/pdf-url` + (force ? '?force=true' : '');
+      const response = await apiClient.request(url, {
         method: 'GET',
-        headers: {
-          'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-        },
         signal: AbortSignal.timeout(30000), // Increase timeout for conversion
       });
 
       if (response.ok) {
         console.log('PDF already exists or conversion completed');
       }
-    } catch (error) {
+    } catch (error: any) {
       console.log('PDF conversion triggered:', error.message);
     }
   },

+ 15 - 0
web/services/userSettingService.ts

@@ -57,6 +57,21 @@ export const userSettingService = {
     return response.json();
   },
 
+  async getTenant(token: string): Promise<Partial<UserSettingResponse>> {
+    const response = await fetch(`${API_BASE_URL}/settings/tenant`, {
+      method: 'GET',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`,
+      },
+    });
+    if (!response.ok) {
+      const errorData = await response.json();
+      throw new Error(errorData.message || 'Failed to fetch tenant settings');
+    }
+    return response.json();
+  },
+
   async updateGlobal(token: string, settings: Partial<AppSettings>): Promise<UserSettingResponse> {
     const response = await fetch(`${API_BASE_URL}/settings/global`, {
       method: 'PUT',

+ 8 - 10
web/src/components/layouts/WorkspaceLayout.tsx

@@ -110,15 +110,13 @@ const WorkspaceLayout: React.FC = () => {
                             isActive={location.pathname.startsWith('/knowledge-groups')}
                             onClick={handleNavClick}
                         />
-                        {user?.isNotebookEnabled !== false && (
-                            <SidebarItem
-                                icon={BookOpen}
-                                label={t('navNotebook')}
-                                path="/notebook"
-                                isActive={location.pathname.startsWith('/notebook')}
-                                onClick={handleNavClick}
-                            />
-                        )}
+                        <SidebarItem
+                            icon={BookOpen}
+                            label={t('navNotebook')}
+                            path="/notebook"
+                            isActive={location.pathname.startsWith('/notebook')}
+                            onClick={handleNavClick}
+                        />
                     </div>
 
                     <div className="space-y-0.5">
@@ -196,7 +194,7 @@ const WorkspaceLayout: React.FC = () => {
             {/* Main Content Area */}
             <main className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
                 {/* Top Header */}
-                <header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-[100]">
+                <header className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
                     <div className="flex items-center gap-4 flex-1">
                         <button
                             onClick={() => setIsSidebarOpen(!isSidebarOpen)}

+ 23 - 2
web/src/pages/workspace/ChatPage.tsx

@@ -1,15 +1,36 @@
-import React from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import { useAuth } from '../../contexts/AuthContext';
 import { ChatView } from '../../../components/views/ChatView';
+import { ModelConfig, DEFAULT_MODELS } from '../../../types';
+import { modelConfigService } from '../../../services/modelConfigService';
 
 export default function ChatPage() {
-    const { apiKey, logout } = useAuth();
+    const { apiKey, logout, user } = useAuth();
+    const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>(DEFAULT_MODELS);
+
+    const fetchModels = useCallback(async () => {
+        if (!apiKey) return;
+        try {
+            const backendModels = await modelConfigService.getAll(apiKey);
+            const map = new Map<string, ModelConfig>();
+            DEFAULT_MODELS.forEach(m => map.set(m.id, m));
+            backendModels.forEach(m => map.set(m.id, m));
+            setModelConfigs(Array.from(map.values()));
+        } catch {
+            setModelConfigs(DEFAULT_MODELS);
+        }
+    }, [apiKey]);
+
+    useEffect(() => { fetchModels(); }, [fetchModels]);
 
     return (
         <ChatView
             authToken={apiKey}
             onLogout={logout}
+            modelConfigs={modelConfigs}
             onNavigate={() => { }}
+            isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
         />
     );
 }
+

+ 19 - 2
web/src/pages/workspace/KnowledgePage.tsx

@@ -1,15 +1,32 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
 import { useAuth } from '../../contexts/AuthContext';
 import { KnowledgeBaseView } from '../../../components/views/KnowledgeBaseView';
+import { modelConfigService } from '../../../services/modelConfigService';
+import { ModelConfig } from '../../../types';
 
 export default function KnowledgePage() {
     const { apiKey, user, logout } = useAuth();
+    const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
+
+    useEffect(() => {
+        const fetchModels = async () => {
+            if (!apiKey) return;
+            try {
+                const models = await modelConfigService.getAll(apiKey);
+                setModelConfigs(models);
+            } catch (error) {
+                console.error('Failed to fetch model configs:', error);
+            }
+        };
+        fetchModels();
+    }, [apiKey]);
 
     return (
         <KnowledgeBaseView
-            authToken={apiKey}
+            authToken={apiKey || ''}
             onLogout={logout}
             onNavigate={() => { }}
+            modelConfigs={modelConfigs}
             isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
         />
     );

+ 5 - 0
web/types.ts

@@ -256,6 +256,9 @@ export interface AppSettings {
   enableQueryExpansion: boolean;
   enableHyDE: boolean;
 
+  chunkSize?: number;
+  chunkOverlap?: number;
+
   // Language
   language?: string;
 
@@ -282,6 +285,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
   hybridVectorWeight: 0.7, // Vector weight for hybrid search
   enableQueryExpansion: false,
   enableHyDE: false,
+  chunkSize: 1000,
+  chunkOverlap: 100,
   language: 'ja',
 };