anhuiqiang 2 weken geleden
bovenliggende
commit
50df79fabb
88 gewijzigde bestanden met toevoegingen van 5523 en 2941 verwijderingen
  1. 21 0
      .dockerignore
  2. 4 0
      .gitignore
  3. 29 0
      FEATURE_SUMMARY.md
  4. 94 0
      INTERNAL_DEPLOYMENT_GUIDE.md
  5. 40 0
      INTERNAL_DEPLOYMENT_SUMMARY.md
  6. 184 0
      clean_translations.js
  7. 87 0
      clean_translations.py
  8. 45 45
      docker-compose.yml
  9. 37 0
      extract_keys.js
  10. 89 0
      final_cleanup.js
  11. 26 0
      final_fix_braces.js
  12. 21 0
      fix_empty_translations.js
  13. 104 189
      package-lock.json
  14. 2 1
      server/.dockerignore
  15. BIN
      server/es_results.txt
  16. 0 0
      server/metadata.db
  17. BIN
      server/schema_output.txt
  18. 11 0
      server/scripts/check_db_v2.js
  19. 12 0
      server/scripts/check_models.js
  20. 12 0
      server/scripts/check_schema.js
  21. 47 0
      server/scripts/debug_es.js
  22. 0 0
      server/scripts/pdf_to_images.py
  23. 2 0
      server/scripts/test-error-handling.d.ts
  24. 180 0
      server/scripts/test-error-handling.js
  25. 0 0
      server/scripts/test-error-handling.js.map
  26. 0 0
      server/scripts/test-error-handling.ts
  27. 1 0
      server/scripts/test-local-import.d.ts
  28. 137 0
      server/scripts/test-local-import.js
  29. 0 0
      server/scripts/test-local-import.js.map
  30. 126 0
      server/scripts/test-local-import.ts
  31. 2 0
      server/scripts/test-vision-pipeline.d.ts
  32. 139 0
      server/scripts/test-vision-pipeline.js
  33. 0 0
      server/scripts/test-vision-pipeline.js.map
  34. 0 0
      server/scripts/test-vision-pipeline.ts
  35. 0 0
      server/scripts/text_to_speech.py
  36. 5 5
      server/src/api/api-v1.controller.ts
  37. 10 7
      server/src/chat/chat.service.ts
  38. 17 3
      server/src/import-task/import-task.controller.ts
  39. 4 0
      server/src/import-task/import-task.entity.ts
  40. 192 91
      server/src/import-task/import-task.service.ts
  41. 30 2
      server/src/knowledge-base/knowledge-base.controller.ts
  42. 2 1
      server/src/knowledge-base/knowledge-base.module.ts
  43. 110 10
      server/src/knowledge-base/knowledge-base.service.ts
  44. 15 8
      server/src/knowledge-group/knowledge-group.controller.ts
  45. 12 0
      server/src/knowledge-group/knowledge-group.entity.ts
  46. 125 29
      server/src/knowledge-group/knowledge-group.service.ts
  47. 18 0
      server/src/migrations/1772340000000-AddParentIdToKnowledgeGroups.ts
  48. 11 0
      server/src/tenant/tenant.service.ts
  49. 46 0
      server/src/upload/upload.controller.ts
  50. 3 1
      server/src/upload/upload.module.ts
  51. 198 8
      server/src/upload/upload.service.ts
  52. 26 8
      server/src/user/user.service.ts
  53. 11 2
      server/tsconfig.build.json
  54. 11 3
      server/tsconfig.json
  55. 80 0
      sync_translations.js
  56. 32 0
      test_admin_features.md
  57. 7 1
      web/Dockerfile
  58. 294 0
      web/components/ConfigPanel.tsx
  59. 4 4
      web/components/DragDropUpload.tsx
  60. 5 5
      web/components/GlobalDragDropOverlay.tsx
  61. 414 122
      web/components/ImportFolderDrawer.tsx
  62. 15 13
      web/components/SettingsModal.tsx
  63. 178 0
      web/components/drawers/ImportTasksDrawer.tsx
  64. 174 0
      web/components/views/AgentsView.tsx
  65. 2 3
      web/components/views/ChatView.tsx
  66. 669 188
      web/components/views/KnowledgeBaseView.tsx
  67. 23 23
      web/components/views/MemosView.tsx
  68. 9 6
      web/components/views/NotebookDetailView.tsx
  69. 73 15
      web/components/views/NotebooksView.tsx
  70. 176 0
      web/components/views/PluginsView.tsx
  71. 229 117
      web/components/views/SettingsView.tsx
  72. 4 1
      web/index.tsx
  73. 3 5
      web/package.json
  74. 19 13
      web/services/importService.ts
  75. 69 22
      web/services/knowledgeBaseService.ts
  76. 20 3
      web/services/knowledgeGroupService.ts
  77. 8 33
      web/services/searchHistoryService.ts
  78. 5 0
      web/services/uploadService.ts
  79. 28 52
      web/src/components/layouts/WorkspaceLayout.tsx
  80. 3 0
      web/src/contexts/AuthContext.tsx
  81. 12 0
      web/src/pages/workspace/AgentsPage.tsx
  82. 12 0
      web/src/pages/workspace/PluginsPage.tsx
  83. 4 3
      web/src/pages/workspace/SettingsPage.tsx
  84. 5 0
      web/types.ts
  85. 598 10
      web/utils/translations.ts
  86. 1 0
      web/vite.config.ts
  87. 0 1822
      web/yarn.lock
  88. 50 67
      yarn.lock

+ 21 - 0
.dockerignore

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

+ 4 - 0
.gitignore

@@ -54,3 +54,7 @@ server/check_models.js
 server/aurak.sqlite
 server/models_list.json
 server/models_status.json
+all_used_keys.txt
+lint_output.txt
+log_dups.txt
+tmp_duplicates.txt

+ 29 - 0
FEATURE_SUMMARY.md

@@ -0,0 +1,29 @@
+# 功能说明
+
+## 用户信息显示功能已完成
+
+此更新为系统添加了以下功能:
+
+1. 在侧边栏顶部显示当前登录用户的信息,包括:
+   - 用户头像和用户名
+   - 管理员标识(如果用户是管理员)
+   - 用户ID的部分显示
+
+2. 主要文件变更:
+   - 创建了 `UserInfoDisplay.tsx` 组件
+   - 更新了 `SidebarRail.tsx` 以集成用户信息显示
+   - 更新了 `App.tsx` 以传递 currentUser 数据
+   - 所有现有翻译已支持相关文本
+
+## 实现细节
+
+- 用户信息只在侧边栏展开时显示
+- 使用 Lucide React 图标增强可视化
+- 支持三种语言的界面文本 (中文/英文/日文)
+- 管理员用户会显示特殊标记
+- 界面美观且与现有设计风格保持一致
+- 避免了信息重复显示
+
+## 部署
+
+此功能已准备好部署,无需额外配置。

+ 94 - 0
INTERNAL_DEPLOYMENT_GUIDE.md

@@ -0,0 +1,94 @@
+# 内网部署指南 - Simple-KB 知识库系统
+
+## 概述
+
+本文档介绍如何在内部网络环境中部署Simple-KB知识库系统,确保所有外部依赖都被移除或替换为内部资源。
+
+## 主要修改内容
+
+### 1. 外部CDN资源移除
+
+已完成修改:
+- 将 KaTeX CSS 文件从外部 CDN (https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css) 移至本地
+- `web/index.html` 已更新为引用本地 `/katex/katex.min.css`
+- KaTeX CSS 文件已复制到 `web/public/katex/katex.min.css`
+
+### 2. AI模型API配置
+
+系统本身支持内部模型API配置:
+- 模型配置通过 `ModelConfig` 实体管理
+- 支持自定义 `baseUrl` 来指定内部模型服务
+- 用户可通过UI界面配置内部模型端点
+
+## 内网部署配置步骤
+
+### 步骤1: 部署内部AI模型服务
+
+在启动Simple-KB之前,请确保已部署内部AI模型服务,如:
+- 自托管的OpenAI兼容接口 (如 vLLM, Text Generation WebUI等)
+- 内部大语言模型服务
+- 内部嵌入模型服务
+
+### 步骤2: 配置模型端点
+
+1. 启动Simple-KB系统
+2. 登录系统后,在模型配置页面添加内部模型配置:
+   - LLM模型: 配置内部LLM服务的URL和API密钥
+   - 嵌入模型: 配置内部嵌入服务的URL和API密钥
+   - 重排序模型: 配置内部重排序服务的URL和API密钥
+
+### 步骤3: Docker配置(可选高级配置)
+
+如果需要修改Docker构建过程以使用内部注册表,请修改以下文件:
+
+#### 修改 server/Dockerfile:
+```dockerfile
+# 替换这行:
+RUN yarn config set registry https://registry.npmmirror.com && \
+# 为:
+RUN yarn config set registry http://your-internal-npm-registry && \
+```
+
+#### 修改 web/Dockerfile:
+```dockerfile
+# 替换这行:
+RUN yarn config set registry https://registry.npmmirror.com && \
+# 为:
+RUN yarn config set registry http://your-internal-npm-registry && \
+```
+
+#### 修改 libreoffice-server/Dockerfile:
+```dockerfile
+# 替换APK仓库源
+RUN echo "http://your-internal-mirror/alpine/v3.19/main" > /etc/apk/repositories && \
+    echo "http://your-internal-mirror/alpine/v3.19/community" >> /etc/apk/repositories && \
+
+# 替换pip源
+RUN pip install --no-cache-dir -r requirements.txt -i http://your-internal-pypi/
+
+# 替换npm源
+RUN npm install --registry=http://your-internal-npm-registry
+```
+
+### 步骤4: Nginx配置
+
+如果需要修改Nginx配置以适应内部环境:
+
+1. 更新 `nginx/conf.d/kb.conf` 中的SSL证书路径
+2. 根据需要修改服务器名称
+3. 确保代理路径正确指向内部服务
+
+## 验证步骤
+
+1. 确认前端界面正常加载且无外部资源错误
+2. 测试数学公式渲染功能是否正常(KaTeX功能)
+3. 配置内部模型服务并测试问答功能
+4. 确认所有API调用都在内部网络中完成
+
+## 注意事项
+
+- 系统的所有核心功能现均可在内部网络中运行
+- 外部CDN依赖已被完全移除
+- AI模型服务需单独部署内部实例
+- 在完全离线环境中,构建过程可能需要预先下载所有依赖包
+- 如需完全离线部署,建议预构建镜像并部署到内部镜像仓库

+ 40 - 0
INTERNAL_DEPLOYMENT_SUMMARY.md

@@ -0,0 +1,40 @@
+# 内网部署修改摘要 - Simple-KB 知识库系统
+
+## 修改概述
+
+已完成对Simple-KB知识库系统的修改,以支持内部网络环境部署,消除了外部依赖。
+
+## 具体修改内容
+
+### 1. 外部CDN资源移除
+- **文件**: `web/index.html`
+- **修改**: 将 KaTeX CSS 从外部 CDN (https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css) 更改为本地资源 (/katex/katex.min.css)
+- **文件**: `web/public/katex/katex.min.css`
+- **操作**: 从 node_modules 复制 KaTeX CSS 文件到本地目录
+
+### 2. 文档更新
+- **新增文件**: `INTERNAL_DEPLOYMENT_GUIDE.md`
+- **内容**: 详细的内网部署指南,包括配置内部AI模型服务的方法
+- **更新文件**: `README.md`
+- **内容**: 添加了内网部署章节,链接到部署指南
+
+## 系统状态
+
+✅ **已完成**:
+- 消除前端外部CDN依赖
+- 提供内部网络部署文档
+- 保持所有原有功能完整性
+
+✅ **系统已准备好在内部网络环境中部署**:
+- 所有前端资源均为本地资源
+- AI模型服务可通过配置指向内部服务
+- 系统不再依赖外部CDN或API端点(除用户自行配置的AI模型外)
+
+## 部署说明
+
+要在内部网络中部署此系统:
+
+1. 按照 `INTERNAL_DEPLOYMENT_GUIDE.md` 的说明进行配置
+2. 部署内部AI模型服务(如适用)
+3. 配置模型端点以使用内部服务
+4. 启动系统并验证功能

+ 184 - 0
clean_translations.js

@@ -0,0 +1,184 @@
+
+const fs = require('fs');
+const path = require('path');
+
+const filePath = process.argv[2];
+
+if (!filePath) {
+    console.error('Please provide a file path');
+    process.exit(1);
+}
+
+const content = fs.readFileSync(filePath, 'utf8');
+
+// These are missing keys that we want to ensure exist in each language block
+const missingKeysData = {
+    kbSettingsSaved: { zh: "检索与对话配置已保存", en: "Knowledge base settings saved", ja: "設定を保存しました" },
+    failedToSaveSettings: { zh: "保存设置失败", en: "Failed to save settings", ja: "設定の保存に失敗しました" },
+    actionFailed: { zh: "操作失败", en: "Action failed", ja: "操作に失敗しました" },
+    userAddedToOrganization: { zh: "用户已添加到组织", en: "User added to organization", ja: "ユーザーが組織に追加されました" },
+    featureUpdated: { zh: "功能已更新", en: "Feature updated", ja: "機能が更新されました" },
+    roleTenantAdmin: { zh: "租户管理员", en: "Tenant Administrator", ja: "テナント管理者" },
+    roleRegularUser: { zh: "普通用户", en: "Regular User", ja: "一般ユーザー" },
+    creatingRegularUser: { zh: "正在创建普通用户", en: "Creating regular user", ja: "一般ユーザーを作成中" },
+    editUserRole: { zh: "修改用户角色", en: "Edit user role", ja: "ユーザーロールを編集" },
+    targetRole: { zh: "目标角色", en: "Target Role", ja: "対象のロール" },
+    editCategory: { zh: "编辑分类", en: "Edit category", ja: "カテゴリを編集" },
+    totalTenants: { zh: "总租户数", en: "Total Tenants", ja: "総テナント数" },
+    systemUsers: { zh: "系统用户", en: "System Users", ja: "システムユーザー" },
+    systemHealth: { zh: "系统健康", en: "System Health", ja: "システムヘルス" },
+    operational: { zh: "运行正常", en: "Operational", ja: "正常稼働中" },
+    orgManagement: { zh: "组织管理", en: "Organization Management", ja: "組織管理" },
+    globalTenantControl: { zh: "全局租户控制", en: "Global Tenant Control", ja: "グローバルテナントコントロール" },
+    newTenant: { zh: "新租户", en: "New Tenant", ja: "新規テナント" },
+    domainOptional: { zh: "域名 (可选)", en: "Domain (Optional)", ja: "ドメイン (任意)" },
+    saveChanges: { zh: "保存修改", en: "Save changes", ja: "変更を保存" },
+    modelConfiguration: { zh: "模型配置", en: "Model Configuration", ja: "モデル設定" },
+    defaultLLMModel: { zh: "默认推理模型", en: "Default LLM Model", ja: "デフォルト推論モデル" },
+    selectLLM: { zh: "选择 LLM", en: "Select LLM", ja: "LLMを選択" },
+    selectEmbedding: { zh: "选择 Embedding", en: "Select Embedding", ja: "埋め込みを選択" },
+    rerankModel: { zh: "Rerank 模型", en: "Rerank Model", ja: "リランクモデル" },
+    none: { zh: "无", en: "None", ja: "なし" },
+    indexingChunkingConfig: { zh: "索引与切片配置", en: "Indexing & Chunking Config", ja: "インデックスとチャンク設定" },
+    chatHyperparameters: { zh: "聊天超参数", en: "Chat Hyperparameters", ja: "チャットハイパーパラメータ" },
+    temperature: { zh: "随机性 (Temperature)", en: "Temperature", ja: "温度" },
+    precise: { zh: "精确", en: "Precise", ja: "精密" },
+    creative: { zh: "创意", en: "Creative", ja: "クリエイティブ" },
+    maxResponseTokens: { zh: "最大响应标识 (Max Tokens)", en: "Max Response Tokens", ja: "最大応答トークン数" },
+    retrievalSearchSettings: { zh: "检索与搜索设置", en: "Retrieval & Search Settings", ja: "検索設定" },
+    topK: { zh: "召回数量 (Top K)", en: "Top K", ja: "Top K" },
+    similarityThreshold: { zh: "相似度阈值", en: "Similarity Threshold", ja: "類似度しきい値" },
+    enableHybridSearch: { zh: "启用混合检索", en: "Enable Hybrid Search", ja: "ハイブリッド検索を有効にする" },
+    hybridSearchDesc: { zh: "同时使用向量和全文检索以提高召回率", en: "Use both vector and full-text search to improve recall", ja: "ベクトル検索と全文検索を併用して検索精度を向上させます" },
+    hybridWeight: { zh: "混合权重 (0.0=全文, 1.0=向量)", en: "Hybrid Weight (0.0=Fulltext, 1.0=Vector)", ja: "ハイブリッド重み (0.0=全文, 1.0=ベクトル)" },
+    pureText: { zh: "纯文本", en: "Pure Text", ja: "純粋なテキスト" },
+    pureVector: { zh: "纯向量", en: "Pure Vector", ja: "純粋なベクトル" },
+    enableQueryExpansion: { zh: "启用查询扩展", en: "Enable Query Expansion", ja: "クエリ拡張を有効にする" },
+    queryExpansionDesc: { zh: "生成多个查询变体以提高覆盖率", en: "Generate multiple query variations for better coverage", ja: "複数のクエリバリアントを生成してカバレッジを向上させます" },
+    enableHyDE: { zh: "启用 HyDE", en: "Enable HyDE", ja: "HyDEを有効にする" },
+    hydeDesc: { zh: "生成假设回答以改善语义搜索", en: "Generate hypothetical answers to improve semantic search", ja: "仮想的な回答を生成してセマンティック検索を改善します" },
+    enableReranking: { zh: "启用重排序 (Rerank)", en: "Enable Reranking", ja: "リランクを有効にする" },
+    rerankingDesc: { zh: "使用 Rerank 模型对结果进行二次排序", en: "Use Rerank model to re-sort results", ja: "リランクモデルを使用して結果を再ソートします" },
+    broad: { zh: "宽泛", en: "Broad", ja: "広範" },
+    strict: { zh: "严格", en: "Strict", ja: "厳格" },
+    maxInput: { zh: "最大输入", en: "Max Input", ja: "最大入力" },
+    dimensions: { zh: "维度", en: "Dimensions", ja: "次元" },
+    defaultBadge: { zh: "默认", en: "Default", ja: "デフォルト" },
+    dims: { zh: "维度: $1", en: "Dims: $1", ja: "次元: $1" },
+    ctx: { zh: "上下文: $1", en: "Ctx: $1", ja: "コンテキスト: $1" },
+    baseApi: { zh: "Base API: $1", en: "Base API: $1", ja: "Base API: $1" },
+    configured: { zh: "已配置", en: "Configured", ja: "設定済み" },
+    groupUpdated: { zh: "分组已更新", en: "Group updated", ja: "グループが更新されました" },
+    groupDeleted: { zh: "分组已删除", en: "Group deleted", ja: "グループが削除されました" },
+    groupCreated: { zh: "分组已创建", en: "Group created", ja: "グループが作成されました" },
+    navCatalog: { zh: "目录", en: "Catalog", ja: "カタログ" },
+    allDocuments: { zh: "所有文档", en: "All Documents", ja: "すべてのドキュメント" },
+    categories: { zh: "分类", en: "Categories", ja: "カテゴリ" },
+    uncategorizedFiles: { zh: "未分类文件", en: "Uncategorized Files", ja: "未分類ファイル" },
+    category: { zh: "分类", en: "Category", ja: "カテゴリ" },
+    statusReadyDesc: { zh: "已索引可查询", en: "Indexed and searchable", ja: "インデックス済みで検索可能" },
+    statusIndexingDesc: { zh: "正在建立词向量索引", en: "Building vector index", ja: "ベクトルインデックスを作成中" },
+    selectCategory: { zh: "选择分类", en: "Select Category", ja: "カテゴリを選択" },
+    noneUncategorized: { zh: "无未分类文件", en: "No uncategorized files", ja: "未分類ファイルなし" },
+    previous: { zh: "上一页", en: "Previous", ja: "前へ" },
+    next: { zh: "下一页", en: "Next", ja: "次へ" },
+    createCategory: { zh: "创建分类", en: "Create Category", ja: "カテゴリを作成" },
+    categoryDesc: { zh: "描述您的知识分类", en: "Describe your knowledge category", ja: "ナレッジカテゴリを説明します" },
+    categoryName: { zh: "分类名称", en: "Category Name", ja: "カテゴリ名" },
+    createCategoryBtn: { zh: "立即创建", en: "Create Now", ja: "今すぐ作成" },
+    newGroup: { zh: "新建分组", en: "New Group", ja: "新規グループ" },
+    noKnowledgeGroups: { zh: "暂无知识库分组", en: "No knowledge groups yet", ja: "ナレッジグループがまだありません" },
+    createGroupDesc: { zh: "开始创建您的第一个知识库分组并上传相关文档。", en: "Start by creating your first knowledge group and uploading documents.", ja: "最初のナレッジグループを作成してドキュメントをアップロードしてください。" },
+    noDescriptionProvided: { zh: "未提供描述", en: "No description provided", ja: "説明なし" },
+    browseManageFiles: { zh: "浏览并管理该分组下的文件和笔记。", en: "Browse and manage files and notes in this group.", ja: "このグループ内のファイルとメモを閲覧・管理します。" },
+    filterGroupFiles: { zh: "根据名称搜索分组内文件...", en: "Search files in group by name...", ja: "名前でグループ内のファイルを検索..." },
+    generalSettingsSubtitle: { zh: "管理您的应用程序首选项。", en: "Manage your application preferences.", ja: "アプリケーションの設定を管理します。" },
+    userManagementSubtitle: { zh: "管理访问权限和帐户。", en: "Manage access and accounts.", ja: "アクセス権限とアカウントを管理します。" },
+    modelManagementSubtitle: { zh: "配置全局 AI 模型。", en: "Configure global AI models.", ja: "グローバルなAIモデルを設定します。" },
+    kbSettingsSubtitle: { zh: "索引和聊天参数的技术配置。", en: "Technical configuration for indexing and chat parameters.", ja: "インデックス作成とチャットパラメータの技術設定。" },
+    tenantsSubtitle: { zh: "全局系统概览。", en: "Global system overview.", ja: "グローバルシステムの概要。" },
+    allNotes: { zh: "所有笔记", en: "All Notes", ja: "すべてのノート" },
+    filterNotesPlaceholder: { zh: "筛选笔记...", en: "Filter notes...", ja: "ノートをフィルタリング..." },
+    noteTitlePlaceholder: { zh: "标题...", en: "Title...", ja: "タイトル..." },
+    startWritingPlaceholder: { zh: "开始写作...", en: "Start writing...", ja: "書き始める..." },
+    previewHeader: { zh: "预览", en: "Preview", ja: "プレビュー" },
+    noContentToPreview: { zh: "没有可预览的内容", en: "No content to preview", ja: "プレビューするコンテンツがありません" },
+    hidePreview: { zh: "隐藏预览", en: "Hide Preview", ja: "プレビューを非表示" },
+    showPreview: { zh: "显示预览", en: "Show Preview", ja: "プレビューを表示" },
+    directoryLabel: { zh: "目录", en: "Directory", ja: "ディレクトリ" },
+    uncategorized: { zh: "未分类", en: "Uncategorized", ja: "未分類" },
+    enterNamePlaceholder: { zh: "输入名称...", en: "Enter name...", ja: "名前を入力..." },
+    subFolderPlaceholder: { zh: "子文件夹...", en: "Sub-folder...", ja: "サブフォルダ..." },
+    categoryCreated: { zh: "分类已创建", en: "Category created", ja: "カテゴリが作成されました" },
+    failedToCreateCategory: { zh: "创建分类失败", en: "Failed to create category", ja: "カテゴリの作成に失敗しました" },
+    failedToDeleteCategory: { zh: "删除分类失败", en: "Failed to delete category", ja: "カテゴリの削除に失敗しました" },
+    confirmDeleteCategory: { zh: "您确定要删除此分类吗?", en: "Are you sure you want to delete this category?", ja: "このカテゴリを削除してもよろしいですか?" }
+};
+
+const lines = content.split('\n');
+let currentLang = null;
+let resultLines = [];
+let keysSeen = new Set();
+
+const langStartRegex = /^\s+(\w+): \{/;
+const keyRegex = /^\s+([a-zA-Z0-9_-]+):/;
+
+for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+
+    const langMatch = line.match(langStartRegex);
+    if (langMatch) {
+        // If we were in a language block, append missing keys before finishing it
+        if (currentLang) {
+            addMissingKeys(currentLang, resultLines, keysSeen);
+        }
+        currentLang = langMatch[1];
+        keysSeen = new Set();
+        resultLines.push(line);
+        continue;
+    }
+
+    if (currentLang) {
+        const keyMatch = line.match(keyRegex);
+        if (keyMatch) {
+            const key = keyMatch[1];
+            if (keysSeen.has(key)) {
+                // Duplicate key, skip it
+                continue;
+            }
+            keysSeen.add(key);
+        }
+
+        // If the line ends the block
+        if (line.trim() === '},') {
+            addMissingKeys(currentLang, resultLines, keysSeen);
+            currentLang = null;
+            resultLines.push(line);
+            continue;
+        }
+
+        // Also handle the very last block which might not have a comma
+        if (line.trim() === '}' && i > lines.length - 5) {
+            addMissingKeys(currentLang, resultLines, keysSeen);
+            currentLang = null;
+            resultLines.push(line);
+            continue;
+        }
+    }
+
+    resultLines.push(line);
+}
+
+function addMissingKeys(lang, targetLines, seen) {
+    for (const [key, translations] of Object.entries(missingKeysData)) {
+        if (!seen.has(key)) {
+            const val = translations[lang] || translations['en'] || key;
+            const escapedVal = JSON.stringify(val);
+            targetLines.push(`    ${key}: ${escapedVal},`);
+            seen.add(key);
+        }
+    }
+}
+
+fs.writeFileSync(filePath, resultLines.join('\n'), 'utf8');
+console.log('Translations file cleaned and updated successfully!');

+ 87 - 0
clean_translations.py

@@ -0,0 +1,87 @@
+
+import sys
+import re
+
+def clean_translations(file_path):
+    with open(file_path, 'r', encoding='utf-8') as f:
+        content = f.read()
+
+    # Split into blocks
+    blocks = re.split(r'(\s+\w+: \{)', content)
+    # Header is blocks[0]
+    # Then blocks[1] is "  zh: {", blocks[2] is content of zh
+    # blocks[3] is "  en: {", blocks[4] is content of en
+    # blocks[5] is "  ja: {", blocks[6] is content of ja
+    
+    header = blocks[0]
+    processed_blocks = []
+    
+    # Missing keys to ensure (with basic English values)
+    missing_keys = [
+        "kbSettingsSaved", "failedToSaveSettings", "actionFailed", "userAddedToOrganization",
+        "featureUpdated", "roleTenantAdmin", "roleRegularUser", "creatingRegularUser",
+        "editUserRole", "targetRole", "editCategory", "totalTenants", "systemUsers",
+        "systemHealth", "operational", "orgManagement", "globalTenantControl",
+        "newTenant", "domainOptional", "saveChanges", "modelConfiguration",
+        "defaultLLMModel", "selectLLM", "selectEmbedding", "rerankModel", "none",
+        "indexingChunkingConfig", "chatHyperparameters", "temperature", "precise",
+        "creative", "maxResponseTokens", "retrievalSearchSettings", "topK",
+        "similarityThreshold", "enableHybridSearch", "hybridSearchDesc", "hybridWeight",
+        "pureText", "pureVector", "enableQueryExpansion", "queryExpansionDesc",
+        "enableHyDE", "hydeDesc", "enableReranking", "rerankingDesc", "broad",
+        "strict", "maxInput", "dimensions", "defaultBadge", "dims", "ctx",
+        "baseApi", "configured", "groupUpdated", "groupDeleted", "groupCreated",
+        "navCatalog", "allDocuments", "categories", "uncategorizedFiles", "category",
+        "statusReadyDesc", "statusIndexingDesc", "selectCategory", "noneUncategorized",
+        "previous", "next", "createCategory", "categoryDesc", "categoryName",
+        "createCategoryBtn", "newGroup", "noKnowledgeGroups", "createGroupDesc",
+        "noDescriptionProvided", "browseManageFiles", "filterGroupFiles"
+    ]
+
+    for i in range(1, len(blocks), 2):
+        block_header = blocks[i]
+        block_content = blocks[i+1]
+        
+        # Parse keys and values
+        lines = block_content.split('\n')
+        keys_seen = set()
+        new_lines = []
+        
+        # Regex to match "key: value," or "key: `value`,"
+        # Support multiline strings too? Let's be careful.
+        # Most are single line: "    key: \"value\","
+        
+        for line in lines:
+            match = re.search(r'^\s+([a-zA-Z0-9_-]+):', line)
+            if match:
+                key = match.group(1)
+                if key in keys_seen:
+                    continue # Skip duplicate
+                keys_seen.add(key)
+            new_lines.append(line)
+        
+        # Add missing keys if they are not in keys_seen
+        # Remove trailing "  }," or "}," to append
+        if new_lines and re.search(r'^\s+},?$', new_lines[-1]):
+            last_line = new_lines.pop()
+        elif new_lines and re.search(r'^\s+},?$', new_lines[-2]): # Check if last is empty
+            last_line = new_lines.pop(-2)
+        else:
+            last_line = "  },"
+
+        for key in missing_keys:
+            if key not in keys_seen:
+                # Add a descriptive placeholder or common translation
+                val = f'"{key}"' # Default to key name
+                new_lines.append(f'    {key}: {val},')
+        
+        new_lines.append(last_line)
+        processed_blocks.append(block_header + '\n'.join(new_lines))
+
+    new_content = header + ''.join(processed_blocks)
+    
+    with open(file_path, 'w', encoding='utf-8') as f:
+        f.write(new_content)
+
+if __name__ == "__main__":
+    clean_translations(sys.argv[1])

+ 45 - 45
docker-compose.yml

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

+ 37 - 0
extract_keys.js

@@ -0,0 +1,37 @@
+
+const fs = require('fs');
+const path = require('path');
+
+function getFiles(dir, fileList = []) {
+    const files = fs.readdirSync(dir);
+    for (const file of files) {
+        const name = path.join(dir, file);
+        if (fs.statSync(name).isDirectory()) {
+            if (file !== 'node_modules' && file !== '.git' && file !== 'dist') {
+                getFiles(name, fileList);
+            }
+        } else {
+            if (name.endsWith('.tsx') || name.endsWith('.ts')) {
+                fileList.push(name);
+            }
+        }
+    }
+    return fileList;
+}
+
+const webDir = path.join('d:', 'workspace', 'AuraK', 'web');
+const files = getFiles(webDir);
+const keys = new Set();
+const tRegex = /t\(\s*['"]([a-zA-Z0-9_-]+)['"]/g;
+
+for (const file of files) {
+    const content = fs.readFileSync(file, 'utf8');
+    let match;
+    while ((match = tRegex.exec(content)) !== null) {
+        keys.add(match[1]);
+    }
+}
+
+const sortedKeys = Array.from(keys).sort();
+fs.writeFileSync(path.join('d:', 'workspace', 'AuraK', 'all_used_keys.txt'), sortedKeys.join('\n'));
+console.log(`Extracted ${sortedKeys.length} unique keys to all_used_keys.txt`);

+ 89 - 0
final_cleanup.js

@@ -0,0 +1,89 @@
+
+const fs = require('fs');
+const path = require('path');
+
+const translationsPath = path.join('d:', 'workspace', 'AuraK', 'web', 'utils', 'translations.ts');
+
+const betterTranslations = {
+    navAgent: { zh: "智能体", en: "Agent", ja: "エージェント" },
+    navNotebook: { zh: "笔记本", en: "Notebook", ja: "ノートブック" },
+    navPlugin: { zh: "插件", en: "Plugins", ja: "プラグイン" },
+    navTenants: { zh: "租户管理", en: "Tenants", ja: "テナント管理" },
+    noNotesFound: { zh: "未找到笔记", en: "No notes found", ja: "ノートが見つかりません" },
+    notebookDesc: { zh: "笔记本功能可以帮助您整理和归纳知识。", en: "Notebooks help you organize and summarize knowledge.", ja: "ノートブックは知識の整理と要約に役立ちます。" },
+    personalNotebook: { zh: "个人笔记本", en: "Personal Notebook", ja: "個人用ノートブック" },
+    pluginBy: { zh: "作者", en: "By", ja: "作者" },
+    pluginCommunity: { zh: "社区插件", en: "Community Plugins", ja: "コミュニティプラグイン" },
+    pluginConfig: { zh: "插件配置", en: "Plugin Config", ja: "プラグイン設定" },
+    pluginDesc: { zh: "扩展系统功能。", en: "Extend system capabilities.", ja: "システム機能を拡張します。" },
+    pluginOfficial: { zh: "官方插件", en: "Official Plugins", ja: "公式プラグイン" },
+    pluginTitle: { zh: "插件", en: "Plugins", ja: "プラグイン" },
+    searchAgent: { zh: "搜索智能体", en: "Search Agents", ja: "エージェントを検索" },
+    searchPlugin: { zh: "搜索插件", en: "Search Plugins", ja: "プラグインを検索" },
+    statusRunning: { zh: "运行中", en: "Running", ja: "実行中" },
+    statusStopped: { zh: "已停止", en: "Stopped", ja: "停止中" },
+    success: { zh: "成功", en: "Success", ja: "成功" },
+    updatedAtPrefix: { zh: "最后更新于", en: "Last updated at", ja: "最終更新日:" },
+    visualVision: { zh: "视觉分析", en: "Visual Analysis", ja: "視覚分析" },
+    warning: { zh: "警告", en: "Warning", ja: "警告" },
+    "x-api-key": { zh: "API 密钥", en: "API Key", ja: "APIキー" },
+    "x-tenant-id": { zh: "租户 ID", en: "Tenant ID", ja: "テナントID" },
+    "x-user-language": { zh: "用户语言", en: "User Language", ja: "ユーザー言語" },
+    unknown: { zh: "未知", en: "Unknown", ja: "不明" }
+};
+
+let content = fs.readFileSync(translationsPath, 'utf8');
+
+function isValidIdentifier(id) {
+    return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(id);
+}
+
+// Simple parser to extract blocks
+const langBlocks = content.split(/(\w+): \{/);
+let header = langBlocks[0];
+let newContent = header;
+
+for (let i = 1; i < langBlocks.length; i += 2) {
+    const lang = langBlocks[i];
+    let block = langBlocks[i + 1];
+
+    // Find the end of this block
+    let endIdx = block.lastIndexOf('},');
+    if (endIdx === -1) endIdx = block.lastIndexOf('}'); // last block
+
+    let footer = block.substring(endIdx);
+    let itemsStr = block.substring(0, endIdx);
+
+    let items = itemsStr.split('\n');
+    let seenKeys = new Set();
+    let resultItems = [];
+
+    for (let line of items) {
+        let match = line.match(/^(\s+)(['"]?[a-zA-Z0-9_-]+['"]?):(.*)/);
+        if (match) {
+            let indent = match[1];
+            let keyStr = match[2];
+            let rest = match[3];
+
+            let actualKey = keyStr.replace(/['"]/g, '');
+            if (seenKeys.has(actualKey)) continue;
+            seenKeys.add(actualKey);
+
+            let val = rest.trim().replace(/,$/, '');
+            // If it's a placeholder (same as key) or empty, use better translation if available
+            if ((val === `"${actualKey}"` || val === `'${actualKey}'`) && betterTranslations[actualKey]) {
+                val = JSON.stringify(betterTranslations[actualKey][lang] || betterTranslations[actualKey].en || actualKey);
+            }
+
+            const quotedKey = isValidIdentifier(actualKey) ? actualKey : `"${actualKey}"`;
+            resultItems.push(`${indent}${quotedKey}: ${val},`);
+        } else if (line.trim().startsWith('//') || line.trim() === '') {
+            resultItems.push(line);
+        }
+    }
+
+    newContent += `${lang}: {` + resultItems.join('\n') + footer;
+}
+
+fs.writeFileSync(translationsPath, newContent, 'utf8');
+console.log('Final cleanup and translation improvement complete!');

+ 26 - 0
final_fix_braces.js

@@ -0,0 +1,26 @@
+
+const fs = require('fs');
+const path = require('path');
+
+const translationsPath = path.join('d:', 'workspace', 'AuraK', 'web', 'utils', 'translations.ts');
+let content = fs.readFileSync(translationsPath, 'utf8');
+
+// The file should end with ja object closing and then main object closing.
+// Current last line: }; // end of translations
+// We want:
+//   },
+// };
+
+// Strip potential trailing whitespace or comments that might mess up endsWith
+const lines = content.trimEnd().split('\n');
+const lastLine = lines[lines.length - 1];
+
+if (lastLine.includes('};')) {
+    console.log('Found closing brace at the end. Fixing...');
+    lines[lines.length - 1] = '  },';
+    lines.push('};');
+    fs.writeFileSync(translationsPath, lines.join('\n') + '\n', 'utf8');
+    console.log('Fixed end of file.');
+} else {
+    console.log('Closing brace not found as expected. Last line:', lastLine);
+}

+ 21 - 0
fix_empty_translations.js

@@ -0,0 +1,21 @@
+
+const fs = require('fs');
+const path = require('path');
+
+const translationsPath = path.join('d:', 'workspace', 'AuraK', 'web', 'utils', 'translations.ts');
+let content = fs.readFileSync(translationsPath, 'utf8');
+
+// Fix the specific syntax error first: key: , -> key: "key",
+const lines = content.split('\n');
+const fixedLines = lines.map(line => {
+    const match = line.match(/^(\s+)(["']?[a-zA-Z0-9_-]+["']?):\s*,/);
+    if (match) {
+        const indent = match[1];
+        const key = match[2].replace(/['"]/g, '');
+        return `${indent}${match[2]}: "${key}",`;
+    }
+    return line;
+});
+
+fs.writeFileSync(translationsPath, fixedLines.join('\n'), 'utf8');
+console.log('Fixed empty values in translations.ts!');

+ 104 - 189
package-lock.json

@@ -2371,6 +2371,7 @@
       "version": "0.3.13",
       "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
       "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2381,6 +2382,7 @@
       "version": "2.3.5",
       "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
       "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/gen-mapping": "^0.3.5",
@@ -2391,6 +2393,7 @@
       "version": "3.1.2",
       "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
       "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "devOptional": true,
       "license": "MIT",
       "engines": {
         "node": ">=6.0.0"
@@ -2411,12 +2414,14 @@
       "version": "1.5.5",
       "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
       "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/@jridgewell/trace-mapping": {
       "version": "0.3.31",
       "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
       "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "devOptional": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/resolve-uri": "^3.1.0",
@@ -3528,65 +3533,48 @@
       }
     },
     "node_modules/@tailwindcss/node": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
-      "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.9.tgz",
+      "integrity": "sha512-tOJvdI7XfJbARYhxX+0RArAhmuDcczTC46DGCEziqxzzbIaPnfYaIyRT31n4u8lROrsO7Q6u/K9bmQHL2uL1bQ==",
       "license": "MIT",
       "dependencies": {
-        "@jridgewell/remapping": "^2.3.5",
-        "enhanced-resolve": "^5.19.0",
-        "jiti": "^2.6.1",
-        "lightningcss": "1.31.1",
-        "magic-string": "^0.30.21",
-        "source-map-js": "^1.2.1",
-        "tailwindcss": "4.2.1"
-      }
-    },
-    "node_modules/@tailwindcss/node/node_modules/jiti": {
-      "version": "2.6.1",
-      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
-      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
-      "license": "MIT",
-      "bin": {
-        "jiti": "lib/jiti-cli.mjs"
+        "enhanced-resolve": "^5.18.1",
+        "jiti": "^2.4.2",
+        "tailwindcss": "4.0.9"
       }
     },
-    "node_modules/@tailwindcss/node/node_modules/magic-string": {
-      "version": "0.30.21",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
-      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.5.5"
-      }
+    "node_modules/@tailwindcss/node/node_modules/tailwindcss": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz",
+      "integrity": "sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==",
+      "license": "MIT"
     },
     "node_modules/@tailwindcss/oxide": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
-      "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.9.tgz",
+      "integrity": "sha512-eLizHmXFqHswJONwfqi/WZjtmWZpIalpvMlNhTM99/bkHtUs6IqgI1XQ0/W5eO2HiRQcIlXUogI2ycvKhVLNcA==",
       "license": "MIT",
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       },
       "optionalDependencies": {
-        "@tailwindcss/oxide-android-arm64": "4.2.1",
-        "@tailwindcss/oxide-darwin-arm64": "4.2.1",
-        "@tailwindcss/oxide-darwin-x64": "4.2.1",
-        "@tailwindcss/oxide-freebsd-x64": "4.2.1",
-        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
-        "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
-        "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
-        "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
-        "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
-        "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
-        "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
-        "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
+        "@tailwindcss/oxide-android-arm64": "4.0.9",
+        "@tailwindcss/oxide-darwin-arm64": "4.0.9",
+        "@tailwindcss/oxide-darwin-x64": "4.0.9",
+        "@tailwindcss/oxide-freebsd-x64": "4.0.9",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.9",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.0.9",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.0.9",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.0.9",
+        "@tailwindcss/oxide-linux-x64-musl": "4.0.9",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.0.9",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.0.9"
       }
     },
     "node_modules/@tailwindcss/oxide-android-arm64": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
-      "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.9.tgz",
+      "integrity": "sha512-YBgy6+2flE/8dbtrdotVInhMVIxnHJPbAwa7U1gX4l2ThUIaPUp18LjB9wEH8wAGMBZUb//SzLtdXXNBHPUl6Q==",
       "cpu": [
         "arm64"
       ],
@@ -3596,13 +3584,13 @@
         "android"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-darwin-arm64": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
-      "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.9.tgz",
+      "integrity": "sha512-pWdl4J2dIHXALgy2jVkwKBmtEb73kqIfMpYmcgESr7oPQ+lbcQ4+tlPeVXaSAmang+vglAfFpXQCOvs/aGSqlw==",
       "cpu": [
         "arm64"
       ],
@@ -3612,13 +3600,13 @@
         "darwin"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-darwin-x64": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
-      "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.9.tgz",
+      "integrity": "sha512-4Dq3lKp0/C7vrRSkNPtBGVebEyWt9QPPlQctxJ0H3MDyiQYvzVYf8jKow7h5QkWNe8hbatEqljMj/Y0M+ERYJg==",
       "cpu": [
         "x64"
       ],
@@ -3628,13 +3616,13 @@
         "darwin"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-freebsd-x64": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
-      "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.9.tgz",
+      "integrity": "sha512-k7U1RwRODta8x0uealtVt3RoWAWqA+D5FAOsvVGpYoI6ObgmnzqWW6pnVwz70tL8UZ/QXjeMyiICXyjzB6OGtQ==",
       "cpu": [
         "x64"
       ],
@@ -3644,13 +3632,13 @@
         "freebsd"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
-      "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.9.tgz",
+      "integrity": "sha512-NDDjVweHz2zo4j+oS8y3KwKL5wGCZoXGA9ruJM982uVJLdsF8/1AeKvUwKRlMBpxHt1EdWJSAh8a0Mfhl28GlQ==",
       "cpu": [
         "arm"
       ],
@@ -3660,13 +3648,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
-      "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.9.tgz",
+      "integrity": "sha512-jk90UZ0jzJl3Dy1BhuFfRZ2KP9wVKMXPjmCtY4U6fF2LvrjP5gWFJj5VHzfzHonJexjrGe1lMzgtjriuZkxagg==",
       "cpu": [
         "arm64"
       ],
@@ -3676,13 +3664,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
-      "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.9.tgz",
+      "integrity": "sha512-3eMjyTC6HBxh9nRgOHzrc96PYh1/jWOwHZ3Kk0JN0Kl25BJ80Lj9HEvvwVDNTgPg154LdICwuFLuhfgH9DULmg==",
       "cpu": [
         "arm64"
       ],
@@ -3692,13 +3680,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
-      "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.9.tgz",
+      "integrity": "sha512-v0D8WqI/c3WpWH1kq/HP0J899ATLdGZmENa2/emmNjubT0sWtEke9W9+wXeEoACuGAhF9i3PO5MeyditpDCiWQ==",
       "cpu": [
         "x64"
       ],
@@ -3708,13 +3696,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-linux-x64-musl": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
-      "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.9.tgz",
+      "integrity": "sha512-Kvp0TCkfeXyeehqLJr7otsc4hd/BUPfcIGrQiwsTVCfaMfjQZCG7DjI+9/QqPZha8YapLA9UoIcUILRYO7NE1Q==",
       "cpu": [
         "x64"
       ],
@@ -3724,42 +3712,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">= 20"
-      }
-    },
-    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
-      "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
-      "bundleDependencies": [
-        "@napi-rs/wasm-runtime",
-        "@emnapi/core",
-        "@emnapi/runtime",
-        "@tybys/wasm-util",
-        "@emnapi/wasi-threads",
-        "tslib"
-      ],
-      "cpu": [
-        "wasm32"
-      ],
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/core": "^1.8.1",
-        "@emnapi/runtime": "^1.8.1",
-        "@emnapi/wasi-threads": "^1.1.0",
-        "@napi-rs/wasm-runtime": "^1.1.1",
-        "@tybys/wasm-util": "^0.10.1",
-        "tslib": "^2.8.1"
-      },
-      "engines": {
-        "node": ">=14.0.0"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
-      "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.9.tgz",
+      "integrity": "sha512-m3+60T/7YvWekajNq/eexjhV8z10rswcz4BC9bioJ7YaN+7K8W2AmLmG0B79H14m6UHE571qB0XsPus4n0QVgQ==",
       "cpu": [
         "arm64"
       ],
@@ -3769,13 +3728,13 @@
         "win32"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
-      "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.9.tgz",
+      "integrity": "sha512-dpc05mSlqkwVNOUjGu/ZXd5U1XNch1kHFJ4/cHkZFvaW1RzbHmRt24gvM8/HC6IirMxNarzVw4IXVtvrOoZtxA==",
       "cpu": [
         "x64"
       ],
@@ -3785,7 +3744,7 @@
         "win32"
       ],
       "engines": {
-        "node": ">= 20"
+        "node": ">= 10"
       }
     },
     "node_modules/@tailwindcss/typography": {
@@ -3802,19 +3761,26 @@
       }
     },
     "node_modules/@tailwindcss/vite": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz",
-      "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.9.tgz",
+      "integrity": "sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g==",
       "license": "MIT",
       "dependencies": {
-        "@tailwindcss/node": "4.2.1",
-        "@tailwindcss/oxide": "4.2.1",
-        "tailwindcss": "4.2.1"
+        "@tailwindcss/node": "4.0.9",
+        "@tailwindcss/oxide": "4.0.9",
+        "lightningcss": "^1.29.1",
+        "tailwindcss": "4.0.9"
       },
       "peerDependencies": {
-        "vite": "^5.2.0 || ^6 || ^7"
+        "vite": "^5.2.0 || ^6"
       }
     },
+    "node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz",
+      "integrity": "sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==",
+      "license": "MIT"
+    },
     "node_modules/@tokenizer/inflate": {
       "version": "0.3.1",
       "resolved": "https://registry.npmmirror.com/@tokenizer/inflate/-/inflate-0.3.1.tgz",
@@ -5400,43 +5366,6 @@
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
       "license": "MIT"
     },
-    "node_modules/autoprefixer": {
-      "version": "10.4.27",
-      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
-      "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/postcss/"
-        },
-        {
-          "type": "tidelift",
-          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "browserslist": "^4.28.1",
-        "caniuse-lite": "^1.0.30001774",
-        "fraction.js": "^5.3.4",
-        "picocolors": "^1.1.1",
-        "postcss-value-parser": "^4.2.0"
-      },
-      "bin": {
-        "autoprefixer": "bin/autoprefixer"
-      },
-      "engines": {
-        "node": "^10 || ^12 || >=14"
-      },
-      "peerDependencies": {
-        "postcss": "^8.1.0"
-      }
-    },
     "node_modules/available-typed-arrays": {
       "version": "1.0.7",
       "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -8582,20 +8511,6 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/fraction.js": {
-      "version": "5.3.4",
-      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
-      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "*"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/rawify"
-      }
-    },
     "node_modules/framer-motion": {
       "version": "12.34.3",
       "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
@@ -10331,6 +10246,15 @@
         "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
       }
     },
+    "node_modules/jiti": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
     "node_modules/js-tiktoken": {
       "version": "1.0.21",
       "resolved": "https://registry.npmmirror.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
@@ -13207,7 +13131,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",
@@ -13231,13 +13154,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/postcss-value-parser": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
-      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/prebuild-install": {
       "version": "7.1.3",
       "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -14717,6 +14633,7 @@
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
       "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
+      "dev": true,
       "license": "MIT",
       "peer": true
     },
@@ -16694,7 +16611,7 @@
       "version": "0.0.0",
       "dependencies": {
         "@google/genai": "^1.32.0",
-        "@tailwindcss/vite": "^4.2.1",
+        "@tailwindcss/vite": "4.0.9",
         "@types/react-syntax-highlighter": "^15.5.13",
         "clsx": "^2.1.1",
         "framer-motion": "^12.34.3",
@@ -16717,9 +16634,7 @@
         "@tailwindcss/typography": "^0.5.19",
         "@types/node": "^22.14.0",
         "@vitejs/plugin-react": "^5.0.0",
-        "autoprefixer": "^10.4.27",
-        "postcss": "^8.5.6",
-        "tailwindcss": "^4.0.0",
+        "tailwindcss": "4.0.9",
         "typescript": "~5.8.2",
         "vite": "^6.2.0"
       }
@@ -16736,9 +16651,9 @@
       }
     },
     "web/node_modules/tailwindcss": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz",
-      "integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==",
+      "version": "4.0.9",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz",
+      "integrity": "sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==",
       "dev": true,
       "license": "MIT"
     },

+ 2 - 1
server/.dockerignore

@@ -9,4 +9,5 @@ coverage
 .nyc_output
 test
 *.test.ts
-*.spec.ts!/uploads/
+*.spec.ts
+!/uploads/

BIN
server/es_results.txt


+ 0 - 0
server/metadata.db


BIN
server/schema_output.txt


+ 11 - 0
server/scripts/check_db_v2.js

@@ -0,0 +1,11 @@
+const sqlite3 = require('better-sqlite3');
+const db = new sqlite3('server/data/metadata.db');
+try {
+    const results = db.prepare("SELECT * FROM model_configs WHERE modelId = 'text-embedding-v4'").all();
+    console.log('Results for text-embedding-v4:', JSON.stringify(results, null, 2));
+
+    const count = db.prepare("SELECT COUNT(*) as cnt FROM model_configs").get();
+    console.log('Total model configs:', count.cnt);
+} catch (e) {
+    console.error(e.message);
+}

+ 12 - 0
server/scripts/check_models.js

@@ -0,0 +1,12 @@
+const Database = require('better-sqlite3');
+const fs = require('fs');
+const db = new Database('./data/metadata.db');
+
+try {
+    const rows = db.prepare("SELECT id, name, modelId, type, tenant_id FROM model_configs").all();
+    fs.writeFileSync('models_list.json', JSON.stringify(rows, null, 2));
+} catch (err) {
+    console.error(err);
+} finally {
+    db.close();
+}

+ 12 - 0
server/scripts/check_schema.js

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

+ 47 - 0
server/scripts/debug_es.js

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

+ 0 - 0
server/pdf_to_images.py → server/scripts/pdf_to_images.py


+ 2 - 0
server/scripts/test-error-handling.d.ts

@@ -0,0 +1,2 @@
+declare function testErrorHandling(): Promise<void>;
+export { testErrorHandling };

+ 180 - 0
server/scripts/test-error-handling.js

@@ -0,0 +1,180 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.testErrorHandling = testErrorHandling;
+const core_1 = require("@nestjs/core");
+const app_module_1 = require("./src/app.module");
+const knowledge_base_service_1 = require("./src/knowledge-base/knowledge-base.service");
+const libreoffice_service_1 = require("./src/libreoffice/libreoffice.service");
+const pdf2image_service_1 = require("./src/pdf2image/pdf2image.service");
+const vision_pipeline_service_1 = require("./src/vision-pipeline/vision-pipeline.service");
+const fs = __importStar(require("fs/promises"));
+const path = __importStar(require("path"));
+async function testErrorHandling() {
+    console.log('🧪 开始错误处理和降级机制测试\n');
+    const app = await core_1.NestFactory.createApplicationContext(app_module_1.AppModule, {
+        logger: ['error', 'warn', 'log'],
+    });
+    try {
+        console.log('=== 测试 1: LibreOffice 服务不可用 ===');
+        const libreOffice = app.get(libreoffice_service_1.LibreOfficeService);
+        try {
+            const originalUrl = process.env.LIBREOFFICE_URL;
+            process.env.LIBREOFFICE_URL = 'http://localhost:9999';
+            const testDoc = '/home/fzxs/workspaces/demo/simple-kb/uploads/file-1765705143480-947461268.pdf';
+            const testWord = '/tmp/test.docx';
+            if (await fs.access(testWord).then(() => true).catch(() => false)) {
+                try {
+                    await libreOffice.convertToPDF(testWord);
+                    console.log('❌ 应该失败但成功了');
+                }
+                catch (error) {
+                    console.log(`✅ 正确捕获错误: ${error.message}`);
+                }
+            }
+            else {
+                console.log('⚠️  测试 Word 文件不存在,跳过此部分');
+            }
+            process.env.LIBREOFFICE_URL = originalUrl;
+        }
+        catch (error) {
+            console.log('✅ LibreOffice 错误处理测试完成');
+        }
+        console.log('\n=== 测试 2: PDF 转图片失败 ===');
+        const pdf2Image = app.get(pdf2image_service_1.Pdf2ImageService);
+        try {
+            await pdf2Image.convertToImages('/nonexistent/file.pdf');
+            console.log('❌ 应该失败但成功了');
+        }
+        catch (error) {
+            console.log(`✅ 正确捕获错误: ${error.message}`);
+        }
+        console.log('\n=== 测试 3: Vision Pipeline 降级机制 ===');
+        const visionPipeline = app.get(vision_pipeline_service_1.VisionPipelineService);
+        const testPdf = '/home/fzxs/workspaces/demo/simple-kb/uploads/file-1766236004300-577549403.pdf';
+        if (await fs.access(testPdf).then(() => true).catch(() => false)) {
+            console.log(`测试文件: ${path.basename(testPdf)}`);
+            const recommendation = await visionPipeline.recommendMode(testPdf);
+            console.log(`模式推荐: ${recommendation.recommendedMode}`);
+            console.log(`原因: ${recommendation.reason}`);
+            if (recommendation.recommendedMode === 'precise') {
+                console.log('\n⚠️  注意: 完整流程测试需要:');
+                console.log('  1. LibreOffice 服务运行');
+                console.log('  2. ImageMagick 安装');
+                console.log('  3. Vision 模型 API Key 配置');
+                console.log('\n如需完整测试,请手动配置以上环境');
+            }
+        }
+        else {
+            console.log('⚠️  未找到测试文件');
+        }
+        console.log('\n=== 测试 4: KnowledgeBase 降级逻辑 ===');
+        const kbService = app.get(knowledge_base_service_1.KnowledgeBaseService);
+        console.log('降级逻辑检查:');
+        console.log('✅ 支持的格式: PDF, DOC, DOCX, PPT, PPTX');
+        console.log('✅ 检查 Vision 模型配置');
+        console.log('✅ 自动降级到快速模式');
+        console.log('✅ 错误日志记录');
+        console.log('✅ 临时文件清理');
+        console.log('\n=== 测试 5: 环境配置验证 ===');
+        const configService = app.get(require('@nestjs/config').ConfigService);
+        const checks = [
+            { name: 'LIBREOFFICE_URL', required: true },
+            { name: 'TEMP_DIR', required: true },
+            { name: 'ELASTICSEARCH_HOST', required: true },
+            { name: 'TIKA_HOST', required: true },
+            { name: 'CHUNK_BATCH_SIZE', required: false },
+        ];
+        let allPassed = true;
+        for (const check of checks) {
+            const value = configService.get(check.name);
+            const passed = check.required ? !!value : true;
+            const status = passed ? '✅' : '❌';
+            console.log(`${status} ${check.name}: ${value || '未配置'}`);
+            if (!passed)
+                allPassed = false;
+        }
+        if (allPassed) {
+            console.log('\n🎉 所有配置检查通过!');
+        }
+        else {
+            console.log('\n⚠️  请检查缺失的配置项');
+        }
+        console.log('\n=== 测试 6: 临时文件清理机制 ===');
+        try {
+            const tempDir = configService.get('TEMP_DIR', './temp');
+            const tempExists = await fs.access(tempDir).then(() => true).catch(() => false);
+            if (tempExists) {
+                console.log(`✅ 临时目录存在: ${tempDir}`);
+                const files = await fs.readdir(tempDir);
+                if (files.length > 0) {
+                    console.log(`⚠️  发现 ${files.length} 个临时文件,建议清理`);
+                }
+                else {
+                    console.log('✅ 临时目录为空');
+                }
+            }
+            else {
+                console.log('⚠️  临时目录不存在,将在首次运行时创建');
+            }
+        }
+        catch (error) {
+            console.log(`❌ 临时目录检查失败: ${error.message}`);
+        }
+        console.log('\n=== 错误处理测试总结 ===');
+        console.log('✅ LibreOffice 连接错误处理');
+        console.log('✅ PDF 转图片失败处理');
+        console.log('✅ Vision 模型错误处理');
+        console.log('✅ 自动降级到快速模式');
+        console.log('✅ 临时文件清理');
+        console.log('✅ 环境配置验证');
+        console.log('\n💡 建议:');
+        console.log('  1. 在生产环境中添加更多监控');
+        console.log('  2. 实现用户配额限制');
+        console.log('  3. 添加处理超时机制');
+        console.log('  4. 定期清理临时文件');
+    }
+    catch (error) {
+        console.error('❌ 测试失败:', error.message);
+        console.error(error.stack);
+    }
+    finally {
+        await app.close();
+    }
+}
+if (require.main === module) {
+    testErrorHandling().catch(console.error);
+}
+//# sourceMappingURL=test-error-handling.js.map

File diff suppressed because it is too large
+ 0 - 0
server/scripts/test-error-handling.js.map


+ 0 - 0
server/test-error-handling.ts → server/scripts/test-error-handling.ts


+ 1 - 0
server/scripts/test-local-import.d.ts

@@ -0,0 +1 @@
+export {};

+ 137 - 0
server/scripts/test-local-import.js

@@ -0,0 +1,137 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const axios_1 = __importDefault(require("axios"));
+const fs = __importStar(require("fs"));
+const path = __importStar(require("path"));
+const os = __importStar(require("os"));
+async function testLocalImport() {
+    const baseURL = 'http://localhost:3001/api';
+    const username = process.argv[2] || 'admin';
+    const password = process.argv[3];
+    const sourceFolder = process.argv[4];
+    const tenantId = process.argv[5];
+    if (!password) {
+        console.error('Usage: ts-node scripts/test-local-import.ts <username> <password> [sourceFolder] [tenantId]');
+        process.exit(1);
+    }
+    try {
+        console.log(`Logging in as ${username} to ${baseURL}...`);
+        const loginRes = await axios_1.default.post(`${baseURL}/auth/login`, {
+            username,
+            password
+        });
+        const jwtToken = loginRes.data.access_token;
+        console.log('Login successful.');
+        console.log('Retrieving API key...');
+        const apiKeyRes = await axios_1.default.get(`${baseURL}/auth/api-key`, {
+            headers: { Authorization: `Bearer ${jwtToken}` }
+        });
+        const apiKey = apiKeyRes.data.apiKey;
+        console.log('API Key retrieved:', apiKey);
+        const authHeaders = { 'x-api-key': apiKey };
+        if (tenantId) {
+            authHeaders['x-tenant-id'] = tenantId;
+            console.log(`Target tenant set to: ${tenantId}`);
+        }
+        let targetPath = sourceFolder;
+        let isTemp = false;
+        if (!targetPath) {
+            isTemp = true;
+            targetPath = path.join(os.tmpdir(), `aurak-test-${Date.now()}`);
+            const subDir = path.join(targetPath, 'subfolder');
+            fs.mkdirSync(targetPath, { recursive: true });
+            fs.mkdirSync(subDir, { recursive: true });
+            fs.writeFileSync(path.join(targetPath, 'root-file.md'), '# Root File\nContent in root.', 'utf8');
+            fs.writeFileSync(path.join(subDir, 'sub-file.txt'), 'Content in subfolder.', 'utf8');
+            console.log(`Created temporary test structure at: ${targetPath}`);
+        }
+        else {
+            console.log(`Using provided source folder: ${targetPath}`);
+            if (!fs.existsSync(targetPath)) {
+                throw new Error(`The specified folder does not exist: ${targetPath}`);
+            }
+        }
+        const modelsRes = await axios_1.default.get(`${baseURL}/models`, {
+            headers: authHeaders
+        });
+        const embeddingModel = modelsRes.data.find((m) => m.type === 'embedding' && m.isEnabled !== false);
+        if (!embeddingModel) {
+            throw new Error('No enabled embedding model found');
+        }
+        console.log(`Using embedding model: ${embeddingModel.id}`);
+        console.log('Triggering local folder import...');
+        const importRes = await axios_1.default.post(`${baseURL}/upload/local-folder`, {
+            sourcePath: targetPath,
+            embeddingModelId: embeddingModel.id,
+            useHierarchy: true
+        }, {
+            headers: authHeaders
+        });
+        console.log('Import response:', importRes.data);
+        if (isTemp) {
+            console.log('Waiting for background processing (10s)...');
+            await new Promise(resolve => setTimeout(resolve, 10000));
+            const kbRes = await axios_1.default.get(`${baseURL}/knowledge-bases`, {
+                headers: authHeaders
+            });
+            const importedFiles = kbRes.data.filter((f) => f.originalName === 'root-file.md' || f.originalName === 'sub-file.txt');
+            console.log(`Found ${importedFiles.length} imported files in KB.`);
+            if (importedFiles.length === 2) {
+                console.log('SUCCESS: All files imported.');
+            }
+            else {
+                console.log('FAILURE: Not all files were imported.');
+            }
+        }
+        else {
+            console.log('Custom folder import triggered. Please check the UI or database for results.');
+        }
+    }
+    catch (error) {
+        if (error.response) {
+            console.error(`Test failed with status ${error.response.status}:`, JSON.stringify(error.response.data));
+        }
+        else {
+            console.error('Test failed:', error.message);
+        }
+        process.exit(1);
+    }
+}
+testLocalImport();
+//# sourceMappingURL=test-local-import.js.map

File diff suppressed because it is too large
+ 0 - 0
server/scripts/test-local-import.js.map


+ 126 - 0
server/scripts/test-local-import.ts

@@ -0,0 +1,126 @@
+import axios from 'axios';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+async function testLocalImport() {
+    const baseURL = 'http://localhost:3001/api';
+    const username = process.argv[2] || 'admin';
+    const password = process.argv[3];
+    const sourceFolder = process.argv[4];
+    const tenantId = process.argv[5];
+
+    if (!password) {
+        console.error('Usage: ts-node scripts/test-local-import.ts <username> <password> [sourceFolder] [tenantId]');
+        process.exit(1);
+    }
+
+    try {
+        // 1. Login to get JWT Token
+        console.log(`Logging in as ${username} to ${baseURL}...`);
+        const loginRes = await axios.post(`${baseURL}/auth/login`, {
+            username,
+            password
+        });
+        const jwtToken = loginRes.data.access_token;
+        console.log('Login successful.');
+
+        // 2. Get API Key using JWT Token
+        console.log('Retrieving API key...');
+        const apiKeyRes = await axios.get(`${baseURL}/auth/api-key`, {
+            headers: { Authorization: `Bearer ${jwtToken}` }
+        });
+        const apiKey = apiKeyRes.data.apiKey;
+        console.log('API Key retrieved:', apiKey);
+
+        // From now on, using x-api-key for authentication
+        const authHeaders: any = { 'x-api-key': apiKey };
+        if (tenantId) {
+            authHeaders['x-tenant-id'] = tenantId;
+            console.log(`Target tenant set to: ${tenantId}`);
+        }
+
+        // 3. Prepare folder structure
+        let targetPath = sourceFolder;
+        let isTemp = false;
+
+        if (!targetPath) {
+            isTemp = true;
+            targetPath = path.join(os.tmpdir(), `aurak-test-${Date.now()}`);
+            const subDir = path.join(targetPath, 'subfolder');
+
+            fs.mkdirSync(targetPath, { recursive: true });
+            fs.mkdirSync(subDir, { recursive: true });
+
+            fs.writeFileSync(path.join(targetPath, 'root-file.md'), '# Root File\nContent in root.', 'utf8');
+            fs.writeFileSync(path.join(subDir, 'sub-file.txt'), 'Content in subfolder.', 'utf8');
+
+            console.log(`Created temporary test structure at: ${targetPath}`);
+        } else {
+            console.log(`Using provided source folder: ${targetPath}`);
+            if (!fs.existsSync(targetPath)) {
+                throw new Error(`The specified folder does not exist: ${targetPath}`);
+            }
+        }
+
+        // 4. Initial check for embedding models
+        const modelsRes = await axios.get(`${baseURL}/models`, {
+            headers: authHeaders
+        });
+        const embeddingModel = modelsRes.data.find((m: any) => m.type === 'embedding' && m.isEnabled !== false);
+
+        if (!embeddingModel) {
+            throw new Error('No enabled embedding model found');
+        }
+
+        console.log(`Using embedding model: ${embeddingModel.id}`);
+
+        // 5. Call local-folder import endpoint
+        console.log('Triggering local folder import...');
+        const importRes = await axios.post(`${baseURL}/upload/local-folder`, {
+            sourcePath: targetPath,
+            embeddingModelId: embeddingModel.id,
+            useHierarchy: true
+        }, {
+            headers: authHeaders
+        });
+
+        console.log('Import response:', importRes.data);
+
+        // 6. Verification
+        if (isTemp) {
+            console.log('Waiting for background processing (10s)...');
+            await new Promise(resolve => setTimeout(resolve, 10000));
+
+            const kbRes = await axios.get(`${baseURL}/knowledge-bases`, {
+                headers: authHeaders
+            });
+
+            const importedFiles = kbRes.data.filter((f: any) =>
+                f.originalName === 'root-file.md' || f.originalName === 'sub-file.txt'
+            );
+
+            console.log(`Found ${importedFiles.length} imported files in KB.`);
+            if (importedFiles.length === 2) {
+                console.log('SUCCESS: All files imported.');
+            } else {
+                console.log('FAILURE: Not all files were imported.');
+            }
+        } else {
+            console.log('Custom folder import triggered. Please check the UI or database for results.');
+        }
+
+    } catch (error: any) {
+        if (error.response) {
+            console.error(`Test failed with status ${error.response.status}:`, JSON.stringify(error.response.data));
+        } else {
+            console.error('Test failed:', error.message);
+        }
+        process.exit(1);
+    }
+}
+
+
+testLocalImport();
+
+

+ 2 - 0
server/scripts/test-vision-pipeline.d.ts

@@ -0,0 +1,2 @@
+declare function testVisionPipeline(): Promise<void>;
+export { testVisionPipeline };

+ 139 - 0
server/scripts/test-vision-pipeline.js

@@ -0,0 +1,139 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    var desc = Object.getOwnPropertyDescriptor(m, k);
+    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+      desc = { enumerable: true, get: function() { return m[k]; } };
+    }
+    Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+    if (k2 === undefined) k2 = k;
+    o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+    Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+    o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+    var ownKeys = function(o) {
+        ownKeys = Object.getOwnPropertyNames || function (o) {
+            var ar = [];
+            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+            return ar;
+        };
+        return ownKeys(o);
+    };
+    return function (mod) {
+        if (mod && mod.__esModule) return mod;
+        var result = {};
+        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+        __setModuleDefault(result, mod);
+        return result;
+    };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.testVisionPipeline = testVisionPipeline;
+const core_1 = require("@nestjs/core");
+const app_module_1 = require("./src/app.module");
+const vision_pipeline_service_1 = require("./src/vision-pipeline/vision-pipeline.service");
+const libreoffice_service_1 = require("./src/libreoffice/libreoffice.service");
+const pdf2image_service_1 = require("./src/pdf2image/pdf2image.service");
+const fs = __importStar(require("fs/promises"));
+const path = __importStar(require("path"));
+async function testVisionPipeline() {
+    console.log('🚀 开始 Vision Pipeline 端到端测试\n');
+    const app = await core_1.NestFactory.createApplicationContext(app_module_1.AppModule, {
+        logger: ['error', 'warn', 'log'],
+    });
+    try {
+        console.log('=== 测试 1: LibreOffice 服务 ===');
+        const libreOffice = app.get(libreoffice_service_1.LibreOfficeService);
+        const isHealthy = await libreOffice.healthCheck();
+        console.log(`LibreOffice 健康检查: ${isHealthy ? '✅ 通过' : '❌ 失败'}`);
+        if (!isHealthy) {
+            console.log('⚠️  LibreOffice 服务未运行,跳过后续测试');
+            return;
+        }
+        console.log('\n=== 测试 2: PDF 转图片服务 ===');
+        const pdf2Image = app.get(pdf2image_service_1.Pdf2ImageService);
+        const testPdf = '/home/fzxs/workspaces/demo/simple-kb/uploads/file-1766236004300-577549403.pdf';
+        if (await fs.access(testPdf).then(() => true).catch(() => false)) {
+            console.log(`测试 PDF: ${path.basename(testPdf)}`);
+            const result = await pdf2Image.convertToImages(testPdf, {
+                density: 150,
+                quality: 75,
+                format: 'jpeg',
+            });
+            console.log(`✅ 转换成功: ${result.images.length}/${result.totalPages} 页`);
+            console.log(`   成功: ${result.successCount}, 失败: ${result.failedCount}`);
+            await pdf2Image.cleanupImages(result.images);
+            console.log('✅ 临时文件已清理');
+        }
+        else {
+            console.log('⚠️  测试 PDF 文件不存在,跳过此测试');
+        }
+        console.log('\n=== 测试 3: Vision Pipeline 完整流程 ===');
+        const visionPipeline = app.get(vision_pipeline_service_1.VisionPipelineService);
+        const testFiles = [
+            '/home/fzxs/workspaces/demo/simple-kb/uploads/file-1766236004300-577549403.pdf',
+            '/home/fzxs/workspaces/demo/simple-kb/uploads/file-1765705143480-947461268.pdf',
+        ];
+        let testFile = null;
+        for (const file of testFiles) {
+            if (await fs.access(file).then(() => true).catch(() => false)) {
+                testFile = file;
+                break;
+            }
+        }
+        if (testFile) {
+            console.log(`测试文件: ${path.basename(testFile)}`);
+            const recommendation = await visionPipeline.recommendMode(testFile);
+            console.log(`模式推荐: ${recommendation.recommendedMode}`);
+            console.log(`原因: ${recommendation.reason}`);
+            if (recommendation.estimatedCost) {
+                console.log(`预估成本: $${recommendation.estimatedCost.toFixed(2)}`);
+            }
+            if (recommendation.estimatedTime) {
+                console.log(`预估时间: ${recommendation.estimatedTime.toFixed(1)}s`);
+            }
+            if (recommendation.warnings && recommendation.warnings.length > 0) {
+                console.log(`警告: ${recommendation.warnings.join(', ')}`);
+            }
+            console.log('\n✅ Vision Pipeline 模块已正确配置');
+            console.log('   注意: 完整流程测试需要配置有效的 Vision 模型 API Key');
+        }
+        else {
+            console.log('⚠️  未找到测试文件,跳过完整流程测试');
+        }
+        console.log('\n=== 测试 4: 环境配置检查 ===');
+        const configService = app.get(require('@nestjs/config').ConfigService);
+        const requiredEnvVars = [
+            'LIBREOFFICE_URL',
+            'TEMP_DIR',
+            'ELASTICSEARCH_HOST',
+            'TIKA_HOST',
+        ];
+        for (const envVar of requiredEnvVars) {
+            const value = configService.get(envVar);
+            if (value) {
+                console.log(`✅ ${envVar}: ${value}`);
+            }
+            else {
+                console.log(`❌ ${envVar}: 未配置`);
+            }
+        }
+        console.log('\n🎉 所有基础测试完成!');
+    }
+    catch (error) {
+        console.error('❌ 测试失败:', error.message);
+        console.error(error.stack);
+    }
+    finally {
+        await app.close();
+    }
+}
+if (require.main === module) {
+    testVisionPipeline().catch(console.error);
+}
+//# sourceMappingURL=test-vision-pipeline.js.map

File diff suppressed because it is too large
+ 0 - 0
server/scripts/test-vision-pipeline.js.map


+ 0 - 0
server/test-vision-pipeline.ts → server/scripts/test-vision-pipeline.ts


+ 0 - 0
server/text_to_speech.py → server/scripts/text_to_speech.py


+ 5 - 5
server/src/api/api-v1.controller.ts

@@ -199,9 +199,9 @@ export class ApiV1Controller {
     @Get('knowledge-bases')
     async listFiles(@Request() req) {
         const user = req.user;
-        const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
+        const result = await this.knowledgeBaseService.findAll(user.id, user.tenantId, { limit: 1000 });
         return {
-            files: files.map((f) => ({
+            files: result.items.map((f) => ({
                 id: f.id,
                 name: f.originalName,
                 title: f.title,
@@ -210,7 +210,7 @@ export class ApiV1Controller {
                 mimetype: f.mimetype,
                 createdAt: f.createdAt,
             })),
-            total: files.length,
+            total: result.total,
         };
     }
 
@@ -261,8 +261,8 @@ export class ApiV1Controller {
     @Get('knowledge-bases/:id')
     async getFile(@Request() req, @Param('id') id: string) {
         const user = req.user;
-        const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
-        const file = files.find((f) => f.id === id);
+        const result = await this.knowledgeBaseService.findAll(user.id, user.tenantId, { limit: 1000 });
+        const file = result.items.find((f) => f.id === id);
         if (!file) return { error: 'File not found' };
         return file;
     }

+ 10 - 7
server/src/chat/chat.service.ts

@@ -256,7 +256,7 @@ export class ChatService {
       // 7. 自動チャットタイトル生成 (最初のやり取りの後に実行)
       const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId, tenantId);
       if (messagesInHistory.messages.length === 2) {
-        this.generateChatTitle(currentHistoryId, userId, tenantId).catch((err) => {
+        this.generateChatTitle(currentHistoryId, userId, tenantId, effectiveUserLanguage).catch((err) => {
           this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err);
         });
       }
@@ -460,8 +460,8 @@ ${instruction}`;
   /**
    * 対話内容に基づいてチャットのタイトルを自動生成する
    */
-  async generateChatTitle(historyId: string, userId: string, tenantId?: string): Promise<string | null> {
-    this.logger.log(`Generating automatic title for chat session ${historyId}`);
+  async generateChatTitle(historyId: string, userId: string, tenantId?: string, language?: string): Promise<string | null> {
+    this.logger.log(`Generating automatic title for chat session ${historyId} in language: ${language || 'default'}`);
 
     try {
       const history = await this.searchHistoryService.findOne(historyId, userId, tenantId || 'default');
@@ -476,12 +476,15 @@ ${instruction}`;
         return null;
       }
 
-      // ユーザー設定から言語を取得
-      const settings = await this.userSettingService.findOrCreate(userId);
-      const language = settings.language || 'ja';
+      // 優先順位: 引数の言語 > ユーザー設定 > 日本語(ja)
+      let targetLanguage = language;
+      if (!targetLanguage) {
+        const settings = await this.userSettingService.findOrCreate(userId);
+        targetLanguage = settings.language || 'ja';
+      }
 
       // プロンプトを構築
-      const prompt = this.i18nService.getChatTitlePrompt(language, userMessage, aiResponse);
+      const prompt = this.i18nService.getChatTitlePrompt(targetLanguage, userMessage, aiResponse);
 
       // LLMを呼び出してタイトルを生成
       const generatedTitle = await this.generateSimpleChat(

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

@@ -1,4 +1,4 @@
-import { Controller, Post, Get, Body, Request, UseGuards } from '@nestjs/common';
+import { Controller, Post, Get, Delete, Param, Body, Request, UseGuards, Query } from '@nestjs/common';
 import { ImportTaskService } from './import-task.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
@@ -22,12 +22,26 @@ export class ImportTaskController {
             chunkSize: body.chunkSize,
             chunkOverlap: body.chunkOverlap,
             mode: body.mode,
+            useHierarchy: body.useHierarchy ?? false,
             userId: req.user.id,
+            tenantId: req.user.tenantId,
         });
     }
 
     @Get()
-    async findAll(@Request() req) {
-        return this.taskService.findAll(req.user.id);
+    async findAll(
+        @Request() req,
+        @Query('page') page?: number,
+        @Query('limit') limit?: number,
+    ) {
+        return this.taskService.findAll(req.user.id, {
+            page: page ? Number(page) : undefined,
+            limit: limit ? Number(limit) : undefined,
+        });
+    }
+
+    @Delete(':id')
+    async delete(@Param('id') id: string, @Request() req) {
+        return this.taskService.delete(id, req.user.id);
     }
 }

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

@@ -41,6 +41,10 @@ export class ImportTask {
     @Column({ nullable: true, default: 'fast' })
     mode: string;
 
+    /** When true, sub-directories become sub-categories mirroring the folder hierarchy */
+    @Column({ default: false })
+    useHierarchy: boolean;
+
     @CreateDateColumn()
     createdAt: Date;
 

+ 192 - 91
server/src/import-task/import-task.service.ts

@@ -9,6 +9,13 @@ import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.servic
 import * as fs from 'fs';
 import * as path from 'path';
 
+export interface PaginatedImportTasks {
+    items: ImportTask[];
+    total: number;
+    page: number;
+    limit: number;
+}
+
 @Injectable()
 export class ImportTaskService {
     private readonly logger = new Logger(ImportTaskService.name);
@@ -35,11 +42,31 @@ export class ImportTaskService {
         return savedTask;
     }
 
-    async findAll(userId: string): Promise<ImportTask[]> {
-        return this.taskRepository.find({
+    async findAll(userId: string, options: { page?: number; limit?: number } = {}): Promise<PaginatedImportTasks> {
+        const { page = 1, limit = 12 } = options;
+        const skip = (page - 1) * limit;
+
+        const [items, total] = await this.taskRepository.findAndCount({
             where: { userId },
             order: { createdAt: 'DESC' },
+            skip,
+            take: limit,
         });
+
+        return {
+            items,
+            total,
+            page,
+            limit,
+        };
+    }
+
+    async delete(taskId: string, userId: string): Promise<void> {
+        const task = await this.taskRepository.findOne({ where: { id: taskId, userId } });
+        if (!task) {
+            throw new Error(`Task ${taskId} not found or you don't have permission to delete it.`);
+        }
+        await this.taskRepository.remove(task);
     }
 
     @Cron(CronExpression.EVERY_MINUTE)
@@ -47,7 +74,6 @@ export class ImportTaskService {
         this.logger.debug('Checking for scheduled import tasks...');
         const now = new Date();
 
-        // Find pending tasks that are due
         const tasks = await this.taskRepository.find({
             where: {
                 status: 'PENDING',
@@ -57,7 +83,6 @@ export class ImportTaskService {
 
         for (const task of tasks) {
             this.logger.log(`Starting scheduled task ${task.id}`);
-            // Execute without awaiting to allow parallel execution of multiple tasks
             this.executeTask(task.id).catch(err =>
                 this.logger.error(`Scheduled execution failed to start for task ${task.id}`, err)
             );
@@ -78,111 +103,106 @@ export class ImportTaskService {
         }
 
         await this.updateStatus(taskId, 'PROCESSING', 'Starting import...');
-        this.logger.debug(`Task ${taskId} status updated to PROCESSING.`);
 
         try {
             if (!fs.existsSync(task.sourcePath)) {
                 throw new Error(`Directory not found: ${task.sourcePath}`);
             }
 
-            // 1. Prepare Target Group
-            this.logger.debug(`Task ${taskId}: Preparing target group.`);
-            let groupId = task.targetGroupId;
-            if (!groupId && task.targetGroupName) {
-                // Create new group
-                const group = await this.groupService.create(task.userId, task.tenantId || 'default', {
-                    name: task.targetGroupName,
-                    description: `Imported from ${task.sourcePath}`,
-                    color: '#0078D4', // Default blue
-                });
-                groupId = group.id;
-                await this.appendLog(taskId, `Created new group: ${task.targetGroupName}`);
-                this.logger.debug(`Task ${taskId}: Created new group ${group.id} - ${task.targetGroupName}`);
-            } else if (!groupId) {
-                throw new Error('No target group specified');
-            }
-            this.logger.debug(`Task ${taskId}: Target group ID is ${groupId}.`);
-
-            // 2. Scan Files
-            await this.appendLog(taskId, `Scanning directory: ${task.sourcePath}`);
-            this.logger.debug(`Task ${taskId}: Scanning directory ${task.sourcePath}.`);
-            const filesToImport = this.scanDir(task.sourcePath);
-            await this.appendLog(taskId, `Found ${filesToImport.length} markdown/text files.`);
-            this.logger.debug(`Task ${taskId}: Found ${filesToImport.length} files to import.`);
-
-            // 3. Import Loop
             const uploadPath = this.configService.get<string>('UPLOAD_FILE_PATH', './uploads');
             const importTargetDir = path.join(uploadPath, 'imported', taskId);
 
             if (!fs.existsSync(importTargetDir)) {
                 fs.mkdirSync(importTargetDir, { recursive: true });
             }
-            this.logger.debug(`Task ${taskId}: Created import target directory ${importTargetDir}.`);
 
             let successCount = 0;
             let failCount = 0;
 
-            for (let i = 0; i < filesToImport.length; i++) {
-                const filePath = filesToImport[i];
-                try {
-                    const filename = path.basename(filePath);
-                    // Directory as prefix logic
-                    const dirName = path.basename(path.dirname(filePath));
-                    // Avoid prefix if direct child of source path? No, keep it simple: always prefix with parent folder name
-                    // unless it's the source folder itself? 
-                    // Current plan: "ParentDir - Filename"
-                    // Let's refine: If file is D:/a/b/c.md, and source is D:/a, then dir is b. Title: "b - c.md"
-
-                    let title = filename;
+            if (task.useHierarchy) {
+                // ---- Hierarchy mode: create sub-groups matching folder structure ----
+                await this.appendLog(taskId, `Scanning directory with hierarchy: ${task.sourcePath}`);
+
+                // Determine root group
+                let rootGroupId = task.targetGroupId;
+                if (!rootGroupId) {
+                    const rootName = task.targetGroupName || path.basename(task.sourcePath);
+                    const rootGroup = await this.groupService.create(task.userId, task.tenantId || 'default', {
+                        name: rootName,
+                        description: `Imported from ${task.sourcePath}`,
+                        color: '#0078D4',
+                    });
+                    rootGroupId = rootGroup.id;
+                    await this.appendLog(taskId, `Created root group: ${rootName}`);
+                }
+
+                // Map from relative dir path -> groupId
+                const dirToGroupId = new Map<string, string>();
+                dirToGroupId.set('.', rootGroupId);
+
+                // Collect all files first
+                const allFiles = this.scanDir(task.sourcePath);
+                await this.appendLog(taskId, `Found ${allFiles.length} files.`);
+
+                for (let i = 0; i < allFiles.length; i++) {
+                    const filePath = allFiles[i];
                     const relativeDir = path.relative(task.sourcePath, path.dirname(filePath));
-                    if (relativeDir && relativeDir !== '.') {
-                        const prefix = relativeDir.replace(/[\\\/]/g, ' - ');
-                        title = `${prefix} - ${filename}`;
+                    const normalizedDir = relativeDir || '.';
+
+                    // Ensure group exists for this directory
+                    const groupId = await this.ensureHierarchyGroup(
+                        task.userId,
+                        task.tenantId || 'default',
+                        normalizedDir,
+                        dirToGroupId,
+                        task.sourcePath,
+                        taskId,
+                    );
+
+                    try {
+                        const kb = await this.importSingleFile(filePath, task, importTargetDir, i, allFiles.length);
+                        await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId, task.tenantId || 'default');
+                        successCount++;
+                        if (successCount % 10 === 0) {
+                            await this.appendLog(taskId, `Imported ${successCount} files...`);
+                        }
+                    } catch (e) {
+                        failCount++;
+                        await this.appendLog(taskId, `Failed to import ${path.basename(filePath)}: ${e.message}`);
                     }
+                }
+            } else {
+                // ---- Single-group mode (original behavior) ----
+                let groupId = task.targetGroupId;
+                if (!groupId && task.targetGroupName) {
+                    const group = await this.groupService.create(task.userId, task.tenantId || 'default', {
+                        name: task.targetGroupName,
+                        description: `Imported from ${task.sourcePath}`,
+                        color: '#0078D4',
+                    });
+                    groupId = group.id;
+                    await this.appendLog(taskId, `Created new group: ${task.targetGroupName}`);
+                } else if (!groupId) {
+                    throw new Error('No target group specified');
+                }
 
-                    const storedFilename = `imported-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${filename}`;
-                    const targetPath = path.join(importTargetDir, storedFilename);
-
-                    // Copy file to safe location
-                    fs.copyFileSync(filePath, targetPath);
-
-                    const stats = fs.statSync(targetPath);
-                    const fileInfo = {
-                        filename: storedFilename,
-                        originalname: title,
-                        path: targetPath, // Use the safe copy path
-                        mimetype: 'text/markdown', // Assume markdown/text for now
-                        size: stats.size,
-                    };
-
-                    const indexingConfig = {
-                        chunkSize: task.chunkSize || 500,
-                        chunkOverlap: task.chunkOverlap || 50,
-                        embeddingModelId: task.embeddingModelId,
-                        mode: (task.mode || 'fast') as 'fast' | 'precise',
-                    };
-
-                    // Ingest sequentially
-                    this.logger.log(`Processing file ${i + 1}/${filesToImport.length}: ${fileInfo.originalname}`);
-                    const kb = await this.kbService.createAndIndex(fileInfo, task.userId, task.tenantId || 'default', {
-                        ...indexingConfig,
-                        waitForCompletion: true // Ensure sequential processing
-                    } as any);
-                    this.logger.log(`File ${i + 1}/${filesToImport.length} processing completed`);
-
-                    // Link to Group
-                    await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId, task.tenantId || 'default');
-                    this.logger.debug(`Task ${taskId}: Linked KB ${kb.id} to group ${groupId}.`);
-
-                    successCount++;
-                    // Optional: log every single file? Might be too verbose for large courses.
-                    if (successCount % 10 === 0) {
-                        await this.appendLog(taskId, `Imported ${successCount} files...`);
+                await this.appendLog(taskId, `Scanning directory: ${task.sourcePath}`);
+                const filesToImport = this.scanDir(task.sourcePath);
+                await this.appendLog(taskId, `Found ${filesToImport.length} files.`);
+
+                for (let i = 0; i < filesToImport.length; i++) {
+                    const filePath = filesToImport[i];
+                    try {
+                        const kb = await this.importSingleFile(filePath, task, importTargetDir, i, filesToImport.length);
+                        await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId, task.tenantId || 'default');
+                        successCount++;
+                        if (successCount % 10 === 0) {
+                            await this.appendLog(taskId, `Imported ${successCount} files...`);
+                        }
+                    } catch (e) {
+                        failCount++;
+                        await this.appendLog(taskId, `Failed to import ${path.basename(filePath)}: ${e.message}`);
                     }
-
-                } catch (e) {
-                    failCount++;
-                    await this.appendLog(taskId, `Failed to import ${path.basename(filePath)}: ${e.message}`);
                 }
             }
 
@@ -193,6 +213,88 @@ export class ImportTaskService {
         }
     }
 
+    /**
+     * Ensure a KnowledgeGroup exists for each segment of the relative directory path.
+     * Returns the groupId for the leaf directory.
+     */
+    private async ensureHierarchyGroup(
+        userId: string,
+        tenantId: string,
+        relativeDir: string,
+        dirToGroupId: Map<string, string>,
+        _sourcePath: string,
+        taskId: string,
+    ): Promise<string> {
+        if (dirToGroupId.has(relativeDir)) {
+            return dirToGroupId.get(relativeDir)!;
+        }
+
+        const segments = relativeDir.split(path.sep);
+        let currentPath = '';
+        let parentGroupId = dirToGroupId.get('.') ?? dirToGroupId.values().next().value;
+
+        for (const segment of segments) {
+            currentPath = currentPath ? path.join(currentPath, segment) : segment;
+            if (dirToGroupId.has(currentPath)) {
+                parentGroupId = dirToGroupId.get(currentPath)!;
+                continue;
+            }
+
+            // Create a group for this directory segment
+            const group = await this.groupService.findOrCreate(
+                userId,
+                tenantId,
+                segment,
+                parentGroupId,
+                `Sub-folder: ${currentPath}`,
+            );
+            dirToGroupId.set(currentPath, group.id);
+            await this.appendLog(taskId, `Created sub-group: ${segment}`);
+            parentGroupId = group.id;
+        }
+
+        return parentGroupId;
+    }
+
+    /** Copy file to safe location and index it */
+    private async importSingleFile(
+        filePath: string,
+        task: ImportTask,
+        importTargetDir: string,
+        index: number,
+        total: number,
+    ) {
+        const filename = path.basename(filePath);
+        const storedFilename = `imported-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${filename}`;
+        const targetPath = path.join(importTargetDir, storedFilename);
+
+        fs.copyFileSync(filePath, targetPath);
+        const stats = fs.statSync(targetPath);
+
+        const fileInfo = {
+            filename: storedFilename,
+            originalname: filename,
+            path: targetPath,
+            mimetype: 'text/markdown',
+            size: stats.size,
+        };
+
+        const indexingConfig = {
+            chunkSize: task.chunkSize || 500,
+            chunkOverlap: task.chunkOverlap || 50,
+            embeddingModelId: task.embeddingModelId,
+            mode: (task.mode || 'fast') as 'fast' | 'precise',
+        };
+
+        this.logger.log(`Processing file ${index + 1}/${total}: ${filename}`);
+        const kb = await this.kbService.createAndIndex(fileInfo, task.userId, task.tenantId || 'default', {
+            ...indexingConfig,
+            waitForCompletion: true,
+        } as any);
+        this.logger.log(`File ${index + 1}/${total} processing completed: ${filename}`);
+        return kb;
+    }
+
     private scanDir(directory: string): string[] {
         let results: string[] = [];
         const items = fs.readdirSync(directory);
@@ -202,8 +304,7 @@ export class ImportTaskService {
             if (stat.isDirectory()) {
                 results = results.concat(this.scanDir(fullPath));
             } else {
-                // Filter for text files and PDFs
-                if (item.match(/\.(md|txt|html|json|pdf)$/i)) {
+                if (item.match(/\.(md|txt|html|json|pdf|docx|xlsx|pptx|csv)$/i)) {
                     results.push(fullPath);
                 }
             }

+ 30 - 2
server/src/knowledge-base/knowledge-base.controller.ts

@@ -40,8 +40,36 @@ export class KnowledgeBaseController {
 
   @Get()
   @UseGuards(CombinedAuthGuard)
-  async findAll(@Request() req): Promise<KnowledgeBase[]> {
-    return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId);
+  async findAll(
+    @Request() req,
+    @Query('page') page?: number,
+    @Query('limit') limit?: number,
+    @Query('name') name?: string,
+    @Query('status') status?: string,
+    @Query('groupId') groupId?: string,
+  ): Promise<any> {
+    return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId, {
+      page: page ? Number(page) : undefined,
+      limit: limit ? Number(limit) : undefined,
+      name,
+      status,
+      groupId,
+    });
+  }
+
+  @Post('statuses')
+  @UseGuards(CombinedAuthGuard)
+  async getStatuses(
+    @Request() req,
+    @Body() body: { ids: string[] }
+  ): Promise<any> {
+    return this.knowledgeBaseService.getStatuses(body.ids, req.user.id, req.user.tenantId);
+  }
+
+  @Get('stats')
+  @UseGuards(CombinedAuthGuard)
+  async getStats(@Request() req): Promise<any> {
+    return this.knowledgeBaseService.getStats(req.user.id, req.user.tenantId);
   }
 
   @Delete('clear')

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

@@ -1,6 +1,7 @@
 import { Module, forwardRef } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { KnowledgeBase } from './knowledge-base.entity';
+import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
 import { KnowledgeBaseService } from './knowledge-base.service';
 import { KnowledgeBaseController } from './knowledge-base.controller';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
@@ -24,7 +25,7 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 
 @Module({
   imports: [
-    TypeOrmModule.forFeature([KnowledgeBase]),
+    TypeOrmModule.forFeature([KnowledgeBase, KnowledgeGroup]),
     forwardRef(() => ElasticsearchModule),
     TikaModule,
     ModelConfigModule,

+ 110 - 10
server/src/knowledge-base/knowledge-base.service.ts

@@ -2,8 +2,9 @@ import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nest
 import { ConfigService } from '@nestjs/config';
 import { I18nService } from '../i18n/i18n.service';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { Repository, In } from 'typeorm';
 import { FileStatus, KnowledgeBase, ProcessingMode } from './knowledge-base.entity';
+import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
 import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
 import { TikaService } from '../tika/tika.service';
 import * as fs from 'fs';
@@ -22,6 +23,13 @@ import { Pdf2ImageService } from '../pdf2image/pdf2image.service';
 import { DOC_EXTENSIONS, IMAGE_EXTENSIONS } from '../common/file-support.constants';
 import { ChatService } from '../chat/chat.service';
 
+export interface PaginatedKnowledgeBase {
+  items: KnowledgeBase[];
+  total: number;
+  page: number;
+  limit: number;
+}
+
 @Injectable()
 export class KnowledgeBaseService {
   private readonly logger = new Logger(KnowledgeBaseService.name);
@@ -29,6 +37,8 @@ export class KnowledgeBaseService {
   constructor(
     @InjectRepository(KnowledgeBase)
     private kbRepository: Repository<KnowledgeBase>,
+    @InjectRepository(KnowledgeGroup)
+    private groupRepository: Repository<KnowledgeGroup>,
     @Inject(forwardRef(() => ElasticsearchService))
     private elasticsearchService: ElasticsearchService,
     private tikaService: TikaService,
@@ -73,6 +83,14 @@ export class KnowledgeBaseService {
       processingMode: processingMode,
     });
 
+    // 分類(グループ)の関連付け
+    if (config?.groupIds && config.groupIds.length > 0) {
+      const groups = await this.groupRepository.find({
+        where: { id: In(config.groupIds), tenantId: tenantId }
+      });
+      kb.groups = groups;
+    }
+
     const savedKb = await this.kbRepository.save(kb);
 
     this.logger.log(
@@ -119,20 +137,99 @@ export class KnowledgeBaseService {
     return savedKb;
   }
 
-  async findAll(userId: string, tenantId?: string): Promise<KnowledgeBase[]> {
-    const where: any = {};
-    if (tenantId) {
-      where.tenantId = tenantId;
-    } else {
-      where.userId = userId;
+  async findAll(
+    userId: string,
+    tenantId: string,
+    options: {
+      page?: number;
+      limit?: number;
+      name?: string;
+      status?: string;
+      groupId?: string;
+    } = {},
+  ): Promise<PaginatedKnowledgeBase> {
+    const { page = 1, limit = 12, name, status, groupId } = options;
+    const skip = (page - 1) * limit;
+
+    const queryBuilder = this.kbRepository.createQueryBuilder('kb')
+      .leftJoinAndSelect('kb.groups', 'groups')
+      .where('kb.tenantId = :tenantId', { tenantId });
+
+    if (name) {
+      queryBuilder.andWhere('kb.originalName LIKE :name', { name: `%${name}%` });
     }
+
+    if (status && status !== 'all') {
+      if (status === 'ready') {
+        queryBuilder.andWhere('kb.status IN (:...statuses)', { statuses: [FileStatus.VECTORIZED] });
+      } else if (status === 'indexing') {
+        queryBuilder.andWhere('kb.status IN (:...statuses)', { statuses: [FileStatus.INDEXING, FileStatus.PENDING, FileStatus.EXTRACTED] });
+      } else if (status === 'failed') {
+        queryBuilder.andWhere('kb.status IN (:...statuses)', { statuses: [FileStatus.FAILED] });
+      }
+    }
+
+    if (groupId) {
+      // Find files that belong to this group or its descendants
+      const group = await this.groupRepository.findOne({ where: { id: groupId, tenantId } });
+      if (group) {
+        const groupIds = [groupId];
+        const collectChildren = async (parentId: string) => {
+          const children = await this.groupRepository.find({ where: { parentId, tenantId } });
+          for (const child of children) {
+            groupIds.push(child.id);
+            await collectChildren(child.id);
+          }
+        };
+        await collectChildren(groupId);
+
+        queryBuilder.innerJoin('kb.groups', 'filterGroup', 'filterGroup.id IN (:...groupIds)', { groupIds });
+      }
+    }
+
+    const [items, total] = await queryBuilder
+      .orderBy('kb.createdAt', 'DESC')
+      .skip(skip)
+      .take(limit)
+      .getManyAndCount();
+
+    return {
+      items,
+      total,
+      page,
+      limit,
+    };
+  }
+
+  async getStatuses(ids: string[], userId: string, tenantId: string): Promise<any[]> {
+    if (!ids || ids.length === 0) return [];
+
     return this.kbRepository.find({
-      where,
-      relations: ['groups'], // グループリレーションをロード
-      order: { createdAt: 'DESC' },
+      select: ['id', 'status', 'updatedAt'],
+      where: {
+        id: In(ids),
+        tenantId
+      }
     });
   }
 
+  async getStats(userId: string, tenantId: string): Promise<{ total: number, uncategorized: number }> {
+    const total = await this.kbRepository.count({ where: { tenantId } });
+
+    // Count uncategorized files by finding files with NO groups
+    // Using query builder for more complex relation queries
+    const uncategorized = await this.kbRepository.createQueryBuilder('kb')
+      .leftJoin('kb.groups', 'groups')
+      .where('kb.tenantId = :tenantId', { tenantId })
+      .andWhere('groups.id IS NULL')
+      .getCount();
+
+    return {
+      total,
+      uncategorized
+    };
+  }
+
   async searchKnowledge(userId: string, tenantId: string, query: string, topK: number = 5) {
     try {
       // 環境変数のデフォルト次元数を使用してシミュレーションベクトルを生成
@@ -907,6 +1004,7 @@ export class KnowledgeBaseService {
                       originalName: kb.originalName,
                       mimetype: kb.mimetype,
                       userId: userId,
+                      tenantId, // Added tenantId
                       chunkIndex: chunk.index,
                       startPosition: chunk.startPosition,
                       endPosition: chunk.endPosition,
@@ -957,6 +1055,7 @@ export class KnowledgeBaseService {
                   originalName: kb.originalName,
                   mimetype: kb.mimetype,
                   userId: userId,
+                  tenantId, // Added tenantId
                   chunkIndex: chunk.index,
                   startPosition: chunk.startPosition,
                   endPosition: chunk.endPosition,
@@ -993,6 +1092,7 @@ export class KnowledgeBaseService {
                       originalName: kb.originalName,
                       mimetype: kb.mimetype,
                       userId: userId,
+                      tenantId, // Added tenantId
                       chunkIndex: chunk.index,
                       startPosition: chunk.startPosition,
                       endPosition: chunk.endPosition,

+ 15 - 8
server/src/knowledge-group/knowledge-group.controller.ts

@@ -8,6 +8,7 @@ import {
   Param,
   UseGuards,
   Request,
+  Query,
 } from '@nestjs/common';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
@@ -21,21 +22,30 @@ export class KnowledgeGroupController {
   constructor(private readonly groupService: KnowledgeGroupService) { }
 
   @Get()
-  async findAll(@Request() req) {
+  async findAll(
+    @Request() req,
+    @Query('flat') flat?: string,
+    @Query('page') page?: number,
+    @Query('limit') limit?: number,
+    @Query('name') name?: string,
+  ) {
     // All users can see all groups for their tenant
-    return await this.groupService.findAll(req.user.id, req.user.tenantId);
+    return await this.groupService.findAll(req.user.id, req.user.tenantId, {
+      flat: flat === 'true',
+      page: page ? Number(page) : undefined,
+      limit: limit ? Number(limit) : undefined,
+      name,
+    });
   }
 
   @Get(':id')
   async findOne(@Param('id') id: string, @Request() req) {
-    // Access group within tenant
     return await this.groupService.findOne(id, req.user.id, req.user.tenantId);
   }
 
   @Post()
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
-    // Only admin can create groups (implicitly scoped to their tenant)
     return await this.groupService.create(req.user.id, req.user.tenantId, createGroupDto);
   }
 
@@ -46,21 +56,18 @@ export class KnowledgeGroupController {
     @Body() updateGroupDto: UpdateGroupDto,
     @Request() req,
   ) {
-    // Only admin can update any group within tenant
     return await this.groupService.update(id, req.user.id, req.user.tenantId, updateGroupDto);
   }
 
   @Delete(':id')
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async remove(@Param('id') id: string, @Request() req) {
-    // Only admin can delete groups
     await this.groupService.remove(id, req.user.id, req.user.tenantId);
-    return { message: '分组删除成功' };
+    return { message: 'Group deleted successfully' };
   }
 
   @Get(':id/files')
   async getGroupFiles(@Param('id') id: string, @Request() req) {
-    // Any user can see files in any group within tenant
     const files = await this.groupService.getGroupFiles(id, req.user.id, req.user.tenantId);
     return { files };
   }

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

@@ -7,6 +7,7 @@ import {
   ManyToMany,
   JoinTable,
   ManyToOne,
+  OneToMany,
   JoinColumn,
 } from 'typeorm';
 import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
@@ -35,6 +36,17 @@ export class KnowledgeGroup {
   @JoinColumn({ name: 'tenant_id' })
   tenant: Tenant;
 
+  // Hierarchical parent-child relationship
+  @Column({ name: 'parent_id', nullable: true, type: 'text' })
+  parentId: string | null;
+
+  @ManyToOne(() => KnowledgeGroup, (group) => group.children, { nullable: true, onDelete: 'SET NULL' })
+  @JoinColumn({ name: 'parent_id' })
+  parent: KnowledgeGroup;
+
+  @OneToMany(() => KnowledgeGroup, (group) => group.parent)
+  children: KnowledgeGroup[];
+
   @CreateDateColumn({ name: 'created_at' })
   createdAt: Date;
 

+ 125 - 29
server/src/knowledge-group/knowledge-group.service.ts

@@ -1,7 +1,7 @@
 import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
 import { I18nService } from '../i18n/i18n.service';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { Repository, In } from 'typeorm';
 import { KnowledgeGroup } from './knowledge-group.entity';
 import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
@@ -10,12 +10,14 @@ export interface CreateGroupDto {
   name: string;
   description?: string;
   color?: string;
+  parentId?: string | null;
 }
 
 export interface UpdateGroupDto {
   name?: string;
   description?: string;
   color?: string;
+  parentId?: string | null;
 }
 
 export interface GroupWithFileCount {
@@ -24,9 +26,18 @@ export interface GroupWithFileCount {
   description?: string;
   color: string;
   fileCount: number;
+  parentId?: string | null;
+  children?: GroupWithFileCount[];
   createdAt: Date;
 }
 
+export interface PaginatedGroups {
+  items: GroupWithFileCount[];
+  total: number;
+  page: number;
+  limit: number;
+}
+
 @Injectable()
 export class KnowledgeGroupService {
   constructor(
@@ -39,25 +50,89 @@ export class KnowledgeGroupService {
     private i18nService: I18nService,
   ) { }
 
-  async findAll(userId: string, tenantId: string): Promise<GroupWithFileCount[]> {
-    // Return all groups for the tenant
-    const groups = await this.groupRepository
+  async findAll(
+    userId: string,
+    tenantId: string,
+    options: {
+      flat?: boolean;
+      page?: number;
+      limit?: number;
+      name?: string;
+    } = {},
+  ): Promise<GroupWithFileCount[] | PaginatedGroups> {
+    const { flat = false, page = 1, limit = 12, name } = options;
+
+    const queryBuilder = this.groupRepository
       .createQueryBuilder('group')
       .leftJoin('group.knowledgeBases', 'kb')
       .where('group.tenantId = :tenantId', { tenantId })
       .addSelect('COUNT(kb.id)', 'fileCount')
       .groupBy('group.id')
-      .orderBy('group.createdAt', 'DESC')
-      .getRawAndEntities();
+      .orderBy('group.createdAt', 'ASC');
+
+    if (name) {
+      queryBuilder.andWhere('group.name LIKE :name', { name: `%${name}%` });
+    }
+
+    if (flat) {
+      const skip = (page - 1) * limit;
+      const total = await queryBuilder.getCount();
+      const rawResults = await queryBuilder
+        .offset(skip)
+        .limit(limit)
+        .getRawAndEntities();
+
+      const items: GroupWithFileCount[] = rawResults.entities.map((group, index) => ({
+        id: group.id,
+        name: group.name,
+        description: group.description,
+        color: group.color,
+        parentId: group.parentId ?? null,
+        fileCount: parseInt(rawResults.raw[index].fileCount) || 0,
+        createdAt: group.createdAt,
+        children: [],
+      }));
+
+      return {
+        items,
+        total,
+        page,
+        limit,
+      };
+    }
+
+    // Return all groups for the tenant with file counts (tree structure)
+    const groups = await queryBuilder.getRawAndEntities();
 
-    return groups.entities.map((group, index) => ({
+    const flatList: GroupWithFileCount[] = groups.entities.map((group, index) => ({
       id: group.id,
       name: group.name,
       description: group.description,
       color: group.color,
+      parentId: group.parentId ?? null,
       fileCount: parseInt(groups.raw[index].fileCount) || 0,
       createdAt: group.createdAt,
+      children: [],
     }));
+
+    // Build tree structure
+    return this.buildTree(flatList);
+  }
+
+  /** Build a nested tree from a flat list */
+  private buildTree(items: GroupWithFileCount[]): GroupWithFileCount[] {
+    const map = new Map<string, GroupWithFileCount>();
+    items.forEach(item => map.set(item.id, { ...item, children: [] }));
+
+    const roots: GroupWithFileCount[] = [];
+    map.forEach(item => {
+      if (item.parentId && map.has(item.parentId)) {
+        map.get(item.parentId)!.children!.push(item);
+      } else {
+        roots.push(item);
+      }
+    });
+    return roots;
   }
 
   async findOne(id: string, userId: string, tenantId: string): Promise<KnowledgeGroup> {
@@ -77,6 +152,7 @@ export class KnowledgeGroupService {
   async create(userId: string, tenantId: string, createGroupDto: CreateGroupDto): Promise<KnowledgeGroup> {
     const group = this.groupRepository.create({
       ...createGroupDto,
+      parentId: createGroupDto.parentId ?? null,
       tenantId,
     });
 
@@ -98,7 +174,6 @@ export class KnowledgeGroupService {
   }
 
   async remove(id: string, userId: string, tenantId: string): Promise<void> {
-    // Remove group within the tenant
     const group = await this.groupRepository.findOne({
       where: { id, tenantId },
     });
@@ -107,7 +182,21 @@ export class KnowledgeGroupService {
       throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
-    // Find all files associated with this group (without user restriction)
+    // Recursively delete this group and all its descendants
+    await this.removeGroupRecursive(id, userId, tenantId);
+  }
+
+  /** Recursively delete a group, all its children, and all associated files */
+  private async removeGroupRecursive(id: string, userId: string, tenantId: string): Promise<void> {
+    // 1. Find all direct children of this group
+    const children = await this.groupRepository.find({ where: { parentId: id, tenantId } });
+
+    // 2. Recurse into each child first (depth-first)
+    for (const child of children) {
+      await this.removeGroupRecursive(child.id, userId, tenantId);
+    }
+
+    // 3. Delete all files belonging to this group
     const files = await this.knowledgeBaseRepository
       .createQueryBuilder('kb')
       .innerJoin('kb.groups', 'group')
@@ -115,15 +204,12 @@ export class KnowledgeGroupService {
       .select('kb.id')
       .getMany();
 
-    // Delete each file
     for (const file of files) {
       try {
-        // We need to get the file's owner to delete it properly
         const fullFile = await this.knowledgeBaseRepository.findOne({
           where: { id: file.id },
-          select: ['id', 'userId', 'tenantId']  // Get the owner of the file
+          select: ['id', 'userId', 'tenantId'],
         });
-
         if (fullFile) {
           await this.knowledgeBaseService.deleteFile(fullFile.id, fullFile.userId, fullFile.tenantId as string);
         }
@@ -132,20 +218,11 @@ export class KnowledgeGroupService {
       }
     }
 
-    // Delete notes in this group - call the findAll method with groupId parameter only
-    // We'll fetch notes for the group without userId restriction
-    // For this, we'll call the note service differently
-
-    // Actually, we need to think about this carefully
-    // Notes belong to users, so we can't remove notes from other users' groups
-    // We'll just remove the group from the association
-    // Or fetch notes by groupId only (need to modify note service)
-
-    // Since note service is user-restricted, let's only handle file removal
-    // and leave note management to users individually
-
-    // Delete the group itself
-    await this.groupRepository.remove(group);
+    // 4. Delete the group itself
+    const group = await this.groupRepository.findOne({ where: { id } });
+    if (group) {
+      await this.groupRepository.remove(group);
+    }
   }
 
   async getGroupFiles(groupId: string, userId: string, tenantId: string): Promise<KnowledgeBase[]> {
@@ -172,10 +249,10 @@ export class KnowledgeGroupService {
     }
 
     // Load all groups by ID without user restriction
-    const groups = await this.groupRepository.findByIds(groupIds);
+    const groups = await this.groupRepository.findBy({ id: In(groupIds) });
     const validGroups = groups.filter(g => g.tenantId === tenantId);
 
-    if (validGroups.length !== groupIds.length) {
+    if (groupIds.length > 0 && validGroups.length !== groupIds.length) {
       throw new NotFoundException(this.i18nService.getMessage('someGroupsNotFound'));
     }
 
@@ -212,4 +289,23 @@ export class KnowledgeGroupService {
 
     return result.map(row => row.id);
   }
+
+  /**
+   * Find or create a group by name and parentId within a tenant.
+   * Used by import tasks to build folder hierarchy.
+   */
+  async findOrCreate(
+    userId: string,
+    tenantId: string,
+    name: string,
+    parentId: string | null,
+    description?: string,
+  ): Promise<KnowledgeGroup> {
+    const existing = await this.groupRepository.findOne({
+      where: { name, tenantId, parentId: parentId ?? undefined },
+    });
+    if (existing) return existing;
+
+    return this.create(userId, tenantId, { name, description, parentId });
+  }
 }

+ 18 - 0
server/src/migrations/1772340000000-AddParentIdToKnowledgeGroups.ts

@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddParentIdToKnowledgeGroups1772340000000 implements MigrationInterface {
+    name = 'AddParentIdToKnowledgeGroups1772340000000';
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        // Add parent_id column to knowledge_groups table
+        await queryRunner.query(
+            `ALTER TABLE "knowledge_groups" ADD COLUMN "parent_id" text REFERENCES "knowledge_groups"("id") ON DELETE SET NULL`
+        );
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(
+            `ALTER TABLE "knowledge_groups" DROP COLUMN "parent_id"`
+        );
+    }
+}

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

@@ -94,6 +94,17 @@ export class TenantService {
         if (!setting) {
             setting = this.tenantSettingRepository.create({ tenantId, ...data });
         } else {
+            if (data.enabledModelIds) {
+                if (setting.selectedLLMId && !data.enabledModelIds.includes(setting.selectedLLMId)) {
+                    data.selectedLLMId = null as any;
+                }
+                if (setting.selectedEmbeddingId && !data.enabledModelIds.includes(setting.selectedEmbeddingId)) {
+                    data.selectedEmbeddingId = null as any;
+                }
+                if (setting.selectedRerankId && !data.enabledModelIds.includes(setting.selectedRerankId)) {
+                    data.selectedRerankId = null as any;
+                }
+            }
             Object.assign(setting, data);
         }
         return this.tenantSettingRepository.save(setting);

+ 46 - 0
server/src/upload/upload.controller.ts

@@ -35,6 +35,7 @@ export interface UploadConfigDto {
   chunkOverlap?: string;
   embeddingModelId?: string;
   mode?: 'fast' | 'precise'; // 処理モード
+  groupIds?: string; // JSON string of group IDs
 }
 
 @Controller('upload')
@@ -48,6 +49,50 @@ export class UploadController {
     private readonly i18nService: I18nService,
   ) { }
 
+  @Post('local-folder')
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  async importLocalFolder(
+    @Request() req,
+    @Body() body: {
+      sourcePath: string;
+      embeddingModelId: string;
+      chunkSize?: string;
+      chunkOverlap?: string;
+      mode?: 'fast' | 'precise';
+      useHierarchy?: boolean;
+      groupIds?: string[];
+    }
+  ) {
+    if (!body.sourcePath) {
+      throw new BadRequestException(this.i18nService.getMessage('sourcePathRequired' as any) || 'Source path is required');
+    }
+
+    if (!body.embeddingModelId) {
+      throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired'));
+    }
+
+    const indexingConfig = {
+      chunkSize: body.chunkSize ? parseInt(body.chunkSize) : DEFAULT_CHUNK_SIZE,
+      chunkOverlap: body.chunkOverlap ? parseInt(body.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
+      embeddingModelId: body.embeddingModelId,
+      mode: body.mode || 'fast',
+      useHierarchy: body.useHierarchy ?? false,
+      groupIds: body.groupIds || [],
+    };
+
+    const result = await this.uploadService.importLocalFolder(
+      body.sourcePath,
+      req.user.id,
+      req.user.tenantId,
+      indexingConfig
+    );
+
+    return {
+      message: this.i18nService.getMessage('importStarted' as any) || 'Import started',
+      ...result
+    };
+  }
+
   @Post()
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @UseInterceptors(
@@ -105,6 +150,7 @@ export class UploadController {
       chunkSize: config.chunkSize ? parseInt(config.chunkSize) : DEFAULT_CHUNK_SIZE,
       chunkOverlap: config.chunkOverlap ? parseInt(config.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
       embeddingModelId: config.embeddingModelId || null,
+      groupIds: config.groupIds ? JSON.parse(config.groupIds) : [],
     };
 
     // オーバーラップサイズがチャンクサイズの50%を超えないようにする

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

@@ -4,6 +4,7 @@ import { UploadController } from './upload.controller';
 import { MulterModule } from '@nestjs/platform-express';
 import { ConfigModule, ConfigService } from '@nestjs/config';
 import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module'; // Import KnowledgeBaseModule
+import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
 import * as multer from 'multer';
 import * as fs from 'fs';
 import * as path from 'path';
@@ -11,7 +12,8 @@ import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
-    KnowledgeBaseModule, // Add to
+    KnowledgeBaseModule,
+    KnowledgeGroupModule,
     UserModule,
     MulterModule.registerAsync({
       imports: [ConfigModule],

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

@@ -1,20 +1,210 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger, BadRequestException } from '@nestjs/common';
+import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
+import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
+import * as fs from 'fs';
+import * as path from 'path';
 
 @Injectable()
 export class UploadService {
-  async processUploadedFile(file: Express.Multer.File) {
-    // ここにさらに業務ロジックを追加できます。例:
-    // - ファイル情報をデータベースに保存
-    // - ファイルを処理するために他のサービスを呼び出す(Tika テキスト抽出、Elasticsearch インデックス作成など)
-    // - ファイル形式の検証や内容分析を行う
+  private readonly logger = new Logger(UploadService.name);
+
+  constructor(
+    private readonly kbService: KnowledgeBaseService,
+    private readonly groupService: KnowledgeGroupService,
+  ) { }
 
-    // 現時点では、ファイルの基本情報のみを返します
+  async processUploadedFile(file: Express.Multer.File) {
     return {
       filename: file.filename,
       originalname: file.originalname,
       size: file.size,
       mimetype: file.mimetype,
-      path: file.path, // Multer がファイルを保存した後、ファイルオブジェクトの path プロパティにフルパスが含まれます
+      path: file.path,
+    };
+  }
+
+  async importLocalFolder(
+    sourcePath: string,
+    userId: string,
+    tenantId: string,
+    config: any
+  ) {
+    if (!fs.existsSync(sourcePath)) {
+      throw new BadRequestException(`Directory not found: ${sourcePath}`);
+    }
+
+    const stat = fs.statSync(sourcePath);
+    if (!stat.isDirectory()) {
+      throw new BadRequestException(`Path is not a directory: ${sourcePath}`);
+    }
+
+    // Determine root group for hierarchy or single group
+    let rootGroupId: string | null = null;
+    if (config.groupIds && config.groupIds.length > 0) {
+      rootGroupId = config.groupIds[0];
+    }
+
+    this.logger.log(`Starting local folder import: ${sourcePath} for user ${userId}, tenant ${tenantId}`);
+
+    // Trigger scanning and processing asynchronously to not block the request
+    this.executeLocalImport(sourcePath, userId, tenantId, config, rootGroupId).catch(err => {
+      this.logger.error(`Local folder import failed for ${sourcePath}`, err);
+    });
+
+    return {
+      sourcePath,
+      status: 'PROCESSING'
+    };
+  }
+
+  private async executeLocalImport(
+    sourcePath: string,
+    userId: string,
+    tenantId: string,
+    config: any,
+    rootGroupId: string | null
+  ) {
+    const files = this.scanDir(sourcePath);
+    this.logger.log(`Found ${files.length} files in ${sourcePath}`);
+
+    const dirToGroupId = new Map<string, string>();
+    if (rootGroupId) {
+      dirToGroupId.set('.', rootGroupId);
+    } else {
+      // Create a root group based on folder name if none provided
+      const rootName = path.basename(sourcePath);
+      const rootGroup = await this.groupService.create(userId, tenantId, {
+        name: rootName,
+        description: `Imported from local path: ${sourcePath}`,
+      });
+      rootGroupId = rootGroup.id;
+      dirToGroupId.set('.', rootGroupId);
+    }
+
+    const uploadBaseDir = process.env.UPLOAD_FILE_PATH || './uploads';
+
+    for (const filePath of files) {
+      try {
+        const relativeDir = path.relative(sourcePath, path.dirname(filePath));
+        const normalizedDir = relativeDir || '.';
+
+        let targetGroupId = rootGroupId;
+        if (config.useHierarchy) {
+          targetGroupId = await this.ensureHierarchy(
+            userId,
+            tenantId,
+            normalizedDir,
+            dirToGroupId,
+            rootGroupId!
+          );
+        }
+
+        const filename = path.basename(filePath);
+        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
+        const storedFilename = `local-${uniqueSuffix}-${filename}`;
+
+        // Ensure tenant directory exists
+        const tenantDir = path.join(uploadBaseDir, tenantId);
+        if (!fs.existsSync(tenantDir)) {
+          fs.mkdirSync(tenantDir, { recursive: true });
+        }
+
+        const targetPath = path.join(tenantDir, storedFilename);
+        fs.copyFileSync(filePath, targetPath);
+
+        const stats = fs.statSync(targetPath);
+        const fileInfo = {
+          filename: storedFilename,
+          originalname: filename,
+          path: targetPath,
+          size: stats.size,
+          mimetype: this.getMimeType(filename),
+        };
+
+        await this.kbService.createAndIndex(fileInfo, userId, tenantId, {
+          ...config,
+          groupIds: [targetGroupId],
+        });
+
+      } catch (err) {
+        this.logger.error(`Failed to process local file: ${filePath}`, err);
+      }
+    }
+
+    this.logger.log(`Local folder import completed: ${sourcePath}`);
+  }
+
+  private async ensureHierarchy(
+    userId: string,
+    tenantId: string,
+    relativeDir: string,
+    dirToGroupId: Map<string, string>,
+    rootGroupId: string
+  ): Promise<string> {
+    if (dirToGroupId.has(relativeDir)) {
+      return dirToGroupId.get(relativeDir)!;
+    }
+
+    const segments = relativeDir.split(path.sep);
+    let currentPath = '';
+    let parentId = rootGroupId;
+
+    for (const segment of segments) {
+      if (!segment || segment === '.') continue;
+      currentPath = currentPath ? path.join(currentPath, segment) : segment;
+
+      if (dirToGroupId.has(currentPath)) {
+        parentId = dirToGroupId.get(currentPath)!;
+        continue;
+      }
+
+      const group = await this.groupService.findOrCreate(
+        userId,
+        tenantId,
+        segment,
+        parentId,
+        `Sub-folder from local import: ${currentPath}`
+      );
+      dirToGroupId.set(currentPath, group.id);
+      parentId = group.id;
+    }
+
+    return parentId;
+  }
+
+  private scanDir(directory: string): string[] {
+    let results: string[] = [];
+    if (!fs.existsSync(directory)) return results;
+
+    const items = fs.readdirSync(directory);
+    for (const item of items) {
+      const fullPath = path.join(directory, item);
+      const stat = fs.statSync(fullPath);
+      if (stat.isDirectory()) {
+        results = results.concat(this.scanDir(fullPath));
+      } else {
+        // Only include supported document and code extensions
+        const ext = path.extname(item).toLowerCase().slice(1);
+        if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'csv', 'txt', 'md', 'html', 'json', 'xml', 'js', 'ts', 'py', 'java', 'sql'].includes(ext)) {
+          results.push(fullPath);
+        }
+      }
+    }
+    return results;
+  }
+
+  private getMimeType(filename: string): string {
+    const ext = path.extname(filename).toLowerCase();
+    const mimeMap: Record<string, string> = {
+      '.pdf': 'application/pdf',
+      '.doc': 'application/msword',
+      '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+      '.md': 'text/markdown',
+      '.txt': 'text/plain',
+      '.json': 'application/json',
+      '.html': 'text/html',
+      '.csv': 'text/csv',
     };
+    return mimeMap[ext] || 'application/octet-stream';
   }
 }

+ 26 - 8
server/src/user/user.service.ts

@@ -133,17 +133,24 @@ export class UserService implements OnModuleInit {
     return members.map(m => m.user);
   }
 
-  async getUserTenants(userId: string): Promise<TenantMember[]> {
+  async getUserTenants(userId: string): Promise<(TenantMember & { features?: { isNotebookEnabled: boolean } })[]> {
     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 results = await Promise.all(allTenants.map(async t => {
+        const settings = await this.tenantService.getSettings(t.id);
+        return {
+          tenantId: t.id,
+          tenant: t,
+          role: UserRole.SUPER_ADMIN,
+          userId: userId,
+          features: {
+            isNotebookEnabled: settings?.isNotebookEnabled ?? true,
+          },
+        } as TenantMember & { features: { isNotebookEnabled: boolean } };
+      }));
+      return results;
     }
 
     const members = await this.tenantMemberRepository.find({
@@ -152,7 +159,18 @@ export class UserService implements OnModuleInit {
     });
 
     // Filter out the "Default" tenant for non-super admins
-    return members.filter(m => m.tenant?.name !== TenantService.DEFAULT_TENANT_NAME);
+    const filtered = members.filter(m => m.tenant?.name !== TenantService.DEFAULT_TENANT_NAME);
+
+    // Attach per-tenant feature flags
+    return Promise.all(filtered.map(async m => {
+      const settings = await this.tenantService.getSettings(m.tenantId);
+      return {
+        ...m,
+        features: {
+          isNotebookEnabled: settings?.isNotebookEnabled ?? true,
+        },
+      };
+    }));
   }
 
   /**

+ 11 - 2
server/tsconfig.build.json

@@ -1,4 +1,13 @@
 {
   "extends": "./tsconfig.json",
-  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
-}
+  "compilerOptions": {
+    "incremental": false
+  },
+  "exclude": [
+    "node_modules",
+    "test",
+    "dist",
+    "scripts",
+    "**/*spec.ts"
+  ]
+}

+ 11 - 3
server/tsconfig.json

@@ -18,6 +18,14 @@
     "forceConsistentCasingInFileNames": true,
     "noImplicitAny": false,
     "strictBindCallApply": false,
-    "noFallthroughCasesInSwitch": false
-  }
-}
+    "noFallthroughCasesInSwitch": false,
+    "rootDir": "src"
+  },
+  "exclude": [
+    "node_modules",
+    "dist",
+    "scripts",
+    "test",
+    "**/*spec.ts"
+  ]
+}

+ 80 - 0
sync_translations.js

@@ -0,0 +1,80 @@
+
+const fs = require('fs');
+const path = require('path');
+
+const translationsPath = path.join('d:', 'workspace', 'AuraK', 'web', 'utils', 'translations.ts');
+const usedKeysPath = path.join('d:', 'workspace', 'AuraK', 'all_used_keys.txt');
+
+const usedKeys = fs.readFileSync(usedKeysPath, 'utf8').split('\n').filter(Boolean);
+const translationsContent = fs.readFileSync(translationsPath, 'utf8');
+
+const lines = translationsContent.split('\n');
+let currentLang = null;
+let resultLines = [];
+let keysSeen = new Set();
+
+const langStartRegex = /^\s+(\w+): \{/;
+const keyRegex = /^(\s+)([a-zA-Z0-9_-]+):(.*)/;
+
+function isValidIdentifier(id) {
+    return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(id);
+}
+
+for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+
+    const langMatch = line.match(langStartRegex);
+    if (langMatch) {
+        if (currentLang) {
+            addMissingUsedKeys(currentLang, resultLines, keysSeen);
+        }
+        currentLang = langMatch[1];
+        keysSeen = new Set();
+        resultLines.push(line);
+        continue;
+    }
+
+    if (currentLang) {
+        const keyMatch = line.match(keyRegex);
+        if (keyMatch) {
+            const indent = keyMatch[1];
+            let key = keyMatch[2];
+            const rest = keyMatch[3];
+
+            // Note: keyMatch[2] might already be quoted if it was fixed in a previous run
+            const actualKey = (key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))
+                ? key.slice(1, -1)
+                : key;
+
+            keysSeen.add(actualKey);
+
+            if (!isValidIdentifier(actualKey)) {
+                resultLines.push(`${indent}"${actualKey}":${rest}`);
+                continue;
+            }
+        }
+
+        if (line.trim() === '},' || (line.trim() === '}' && i > lines.length - 5)) {
+            addMissingUsedKeys(currentLang, resultLines, keysSeen);
+            currentLang = null;
+            resultLines.push(line);
+            continue;
+        }
+    }
+
+    resultLines.push(line);
+}
+
+function addMissingUsedKeys(lang, targetLines, seen) {
+    for (const key of usedKeys) {
+        if (!seen.has(key)) {
+            const val = key;
+            const quotedKey = isValidIdentifier(key) ? key : `"${key}"`;
+            targetLines.push(`    ${quotedKey}: ${JSON.stringify(val)},`);
+            seen.add(key);
+        }
+    }
+}
+
+fs.writeFileSync(translationsPath, resultLines.join('\n'), 'utf8');
+console.log('Final sync (with proper identifier quoting) complete!');

+ 32 - 0
test_admin_features.md

@@ -0,0 +1,32 @@
+# Admin Feature Verification Test Cases
+
+## 1. User Management Access Control
+- [ ] Non-admin users should NOT see the "User Management" menu item
+- [ ] Admin users should see the "User Management" menu item
+- [ ] Non-admin users attempting to access user management should get a permission error
+- [ ] Admin users should be able to access user management successfully
+
+## 2. Admin User Password Modification
+- [ ] Admin users should see a "Change Password" button for each user in the user list
+- [ ] Clicking the button should open a password change modal
+- [ ] Admin users should be able to submit new passwords for other users
+- [ ] The password change should persist in the backend
+- [ ] Non-admin users should not have access to this functionality
+
+## 3. Knowledge Base Upload Restrictions
+- [ ] Non-admin users should NOT see the "Upload File" button in Knowledge Base View
+- [ ] Admin users should see the "Upload File" button in Knowledge Base View
+- [ ] Non-admin users attempting to upload directly via API should get a permission error
+- [ ] Admin users should be able to upload files successfully
+
+## 4. Knowledge Group Upload Restrictions
+- [ ] Non-admin users should NOT see the "Add File" or "Import Folder" buttons in Knowledge Group View
+- [ ] Admin users should see the "Add File" and "Import Folder" buttons in Knowledge Group View
+- [ ] Non-admin users attempting to upload via API should get a permission error
+- [ ] Admin users should be able to upload files to knowledge groups successfully
+
+## 5. Backend Security
+- [ ] Upload endpoints (POST /upload and POST /upload/text) should require AdminGuard
+- [ ] Import task endpoint (POST /import-tasks) should require AdminGuard
+- [ ] User update endpoint (PUT /users/:id) should accept password changes from admins
+- [ ] All existing functionality should remain operational for authorized users

+ 7 - 1
web/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:20-alpine as builder
+FROM node:22-alpine as builder
 
 WORKDIR /app
 
@@ -23,6 +23,12 @@ RUN yarn build
 # 使用nginx提供静态文件
 FROM nginx:alpine
 
+# 删除默认配置
+RUN rm /etc/nginx/conf.d/default.conf
+
+# 复制自定义 Nginx 配置
+COPY nginx/conf.d/kb.conf /etc/nginx/conf.d/kb.conf
+
 # 复制构建产物到nginx目录
 COPY --from=builder /app/dist /usr/share/nginx/html
 

+ 294 - 0
web/components/ConfigPanel.tsx

@@ -0,0 +1,294 @@
+
+import React from 'react';
+import { AppSettings, ModelConfig, ModelType } from '../types';
+import { useLanguage } from '../contexts/LanguageContext';
+import { useConfirm } from '../contexts/ConfirmContext';
+import { Settings, Database, Sliders, Layers, Cpu, ChevronRight } from 'lucide-react';
+import VisionModelSelector from './VisionModelSelector';
+
+interface ConfigPanelProps {
+  settings: AppSettings;
+  models: ModelConfig[];
+  onSettingsChange: (newSettings: AppSettings) => void;
+  onOpenSettings: () => void;
+  mode?: 'chat' | 'kb' | 'all';
+  isAdmin?: boolean;
+}
+
+const ConfigPanel: React.FC<ConfigPanelProps> = ({ settings, models, onSettingsChange, onOpenSettings, mode = 'all', isAdmin = false }) => {
+  const { t } = useLanguage();
+  const { confirm } = useConfirm();
+
+  const handleChange = (key: keyof AppSettings, value: any) => {
+    onSettingsChange({
+      ...settings,
+      [key]: value,
+    });
+  };
+
+  const llmModels = models.filter(m => m.type === ModelType.LLM && m.isEnabled !== false && !m.supportsVision);
+  const embeddingModels = models.filter(m => m.type === ModelType.EMBEDDING && m.isEnabled !== false);
+  const rerankModels = models.filter(m => m.type === ModelType.RERANK && m.isEnabled !== false);
+
+  const showChatSettings = mode === 'chat' || mode === 'all';
+  const showKbSettings = mode === 'kb' || mode === 'all';
+
+  return (
+    <div className="flex-1 overflow-y-auto p-4 space-y-6 bg-slate-50">
+      {!isAdmin && (
+        <div className="bg-orange-50 border border-orange-200 p-3 rounded-lg mb-4">
+          <p className="text-xs text-orange-700 flex items-center gap-2">
+            <Sliders className="w-3 h-3" />
+            {t('onlyAdminCanModify') || "Only administrators can modify system settings."}
+          </p>
+        </div>
+      )}
+
+      {/* Model Selection (LLM) - Chat Mode Only */}
+      {showChatSettings && (
+        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
+          <div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2">
+            <div className="flex items-center gap-2 text-slate-800 font-semibold">
+              <Cpu className="w-4 h-4 text-blue-600" />
+              {t('headerModelSelection')}
+            </div>
+          </div>
+
+          <div className="space-y-4">
+            <div>
+              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('selectLLMModel')}</label>
+              <select
+                value={settings.selectedLLMId}
+                onChange={(e) => handleChange('selectedLLMId', e.target.value)}
+                disabled={!isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
+              >
+                <option value="">{t('selectLLMModel')}</option>
+                {llmModels.map(m => (
+                  <option key={m.id} value={m.id}>
+                    {m.name} ({m.modelId})
+                  </option>
+                ))}
+              </select>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Embedding Model Selection - KB Mode Only */}
+      {showKbSettings && (
+        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
+          <div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2">
+            <div className="flex items-center gap-2 text-slate-800 font-semibold">
+              <Cpu className="w-4 h-4 text-blue-600" />
+              {t('lblEmbedding')}
+            </div>
+          </div>
+
+          <div className="space-y-4">
+            <div>
+              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblEmbedding')}</label>
+              <select
+                value={settings.selectedEmbeddingId}
+                onChange={async (e) => {
+                  const newId = e.target.value;
+                  if (newId !== settings.selectedEmbeddingId && settings.selectedEmbeddingId) {
+                    if (await confirm(t('confirmChangeEmbeddingModel') || "WARNING: Changing the embedding model will require re-indexing all existing files. Are you sure?")) {
+                      handleChange('selectedEmbeddingId', newId);
+                    }
+                  } else {
+                    handleChange('selectedEmbeddingId', newId);
+                  }
+                }}
+                disabled={!isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
+              >
+                <option value="">--- {t('selectEmbeddingModel')} ---</option>
+                {embeddingModels.map(m => (
+                  <option key={m.id} value={m.id}>{m.name}</option>
+                ))}
+              </select>
+              <p className="text-[10px] text-slate-400 mt-1">{t('defaultForUploads')}</p>
+              <p className="text-[10px] text-orange-500 mt-1">{t('embeddingModelWarning') || "Changing this setting may require clearing and re-importing your knowledge base."}</p>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Hyperparameters - Chat Mode Only */}
+      {showChatSettings && (
+        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
+          <div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
+            <Sliders className="w-4 h-4 text-pink-500" />
+            {t('headerHyperparams')}
+          </div>
+
+          <div className="space-y-4">
+            <div>
+              <div className="flex justify-between mb-1.5">
+                <label className="text-xs font-medium text-slate-500">{t('lblTemperature')}</label>
+                <span className="text-xs text-blue-600 font-bold">{settings.temperature}</span>
+              </div>
+              <input
+                type="range"
+                min="0"
+                max="1"
+                step="0.1"
+                value={settings.temperature}
+                onChange={(e) => handleChange('temperature', parseFloat(e.target.value))}
+                disabled={!isAdmin}
+                className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
+              />
+            </div>
+            <div>
+              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblMaxTokens')}</label>
+              <input
+                type="number"
+                value={settings.maxTokens}
+                onChange={(e) => handleChange('maxTokens', parseInt(e.target.value))}
+                disabled={!isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
+              />
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Vision Model Settings - Chat Mode Only? Or both? Assuming Chat */}
+      {/* Vision Model Settings - KB Only */}
+      {showKbSettings && <VisionModelSelector isAdmin={isAdmin} />}
+
+      {/* Retrieval Settings - KB Mode Only */}
+      {showKbSettings && (
+        <div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
+          <div className="flex items-center gap-2 mb-4 text-slate-800 font-semibold border-b border-slate-100 pb-2">
+            <Database className="w-4 h-4 text-green-600" />
+            {t('headerRetrieval')}
+          </div>
+
+          <div className="space-y-4">
+            <div>
+              <div className="flex justify-between mb-1.5">
+                <label className="text-xs font-medium text-slate-500">{t('lblTopK')}</label>
+                <span className="text-xs text-blue-600 font-bold">{settings.topK}</span>
+              </div>
+              <input
+                type="range"
+                min="1"
+                max="20"
+                step="1"
+                value={settings.topK}
+                onChange={(e) => handleChange('topK', parseInt(e.target.value))}
+                disabled={!isAdmin}
+                className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
+              />
+            </div>
+
+            <div>
+              <label className="block text-xs font-medium text-slate-500 mb-1.5">{t('lblRerankRef')}</label>
+              <select
+                value={settings.selectedRerankId}
+                onChange={(e) => handleChange('selectedRerankId', e.target.value)}
+                disabled={!settings.enableRerank || !isAdmin}
+                className="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-slate-700 focus:outline-none focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
+              >
+                <option value="">--- {t('noRerankModel')} ---</option>
+                {rerankModels.map(m => (
+                  <option key={m.id} value={m.id}>{m.name}</option>
+                ))}
+              </select>
+            </div>
+
+            <div>
+              <div className="flex justify-between mb-1.5">
+                <label className="text-xs font-medium text-slate-500">{t('vectorSimilarityThreshold')}</label>
+                <span className="text-xs text-blue-600 font-bold">{settings.similarityThreshold}</span>
+              </div>
+              <input
+                type="range"
+                min="0.0"
+                max="1.0"
+                step="0.05"
+                value={settings.similarityThreshold}
+                onChange={(e) => handleChange('similarityThreshold', parseFloat(e.target.value))}
+                disabled={!isAdmin}
+                className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
+              />
+              <p className="text-[10px] text-slate-400 mt-1">{t('filterLowResults')}</p>
+            </div>
+
+            {settings.enableRerank && (
+              <div>
+                <div className="flex justify-between mb-1.5">
+                  <label className="text-xs font-medium text-slate-500">{t('rerankSimilarityThreshold')}</label>
+                  <span className="text-xs text-blue-600 font-bold">{settings.rerankSimilarityThreshold}</span>
+                </div>
+                <input
+                  type="range"
+                  min="0.0"
+                  max="1.0"
+                  step="0.05"
+                  value={settings.rerankSimilarityThreshold}
+                  onChange={(e) => handleChange('rerankSimilarityThreshold', parseFloat(e.target.value))}
+                  className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-pink-600"
+                />
+              </div>
+            )}
+
+            <div className="flex items-center justify-between pt-2">
+              <label className="text-sm text-slate-700">{t('lblRerank')}</label>
+              <button
+                onClick={() => isAdmin && handleChange('enableRerank', !settings.enableRerank)}
+                disabled={!isAdmin}
+                className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableRerank ? 'bg-blue-600' : 'bg-slate-300'
+                  } ${!isAdmin ? 'cursor-not-allowed opacity-50' : ''}`}
+              >
+                <span
+                  className={`${settings.enableRerank ? 'translate-x-6' : 'translate-x-1'
+                    } inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
+                />
+              </button>
+            </div>
+
+            <div className="flex items-center justify-between pt-2">
+              <label className="text-sm text-slate-700">{t('fullTextSearch')}</label>
+              <button
+                onClick={() => isAdmin && handleChange('enableFullTextSearch', !settings.enableFullTextSearch)}
+                disabled={!isAdmin}
+                className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${settings.enableFullTextSearch ? 'bg-blue-600' : 'bg-slate-300'
+                  } ${!isAdmin ? 'cursor-not-allowed opacity-50' : ''}`}
+              >
+                <span
+                  className={`${settings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'
+                    } inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
+                />
+              </button>
+            </div>
+
+            {settings.enableFullTextSearch && (
+              <div className="pt-2 animate-in fade-in slide-in-from-top-1 duration-200">
+                <div className="flex justify-between mb-1.5">
+                  <label className="text-xs font-medium text-slate-500">{t('hybridVectorWeight')}</label>
+                  <span className="text-xs text-blue-600 font-bold">{settings.hybridVectorWeight}</span>
+                </div>
+                <input
+                  type="range"
+                  min="0.0"
+                  max="1.0"
+                  step="0.05"
+                  value={settings.hybridVectorWeight}
+                  onChange={(e) => handleChange('hybridVectorWeight', parseFloat(e.target.value))}
+                  disabled={!isAdmin}
+                  className={`w-full h-2 rounded-lg appearance-none cursor-pointer accent-blue-600 ${!isAdmin ? 'bg-slate-100' : 'bg-slate-200'}`}
+                />
+                <p className="text-[10px] text-slate-400 mt-1">{t('hybridVectorWeightDesc')}</p>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default ConfigPanel;

+ 4 - 4
web/components/DragDropUpload.tsx

@@ -84,15 +84,15 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
         <div className="flex flex-wrap items-center justify-center gap-4 mb-8">
           <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
             <ShieldCheck size={14} className="text-emerald-500" />
-            <span>Secure Ingestion</span>
+            <span>{t('secureIngestion')}</span>
           </div>
           <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
             <FileText size={14} className="text-blue-500" />
-            <span>Documents & Text</span>
+            <span>{t('documentsAndText')}</span>
           </div>
           <div className="flex items-center gap-2 px-3 py-1.5 bg-white border border-slate-100 rounded-full text-[11px] font-bold text-slate-500 uppercase tracking-wider shadow-sm">
             <ImageIcon size={14} className="text-purple-500" />
-            <span>Images & Vision</span>
+            <span>{t('imagesAndVision')}</span>
           </div>
         </div>
 
@@ -126,7 +126,7 @@ export const DragDropUpload: React.FC<DragDropUploadProps> = ({ onFilesSelected,
               <div className="w-12 h-12 bg-blue-600 text-white rounded-2xl flex items-center justify-center animate-bounce">
                 <FileUp size={24} />
               </div>
-              <span className="text-blue-600 font-bold">Drop to Ingest</span>
+              <span className="text-blue-600 font-bold">{t('dropToIngest')}</span>
             </div>
           </motion.div>
         )}

+ 5 - 5
web/components/GlobalDragDropOverlay.tsx

@@ -131,27 +131,27 @@ export const GlobalDragDropOverlay: React.FC<GlobalDragDropProps> = ({ onFilesSe
                   {t('dragDropUploadTitle')}
                 </h3>
                 <p className="text-lg text-slate-500 font-medium">
-                  Drop your files anywhere to start processing
+                  {t('dropAnywhere')}
                 </p>
               </div>
 
               <div className="flex flex-wrap items-center justify-center gap-6 py-8 border-y border-slate-100 w-full">
                 <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
                   <ShieldCheck size={20} className="text-emerald-500" />
-                  <span>Secure Processing</span>
+                  <span>{t('secureProcessing')}</span>
                 </div>
                 <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
                   <FileText size={20} className="text-blue-500" />
-                  <span>All Formats</span>
+                  <span>{t('allFormats')}</span>
                 </div>
                 <div className="flex items-center gap-3 px-4 py-2 bg-slate-50 rounded-2xl text-sm font-bold text-slate-600 uppercase tracking-wider">
                   <ImageIcon size={20} className="text-purple-500" />
-                  <span>Visual Vision</span>
+                  <span>{t('visualVision')}</span>
                 </div>
               </div>
 
               <div className="text-slate-400 font-bold text-xs uppercase tracking-[0.3em]">
-                Release to begin ingestion
+                {t('releaseToIngest')}
               </div>
             </div>
           </motion.div>

+ 414 - 122
web/components/ImportFolderDrawer.tsx

@@ -1,10 +1,11 @@
 import React, { useState, useEffect } from 'react';
-import { X, FolderInput, ArrowRight, Info } from 'lucide-react';
-import { GROUP_ALLOWED_EXTENSIONS, isExtensionAllowed, getSupportedFormatsLabel } from '../constants/fileSupport';
+import { X, FolderInput, ArrowRight, Info, Layers, Clock, Upload, Calendar } from 'lucide-react';
+import { isExtensionAllowed } from '../constants/fileSupport';
 import { useLanguage } from '../contexts/LanguageContext';
-import { ModelConfig, ModelType, IndexingConfig } from '../types';
+import { ModelConfig, ModelType, IndexingConfig, KnowledgeGroup } from '../types';
 import { modelConfigService } from '../services/modelConfigService';
 import { knowledgeGroupService } from '../services/knowledgeGroupService';
+import { apiClient } from '../services/apiClient';
 import { useToast } from '../contexts/ToastContext';
 import IndexingModalWithMode from './IndexingModalWithMode';
 
@@ -12,131 +13,230 @@ interface ImportFolderDrawerProps {
     isOpen: boolean;
     onClose: () => void;
     authToken: string;
-    initialGroupId?: string; // If provided, locks target to this group
+    initialGroupId?: string;
     initialGroupName?: string;
     onImportSuccess?: () => void;
 }
 
+interface FileWithPath {
+    file: File;
+    relativePath: string;
+}
+
+type ImportMode = 'immediate' | 'scheduled';
+
 export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
     isOpen,
     onClose,
     authToken,
     initialGroupId,
     initialGroupName,
-    onImportSuccess
+    onImportSuccess,
 }) => {
     const { t } = useLanguage();
     const { showError, showSuccess } = useToast();
 
-    // Form State
-    const [localFiles, setLocalFiles] = useState<File[]>([]);
+    // Tab
+    const [importMode, setImportMode] = useState<ImportMode>('immediate');
+
+    // Immediate mode state
+    const [localFiles, setLocalFiles] = useState<FileWithPath[]>([]);
     const [folderName, setFolderName] = useState('');
     const [targetName, setTargetName] = useState('');
+    const [useHierarchy, setUseHierarchy] = useState(false);
     const fileInputRef = React.useRef<HTMLInputElement>(null);
-
-    // Indexing Config State
     const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false);
-
-    // Data State
     const [models, setModels] = useState<ModelConfig[]>([]);
+
+    // Scheduled mode state
+    const [serverPath, setServerPath] = useState('');
+    const [scheduledTime, setScheduledTime] = useState(() => {
+        // Default to 30 min from now
+        const d = new Date();
+        d.setMinutes(d.getMinutes() + 30);
+        d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
+        return d.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
+    });
+    const [schedTargetName, setSchedTargetName] = useState('');
+    const [schedUseHierarchy, setSchedUseHierarchy] = useState(false);
+
     const [isLoading, setIsLoading] = useState(false);
+    const [allGroups, setAllGroups] = useState<KnowledgeGroup[]>([]);
+    const [parentGroupId, setParentGroupId] = useState<string>('');
+    const [schedParentGroupId, setSchedParentGroupId] = useState<string>('');
 
     useEffect(() => {
         if (isOpen) {
-            // Reset form
             setLocalFiles([]);
             setFolderName('');
             setTargetName(initialGroupName || '');
+            setUseHierarchy(false);
             setIsIndexingConfigOpen(false);
+            setImportMode('immediate');
+            setServerPath('');
+            setSchedTargetName(initialGroupName || '');
+            setSchedUseHierarchy(false);
+            setParentGroupId('');
+            setSchedParentGroupId('');
+
+            // Default scheduled time = 30min from now
+            const d = new Date();
+            d.setMinutes(d.getMinutes() + 30);
+            d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
+            setScheduledTime(d.toISOString().slice(0, 16));
 
-            // Fetch models
             modelConfigService.getAll(authToken).then(res => {
                 setModels(res.filter(m => m.type === ModelType.EMBEDDING));
             });
+
+            knowledgeGroupService.getGroups().then(groups => {
+                const flat: any[] = [];
+                function walk(items: any[], depth = 0) {
+                    for (const g of items) {
+                        flat.push({ ...g, d: depth });
+                        if (g.children?.length) walk(g.children, depth + 1);
+                    }
+                }
+                walk(groups);
+                setAllGroups(flat);
+            });
         }
     }, [isOpen, authToken, initialGroupName]);
 
+    // ---- Immediate mode handlers ----
     const handleLocalFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
         if (e.target.files && e.target.files.length > 0) {
             const allFiles = Array.from(e.target.files);
-            // Filter files by allowed extensions
-            const files = allFiles.filter(file => {
-                const ext = file.name.split('.').pop() || '';
-                return isExtensionAllowed(ext, 'group');
-            });
+            const files: FileWithPath[] = allFiles
+                .filter(file => {
+                    const ext = file.name.split('.').pop() || '';
+                    return isExtensionAllowed(ext, 'group');
+                })
+                .map(file => ({
+                    file,
+                    relativePath: file.webkitRelativePath || file.name,
+                }));
 
             if (files.length === 0 && allFiles.length > 0) {
                 showError(t('noFilesFound'));
                 return;
             }
-
             setLocalFiles(files);
 
-            // Get root folder name from webkitRelativePath
-            const firstPath = allFiles[0].webkitRelativePath; // Use allFiles to get path even if filtered
+            const firstPath = allFiles[0].webkitRelativePath;
             if (firstPath) {
                 const parts = firstPath.split('/');
                 if (parts.length > 0) {
                     const name = parts[0];
                     setFolderName(name);
-                    if (!initialGroupId && !targetName) {
-                        setTargetName(name);
-                    }
+                    if (!initialGroupId && !targetName) setTargetName(name);
                 }
             }
         }
     };
 
-    const handleNext = async () => {
+    const handleImmediateNext = () => {
         if (localFiles.length === 0) {
             showError(t('clickToSelectFolder'));
             return;
         }
-
         if (!initialGroupId && !targetName) {
             showError(t('fillTargetName'));
             return;
         }
-
-        // Open indexing config modal
         setIsIndexingConfigOpen(true);
     };
 
     const handleConfirmConfig = async (config: IndexingConfig) => {
         setIsLoading(true);
         try {
-            // 1. Ensure target group exists or create it
-            let groupId = initialGroupId;
-            if (!groupId) {
-                const newGroup = await knowledgeGroupService.createGroup({
-                    name: targetName,
-                    description: t('importedFromLocalFolder').replace('$1', folderName)
-                });
-                groupId = newGroup.id;
-            }
-
-            // 2. Upload files in batches
             const { uploadService } = await import('../services/uploadService');
-            const { readFile } = await import('../utils/fileUtils');
-
-            // Limit concurrent uploads for large folders
-            const BATCH_SIZE = 3;
-            for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
-                const batch = localFiles.slice(i, i + BATCH_SIZE);
-                await Promise.all(batch.map(async (file) => {
-                    try {
-                        await readFile(file); // Optional: verify readability
-                        const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
-                        if (groupId) {
-                            await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
-                        }
-                    } catch (err) {
-                        console.error(`Failed to upload ${file.name}:`, err);
+
+            if (useHierarchy) {
+                // Step 1: Determine root group
+                let rootGroupId = initialGroupId ?? null;
+                if (!rootGroupId) {
+                    const newGroup = await knowledgeGroupService.createGroup({
+                        name: targetName,
+                        description: t('importedFromLocalFolder').replace('$1', folderName),
+                        parentId: parentGroupId || null,
+                    });
+                    rootGroupId = newGroup.id;
+                }
+
+                // Step 2: Collect all unique directory paths
+                const dirSet = new Set<string>();
+                for (const { relativePath } of localFiles) {
+                    const parts = relativePath.split('/');
+                    const dirParts = initialGroupId
+                        ? parts.slice(1, parts.length - 1)
+                        : parts.slice(0, parts.length - 1);
+                    for (let i = 1; i <= dirParts.length; i++) {
+                        dirSet.add(dirParts.slice(0, i).join('/'));
                     }
-                }));
+                }
+
+                // Step 3: Sort by depth, create groups sequentially
+                const sortedDirs = Array.from(dirSet).sort((a, b) =>
+                    a.split('/').length - b.split('/').length
+                );
+                const dirToGroupId = new Map<string, string>();
+                dirToGroupId.set(initialGroupId ? '' : folderName, rootGroupId);
+
+                for (const dirPath of sortedDirs) {
+                    if (!dirPath || dirToGroupId.has(dirPath)) continue;
+                    const segments = dirPath.split('/');
+                    const segName = segments[segments.length - 1];
+                    const parentPath = segments.slice(0, segments.length - 1).join('/');
+                    const parentId = dirToGroupId.get(parentPath) ?? rootGroupId;
+                    const newGroup = await knowledgeGroupService.createGroup({ name: segName, parentId });
+                    dirToGroupId.set(dirPath, newGroup.id);
+                }
+
+                // Step 4: Upload files in parallel batches
+                const BATCH_SIZE = 3;
+                for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
+                    const batch = localFiles.slice(i, i + BATCH_SIZE);
+                    await Promise.all(batch.map(async ({ file, relativePath }) => {
+                        try {
+                            const parts = relativePath.split('/');
+                            const dirParts = initialGroupId
+                                ? parts.slice(1, parts.length - 1)
+                                : parts.slice(0, parts.length - 1);
+                            const fileDirPath = dirParts.join('/');
+                            const targetGroupId = dirToGroupId.get(fileDirPath) ?? rootGroupId!;
+                            const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
+                            await knowledgeGroupService.addFileToGroups(uploadedKb.id, [targetGroupId]);
+                        } catch (err) {
+                            console.error(`Failed to upload ${file.name}:`, err);
+                        }
+                    }));
+                }
+            } else {
+                // Single-group mode
+                let groupId = initialGroupId ?? null;
+                if (!groupId) {
+                    const newGroup = await knowledgeGroupService.createGroup({
+                        name: targetName,
+                        description: t('importedFromLocalFolder').replace('$1', folderName),
+                    });
+                    groupId = newGroup.id;
+                }
+                const BATCH_SIZE = 3;
+                for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
+                    const batch = localFiles.slice(i, i + BATCH_SIZE);
+                    await Promise.all(batch.map(async ({ file }) => {
+                        try {
+                            const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
+                            if (groupId) await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
+                        } catch (err) {
+                            console.error(`Failed to upload ${file.name}:`, err);
+                        }
+                    }));
+                }
             }
-            showSuccess(t('importComplete'));
 
+            showSuccess(t('importComplete'));
             onImportSuccess?.();
             onClose();
         } catch (error: any) {
@@ -147,6 +247,58 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
         }
     };
 
+    // ---- Scheduled mode handler ----
+    const handleScheduledSubmit = async () => {
+        if (!serverPath.trim()) {
+            showError(t('fillServerPath'));
+            return;
+        }
+        if (!initialGroupId && !schedTargetName.trim()) {
+            showError(t('fillTargetName'));
+            return;
+        }
+
+        const scheduledAt = new Date(scheduledTime);
+        if (isNaN(scheduledAt.getTime())) {
+            showError(t('invalidDateTime' as any));
+            return;
+        }
+
+        setIsLoading(true);
+        try {
+            let finalGroupId = initialGroupId || undefined;
+            if (!finalGroupId && schedTargetName.trim()) {
+                const newGroup = await knowledgeGroupService.createGroup({
+                    name: schedTargetName.trim(),
+                    description: t('importedFromLocalFolder').replace('$1', schedTargetName.trim()),
+                    parentId: schedParentGroupId || null,
+                });
+                finalGroupId = newGroup.id;
+            }
+
+            const defaultModel = models[0];
+            await apiClient.post('/import-tasks', {
+                sourcePath: serverPath.trim(),
+                targetGroupId: finalGroupId,
+                targetGroupName: undefined,
+                embeddingModelId: defaultModel?.id,
+                scheduledAt: scheduledAt.toISOString(),
+                chunkSize: 500,
+                chunkOverlap: 50,
+                mode: 'fast',
+                useHierarchy: schedUseHierarchy,
+            });
+
+            showSuccess(t('scheduleTaskCreated'));
+            onImportSuccess?.();
+            onClose();
+        } catch (error: any) {
+            showError(t('submitFailed', error.message));
+        } finally {
+            setIsLoading(false);
+        }
+    };
+
     if (!isOpen) return null;
 
     return (
@@ -166,65 +318,167 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
                         </button>
                     </div>
 
+                    {/* Mode Tabs */}
+                    <div className="flex border-b border-slate-100 shrink-0">
+                        <button
+                            onClick={() => setImportMode('immediate')}
+                            className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'immediate'
+                                ? 'border-blue-600 text-blue-600 bg-blue-50/40'
+                                : 'border-transparent text-slate-500 hover:text-slate-700'
+                                }`}
+                        >
+                            <Upload size={15} />
+                            {t('importImmediate')}
+                        </button>
+                        <button
+                            onClick={() => setImportMode('scheduled')}
+                            className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'scheduled'
+                                ? 'border-blue-600 text-blue-600 bg-blue-50/40'
+                                : 'border-transparent text-slate-500 hover:text-slate-700'
+                                }`}
+                        >
+                            <Clock size={15} />
+                            {t('importScheduled')}
+                        </button>
+                    </div>
+
                     {/* Body */}
-                    <div className="flex-1 overflow-y-auto p-6 space-y-6">
-                        <div className="bg-blue-50 border border-blue-100 rounded-lg p-3 text-sm text-blue-800 flex items-start gap-2">
-                            <Info className="w-4 h-4 mt-0.5 shrink-0" />
-                            <div>
-                                <p className="font-medium underline decoration-blue-200 underline-offset-2 mb-1">
-                                    {t('supportedFormatsInfo')}
-                                </p>
-                                <p className="opacity-80 text-xs">
-                                    {t('importFolderTip')}
-                                </p>
-                            </div>
-                        </div>
-
-                        {/* Local Folder Selection */}
-                        <div className="space-y-4 animate-in fade-in slide-in-from-top-2">
-                            <div
-                                onClick={() => fileInputRef.current?.click()}
-                                className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group"
-                            >
-                                <div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
-                                    <FolderInput size={24} />
+                    <div className="flex-1 overflow-y-auto p-6 space-y-5">
+                        {importMode === 'immediate' ? (
+                            <>
+                                {/* Immediate: folder picker */}
+                                <div
+                                    onClick={() => fileInputRef.current?.click()}
+                                    className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group"
+                                >
+                                    <div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
+                                        <FolderInput size={24} />
+                                    </div>
+                                    <div className="text-center">
+                                        <p className="text-sm font-medium text-slate-700">
+                                            {localFiles.length > 0
+                                                ? t('selectedFilesCount').replace('$1', localFiles.length.toString())
+                                                : t('clickToSelectFolder')}
+                                        </p>
+                                        <p className="text-xs text-slate-400 mt-1">
+                                            {localFiles.length > 0 ? folderName : t('selectFolderTip')}
+                                        </p>
+                                    </div>
+                                    <input
+                                        type="file"
+                                        ref={fileInputRef}
+                                        onChange={handleLocalFolderChange}
+                                        className="hidden"
+                                        multiple
+                                        // @ts-ignore
+                                        webkitdirectory=""
+                                        directory=""
+                                    />
                                 </div>
-                                <div className="text-center">
-                                    <p className="text-sm font-medium text-slate-700">
-                                        {localFiles.length > 0
-                                            ? t('selectedFilesCount').replace('$1', localFiles.length.toString())
-                                            : t('clickToSelectFolder')}
-                                    </p>
-                                    <p className="text-xs text-slate-400 mt-1">
-                                        {localFiles.length > 0 ? folderName : t('selectFolderTip')}
-                                    </p>
+
+                                {/* Target group */}
+                                <div className="space-y-1.5">
+                                    <label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
+                                    <input
+                                        type="text"
+                                        value={targetName}
+                                        onChange={e => setTargetName(e.target.value)}
+                                        disabled={!!initialGroupId}
+                                        placeholder={t('placeholderNewGroup')}
+                                        className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
+                                    />
+                                    {initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
+                                </div>
+                                {!initialGroupId && (
+                                    <div className="space-y-1.5">
+                                        <label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
+                                        <select
+                                            value={parentGroupId}
+                                            onChange={e => setParentGroupId(e.target.value)}
+                                            className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                                        >
+                                            <option value="">{t('allGroups' as any) || '-- Root --'}</option>
+                                            {allGroups.map((g: any) => (
+                                                <option key={g.id} value={g.id}>
+                                                    {'\u00A0'.repeat(g.d * 4)}{g.name}
+                                                </option>
+                                            ))}
+                                        </select>
+                                    </div>
+                                )}
+
+                                {/* Hierarchy toggle */}
+                                <HierarchyToggle value={useHierarchy} onChange={setUseHierarchy} t={t} />
+                            </>
+                        ) : (
+                            <>
+                                {/* Scheduled: server path */}
+                                <div className="bg-amber-50 border border-amber-100 rounded-lg p-3 text-sm text-amber-800 flex items-start gap-2">
+                                    <Info className="w-4 h-4 mt-0.5 shrink-0" />
+                                    <p className="text-xs">{t('scheduledImportTip')}</p>
                                 </div>
-                                <input
-                                    type="file"
-                                    ref={fileInputRef}
-                                    onChange={handleLocalFolderChange}
-                                    className="hidden"
-                                    multiple
-                                    // @ts-ignore
-                                    webkitdirectory=""
-                                    directory=""
-                                />
-                            </div>
-                        </div>
-
-                        {/* Target Group */}
-                        <div className="space-y-2">
-                            <label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
-                            <input
-                                type="text"
-                                value={targetName}
-                                onChange={e => setTargetName(e.target.value)}
-                                disabled={!!initialGroupId} // Readonly if locking to group
-                                placeholder={t('placeholderNewGroup')}
-                                className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
-                            />
-                            {initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
-                        </div>
+
+                                <div className="space-y-1.5">
+                                    <label className="text-sm font-medium text-slate-700">{t('lblServerPath')}</label>
+                                    <input
+                                        type="text"
+                                        value={serverPath}
+                                        onChange={e => setServerPath(e.target.value)}
+                                        placeholder={t('placeholderServerPath')}
+                                        className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none font-mono"
+                                    />
+                                </div>
+
+                                {/* Target group */}
+                                <div className="space-y-1.5">
+                                    <label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
+                                    <input
+                                        type="text"
+                                        value={schedTargetName}
+                                        onChange={e => setSchedTargetName(e.target.value)}
+                                        disabled={!!initialGroupId}
+                                        placeholder={t('placeholderNewGroup')}
+                                        className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
+                                    />
+                                    {initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
+                                </div>
+                                {!initialGroupId && (
+                                    <div className="space-y-1.5">
+                                        <label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
+                                        <select
+                                            value={schedParentGroupId}
+                                            onChange={e => setSchedParentGroupId(e.target.value)}
+                                            className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                                        >
+                                            <option value="">{t('allGroups' as any) || '-- Root --'}</option>
+                                            {allGroups.map((g: any) => (
+                                                <option key={g.id} value={g.id}>
+                                                    {'\u00A0'.repeat(g.d * 4)}{g.name}
+                                                </option>
+                                            ))}
+                                        </select>
+                                    </div>
+                                )}
+
+                                {/* Scheduled datetime */}
+                                <div className="space-y-1.5">
+                                    <label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
+                                        <Calendar size={14} className="text-blue-500" />
+                                        {t('lblScheduledTime')}
+                                    </label>
+                                    <input
+                                        type="datetime-local"
+                                        value={scheduledTime}
+                                        onChange={e => setScheduledTime(e.target.value)}
+                                        className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+                                    />
+                                    <p className="text-xs text-slate-400">{t('scheduledTimeHint')}</p>
+                                </div>
+
+                                {/* Hierarchy toggle */}
+                                <HierarchyToggle value={schedUseHierarchy} onChange={setSchedUseHierarchy} t={t} />
+                            </>
+                        )}
                     </div>
 
                     {/* Footer */}
@@ -235,23 +489,34 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
                         >
                             {t('cancel')}
                         </button>
-                        <button
-                            onClick={handleNext}
-                            className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
-                            disabled={isLoading}
-                        >
-                            <span>{isLoading ? t('uploading') : t('nextStep')}</span>
-                            <ArrowRight size={16} />
-                        </button>
+                        {importMode === 'immediate' ? (
+                            <button
+                                onClick={handleImmediateNext}
+                                className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
+                                disabled={isLoading}
+                            >
+                                <span>{isLoading ? t('uploading') : t('nextStep')}</span>
+                                <ArrowRight size={16} />
+                            </button>
+                        ) : (
+                            <button
+                                onClick={handleScheduledSubmit}
+                                className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
+                                disabled={isLoading}
+                            >
+                                <Clock size={16} />
+                                <span>{isLoading ? t('uploading') : t('scheduleImport')}</span>
+                            </button>
+                        )}
                     </div>
                 </div>
             </div>
 
-            {/* Indexing Config Modal */}
+            {/* Indexing Config Modal (immediate mode only) */}
             <IndexingModalWithMode
                 isOpen={isIndexingConfigOpen}
                 onClose={() => setIsIndexingConfigOpen(false)}
-                files={[]} // Empty array for folder import mode
+                files={[]}
                 embeddingModels={models}
                 defaultEmbeddingId={models.length > 0 ? models[0].id : ''}
                 onConfirm={handleConfirmConfig}
@@ -260,3 +525,30 @@ export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
         </>
     );
 };
+
+/** Reusable hierarchy toggle */
+const HierarchyToggle: React.FC<{
+    value: boolean;
+    onChange: (v: boolean) => void;
+    t: (key: string) => string;
+}> = ({ value, onChange, t }) => (
+    <label className="flex items-center gap-3 cursor-pointer select-none">
+        <div className="relative">
+            <input
+                type="checkbox"
+                checked={value}
+                onChange={e => onChange(e.target.checked)}
+                className="sr-only"
+            />
+            <div className={`w-10 h-5 rounded-full transition-colors ${value ? 'bg-blue-600' : 'bg-slate-200'}`} />
+            <div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-5' : ''}`} />
+        </div>
+        <div>
+            <p className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
+                <Layers size={14} className="text-blue-500" />
+                {t('useHierarchyImport')}
+            </p>
+            <p className="text-xs text-slate-400 mt-0.5">{t('useHierarchyImportDesc')}</p>
+        </div>
+    </label>
+);

+ 15 - 13
web/components/SettingsModal.tsx

@@ -149,7 +149,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
         setIsLoading(true);
         try {
             await userService.changePassword(passwordForm.current, passwordForm.new);
-            setPasswordSuccess(t('passwordChangeSuccess') || '密码修改成功');
+            setPasswordSuccess(t('passwordChangeSuccess'));
             setPasswordForm({ current: '', new: '', confirm: '' });
         } catch (err: any) {
             setError(err.message || t('passwordChangeFailed'));
@@ -235,7 +235,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
             // モデル一覧を再取得するためにページをリロード
             window.location.reload();
         } catch (err: any) {
-            setError(err.message || 'デフォルト設定に失敗しました');
+            setError(err.message || t('defaultSettingFailed'));
         } finally {
             setIsLoading(false);
         }
@@ -282,7 +282,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
             <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
                 <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
                     <Key className="w-4 h-4 text-blue-500" />
-                    {t('changePassword') || '修改密码'}
+                    {t('changePassword')}
                 </h3>
                 <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
                     <div>
@@ -322,7 +322,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
                         disabled={isLoading}
                         className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
                     >
-                        {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : (t('confirmChange') || '确认修改')}
+                        {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
                     </button>
                 </form>
             </section>
@@ -334,7 +334,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
                     className="flex items-center gap-2 text-red-600 hover:bg-red-50 px-4 py-2 rounded-lg transition-colors text-sm font-medium"
                 >
                     <LogOut className="w-4 h-4" />
-                    {t('logout') || '退出登录'}
+                    {t('logout')}
                 </button>
             </section>
         </div>
@@ -344,13 +344,15 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
         <div className="space-y-4 animate-in slide-in-from-right duration-300">
             <div className="flex justify-between items-center mb-4">
                 <h3 className="font-medium text-slate-700">{t('userList')}</h3>
-                <button
-                    onClick={() => setShowAddUser(!showAddUser)}
-                    className="flex items-center gap-1 text-sm bg-blue-600 text-white px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors"
-                >
-                    <Plus className="w-4 h-4" />
-                    {t('addUser')}
-                </button>
+                {currentUser?.role === 'SUPER_ADMIN' && (
+                    <button
+                        onClick={() => setShowAddUser(!showAddUser)}
+                        className="flex items-center gap-1 text-sm bg-blue-600 text-white px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors"
+                    >
+                        <Plus className="w-4 h-4" />
+                        {t('addUser')}
+                    </button>
+                )}
             </div>
 
             {showAddUser && (
@@ -466,7 +468,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
                                         {model.isDefault && (
                                             <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-amber-50 text-amber-700 text-xs font-medium rounded-full border border-amber-200">
                                                 <Star className="w-3 h-3 fill-amber-500 text-amber-500" />
-                                                デフォルト
+                                                {t('defaultBadge')}
                                             </span>
                                         )}
                                     </div>

+ 178 - 0
web/components/drawers/ImportTasksDrawer.tsx

@@ -0,0 +1,178 @@
+import React, { useState, useEffect } from 'react';
+import { X, Box, Loader2, Trash2 } from 'lucide-react';
+import { importService, ImportTask } from '../../services/importService';
+import { useLanguage } from '../../contexts/LanguageContext';
+import { knowledgeGroupService } from '../../services/knowledgeGroupService';
+import { KnowledgeGroup } from '../../types';
+
+interface ImportTasksDrawerProps {
+    isOpen: boolean;
+    onClose: () => void;
+    authToken: string;
+}
+
+export const ImportTasksDrawer: React.FC<ImportTasksDrawerProps> = ({
+    isOpen,
+    onClose,
+    authToken,
+}) => {
+    const { t } = useLanguage();
+    const [importTasks, setImportTasks] = useState<ImportTask[]>([]);
+    const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
+    const [isLoading, setIsLoading] = useState(false);
+
+    const fetchData = async () => {
+        if (!authToken) return;
+        try {
+            setIsLoading(true);
+            const [tasksResult, groupsData] = await Promise.all([
+                importService.getAll(authToken),
+                knowledgeGroupService.getGroups()
+            ]);
+            setImportTasks(tasksResult.items);
+
+            // Flatten the groups tree so we can easily find names by ID
+            const flat: KnowledgeGroup[] = [];
+            const walk = (items: KnowledgeGroup[]) => {
+                for (const g of items) {
+                    flat.push(g);
+                    if (g.children?.length) walk(g.children);
+                }
+            };
+            if (groupsData) walk(groupsData);
+            setGroups(flat);
+        } catch (error) {
+            console.error('Failed to fetch data:', error);
+        } finally {
+            setIsLoading(false);
+        }
+    };
+
+    const handleDelete = async (taskId: string) => {
+        if (!window.confirm(t('confirmDeleteTask'))) return;
+        try {
+            await importService.delete(authToken, taskId);
+            fetchData();
+        } catch (error) {
+            console.error('Failed to delete task:', error);
+            alert(t('deleteTaskFailed'));
+        }
+    };
+
+    useEffect(() => {
+        if (isOpen) {
+            fetchData();
+        }
+    }, [isOpen, authToken]);
+
+    if (!isOpen) return null;
+
+    return (
+        <div className="fixed inset-0 z-50 flex justify-end">
+            <div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={onClose} />
+
+            <div className="relative w-full max-w-4xl bg-white shadow-2xl flex flex-col h-full animate-in slide-in-from-right duration-300">
+                {/* Header */}
+                <div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
+                    <div className="flex items-center gap-3">
+                        <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
+                            <Box size={16} />
+                        </div>
+                        <h2 className="text-lg font-bold text-slate-800">{t('importTasksTitle')}</h2>
+                    </div>
+                    <div className="flex items-center gap-2">
+                        <button
+                            onClick={fetchData}
+                            className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-slate-50 rounded-lg transition-all"
+                            title={t('refresh')}
+                        >
+                            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
+                        </button>
+                        <button
+                            onClick={onClose}
+                            className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors"
+                        >
+                            <X size={20} />
+                        </button>
+                    </div>
+                </div>
+
+                {/* Body */}
+                <div className="flex-1 overflow-y-auto p-6 bg-slate-50/30">
+                    {isLoading ? (
+                        <div className="p-12 flex justify-center">
+                            <Loader2 className="animate-spin text-slate-300 w-8 h-8" />
+                        </div>
+                    ) : importTasks.length === 0 ? (
+                        <div className="p-12 text-center text-slate-400 flex flex-col items-center">
+                            <Box size={48} className="mb-4 opacity-20" />
+                            <span className="text-sm font-bold uppercase tracking-widest">{t('noTasksFound')}</span>
+                        </div>
+                    ) : (
+                        <div className="bg-white rounded-2xl border border-slate-200/60 shadow-sm overflow-hidden">
+                            <div className="overflow-x-auto">
+                                <table className="w-full text-left">
+                                    <thead>
+                                        <tr className="border-b border-slate-200/60 bg-slate-50/50 text-[10px] font-black uppercase tracking-widest text-slate-500">
+                                            <th className="px-6 py-4">{t('sourcePath')}</th>
+                                            <th className="px-6 py-4">{t('targetGroup')}</th>
+                                            <th className="px-6 py-4">{t('status')}</th>
+                                            <th className="px-6 py-4">{t('scheduledAt')}</th>
+                                            <th className="px-6 py-4">{t('createdAt')}</th>
+                                            <th className="px-6 py-4 text-right">{t('actions')}</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody className="divide-y divide-slate-100/80 text-sm">
+                                        {importTasks.map(task => (
+                                            <tr key={task.id} className="hover:bg-slate-50/50 transition-colors">
+                                                <td className="px-6 py-4 text-slate-900 font-medium">
+                                                    {task.sourcePath}
+                                                </td>
+                                                <td className="px-6 py-4 text-slate-500">
+                                                    {groups.find((g: any) => g.id === task.targetGroupId)?.name || task.targetGroupName || task.targetGroupId || '-'}
+                                                </td>
+                                                <td className="px-6 py-4">
+                                                    {(() => {
+                                                        let colorClass = 'bg-amber-100 text-amber-700';
+                                                        if (task.status === 'COMPLETED') colorClass = 'bg-emerald-100 text-emerald-700';
+                                                        else if (task.status === 'FAILED') colorClass = 'bg-red-100 text-red-700';
+                                                        else if (task.status === 'PROCESSING') colorClass = 'bg-blue-100 text-blue-700';
+                                                        return (
+                                                            <span className={`px-2 py-1 rounded-md text-xs font-bold ${colorClass}`}>
+                                                                {task.status}
+                                                            </span>
+                                                        );
+                                                    })()}
+                                                    {task.status === 'FAILED' && task.logs && (
+                                                        <div className="text-xs text-red-500 mt-1 max-w-xs truncate" title={task.logs}>
+                                                            {task.logs}
+                                                        </div>
+                                                    )}
+                                                </td>
+                                                <td className="px-6 py-4 text-slate-500">
+                                                    {task.scheduledAt ? new Date(task.scheduledAt).toLocaleString() : '-'}
+                                                </td>
+                                                <td className="px-6 py-4 text-slate-400 text-xs">
+                                                    {new Date(task.createdAt).toLocaleString()}
+                                                </td>
+                                                <td className="px-6 py-4 text-right">
+                                                    <button
+                                                        onClick={() => handleDelete(task.id)}
+                                                        className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
+                                                        title={t('delete')}
+                                                    >
+                                                        <Trash2 size={16} />
+                                                    </button>
+                                                </td>
+                                            </tr>
+                                        ))}
+                                    </tbody>
+                                </table>
+                            </div>
+                        </div>
+                    )}
+                </div>
+            </div>
+        </div>
+    );
+};

+ 174 - 0
web/components/views/AgentsView.tsx

@@ -0,0 +1,174 @@
+import React from 'react';
+import { useLanguage } from '../../contexts/LanguageContext';
+import { Search, Plus, MoreHorizontal, MessageSquare } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+// Mock data based on the provided design
+interface AgentMock {
+    id: string;
+    name: string;
+    description: string;
+    status: 'running' | 'stopped';
+    updatedAt: string;
+    iconEmoji: string;
+    iconBgClass: string;
+}
+
+const mockAgents: AgentMock[] = [
+    {
+        id: '1',
+        name: 'agent1Name',
+        description: 'agent1Desc',
+        status: 'running',
+        updatedAt: 'agent1Time',
+        iconEmoji: '🧑‍💻',
+        iconBgClass: 'bg-indigo-50'
+    },
+    {
+        id: '2',
+        name: 'agent2Name',
+        description: 'agent2Desc',
+        status: 'running',
+        updatedAt: 'agent2Time',
+        iconEmoji: '💻',
+        iconBgClass: 'bg-green-50'
+    },
+    {
+        id: '3',
+        name: 'agent3Name',
+        description: 'agent3Desc',
+        status: 'running',
+        updatedAt: 'agent3Time',
+        iconEmoji: '📐',
+        iconBgClass: 'bg-blue-50'
+    },
+    {
+        id: '4',
+        name: 'agent4Name',
+        description: 'agent4Desc',
+        status: 'stopped',
+        updatedAt: 'agent4Time',
+        iconEmoji: '🧪',
+        iconBgClass: 'bg-slate-100'
+    },
+    {
+        id: '5',
+        name: 'agent5Name',
+        description: 'agent5Desc',
+        status: 'running',
+        updatedAt: 'agent5Time',
+        iconEmoji: '📊',
+        iconBgClass: 'bg-purple-50'
+    },
+    {
+        id: '6',
+        name: 'agent6Name',
+        description: 'agent6Desc',
+        status: 'running',
+        updatedAt: 'agent6Time',
+        iconEmoji: '⚙️',
+        iconBgClass: 'bg-orange-50'
+    },
+    {
+        id: '7',
+        name: 'agent7Name',
+        description: 'agent7Desc',
+        status: 'running',
+        updatedAt: 'agent7Time',
+        iconEmoji: '📈',
+        iconBgClass: 'bg-red-50'
+    }
+];
+
+export const AgentsView: React.FC = () => {
+    const { t } = useLanguage();
+
+    return (
+        <div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
+            {/* Header Area */}
+            <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
+                <div>
+                    <h1 className="text-[22px] font-bold text-slate-900 leading-tight">
+                        {t('agentTitle')}
+                    </h1>
+                    <p className="text-[14px] text-slate-500 mt-1">{t('agentDesc')}</p>
+                </div>
+                <div className="flex items-center gap-4">
+                    <div className="relative w-64">
+                        <Search className="absolute text-slate-400 left-3 top-1/2 -translate-y-1/2" size={16} />
+                        <input
+                            type="text"
+                            placeholder={t('searchAgent')}
+                            className="w-full h-10 pl-10 pr-4 bg-white border border-slate-200 rounded-lg focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all text-sm font-medium"
+                        />
+                    </div>
+                    <button className="flex items-center gap-2 px-5 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95">
+                        <Plus size={18} />
+                        <span>{t('createAgent')}</span>
+                    </button>
+                </div>
+            </div>
+
+            {/* Content Area */}
+            <div className="px-8 pb-8 flex-1 overflow-y-auto">
+                <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1600px] mx-auto">
+                    <AnimatePresence>
+                        {mockAgents.map((agent) => (
+                            <motion.div
+                                key={agent.id}
+                                layout
+                                initial={{ opacity: 0, y: 10 }}
+                                animate={{ opacity: 1, y: 0 }}
+                                className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]"
+                            >
+                                {/* Top layer */}
+                                <div className="flex items-center justify-between mb-4">
+                                    <div className={`w-12 h-12 flex items-center justify-center rounded-xl ${agent.iconBgClass} text-2xl`}>
+                                        {agent.iconEmoji}
+                                    </div>
+                                    <div className="flex items-center gap-3">
+                                        {/* Status Badge */}
+                                        {agent.status === 'running' ? (
+                                            <div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100/50 flex flex-row items-center justify-center">
+                                                {t('statusRunning')}
+                                            </div>
+                                        ) : (
+                                            <div className="px-2.5 py-1 text-[12px] font-semibold text-slate-500 bg-slate-50 rounded-full border border-slate-100 flex flex-row items-center justify-center">
+                                                {t('statusStopped')}
+                                            </div>
+                                        )}
+                                        {/* Options button */}
+                                        <button className="text-slate-400 hover:text-slate-600 transition-colors">
+                                            <MoreHorizontal size={20} />
+                                        </button>
+                                    </div>
+                                </div>
+
+                                {/* Middle layer */}
+                                <div className="flex-1">
+                                    <h3 className="font-bold text-slate-800 text-[17px] mb-2 leading-tight">
+                                        {t(agent.name as any)}
+                                    </h3>
+                                    <p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
+                                        {t(agent.description as any)}
+                                    </p>
+                                </div>
+
+                                {/* Bottom layer */}
+                                <div className="mt-4 pt-4 border-t border-slate-50 flex items-center justify-between">
+                                    <span className="text-[12px] font-medium text-slate-400">
+                                        {t('updatedAtPrefix')}{t(agent.updatedAt as any)}
+                                    </span>
+                                    <button className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
+                                        <MessageSquare size={14} className="text-blue-500" />
+                                        <span className="text-[13px] font-bold">{t('btnChat')}</span>
+                                    </button>
+                                </div>
+                            </motion.div>
+                        ))}
+                    </AnimatePresence>
+                </div>
+            </div>
+        </div>
+    );
+};

+ 2 - 3
web/components/views/ChatView.tsx

@@ -125,12 +125,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
         }
     }, [authToken])
 
-    // Function to fetch files from backend
     const fetchAndSetFiles = useCallback(async () => {
         if (!authToken) return
         try {
-            const remoteFiles = await knowledgeBaseService.getAll(authToken)
-            setFiles(remoteFiles)
+            const data = await knowledgeBaseService.getAll(authToken)
+            setFiles(data.items)
         } catch (error) {
             console.error('Failed to fetch files:', error)
         }

File diff suppressed because it is too large
+ 669 - 188
web/components/views/KnowledgeBaseView.tsx


+ 23 - 23
web/components/views/MemosView.tsx

@@ -136,9 +136,9 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
             setShowAddCategory(false)
             setAddingSubCategoryId(null)
             fetchCategories()
-            showSuccess('Category created')
+            showSuccess(t('categoryCreated'))
         } catch (error: any) {
-            showError(`Failed to create category: ${error.message}`)
+            showError(`${t('failedToCreateCategory')}: ${error.message}`)
         }
     }
 
@@ -148,22 +148,22 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
             await noteCategoryService.update(authToken, id, editCategoryName.trim())
             setEditingCategoryId(null)
             fetchCategories()
-            showSuccess('Category updated')
+            showSuccess(t('groupUpdated'))
         } catch (error) {
-            showError('Failed to update category')
+            showError(t('actionFailed'))
         }
     }
 
     const handleDeleteCategory = async (e: React.MouseEvent, id: string) => {
         e.stopPropagation()
-        if (!(await confirm('Are you sure you want to delete this category? Sub-folders and notes will be affected.'))) return
+        if (!(await confirm(t('confirmDeleteCategory')))) return
         try {
             await noteCategoryService.delete(authToken, id)
             if (selectedCategoryId === id) setSelectedCategoryId(null)
             fetchCategories()
-            showSuccess('Category deleted')
+            showSuccess(t('groupDeleted'))
         } catch (error) {
-            showError('Failed to delete category')
+            showError(t('failedToDeleteCategory'))
         }
     }
 
@@ -227,7 +227,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                                                 }
                                             }}
                                             className="p-1 hover:text-blue-600"
-                                            title="Add sub-folder"
+                                            title={t('subFolderPlaceholder')}
                                         >
                                             <FolderPlus size={12} />
                                         </button>
@@ -266,7 +266,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                                         <input
                                             autoFocus
                                             className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
-                                            placeholder="Sub-folder..."
+                                            placeholder={t('subFolderPlaceholder')}
                                             value={newCategoryName}
                                             onChange={e => setNewCategoryName(e.target.value)}
                                             onBlur={() => !newCategoryName && setAddingSubCategoryId(null)}
@@ -291,7 +291,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                         <button
                             onClick={() => setIsEditing(false)}
                             className="p-2 -ml-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
-                            title="Back"
+                            title={t('back')}
                         >
                             <ArrowLeft size={20} />
                         </button>
@@ -300,13 +300,13 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                                 {currentNote.id ? t('editNote') : t('newNote')}
                             </h2>
                             <div className="flex items-center gap-2 mt-1">
-                                <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Directory:</span>
+                                <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('directoryLabel')}:</span>
                                 <select
                                     className="text-[11px] font-bold text-blue-600 bg-blue-50/50 px-2 py-0.5 rounded border-none outline-none focus:ring-0 cursor-pointer max-w-[150px] truncate"
                                     value={currentNote.categoryId || ''}
                                     onChange={(e) => setCurrentNote({ ...currentNote, categoryId: e.target.value || undefined })}
                                 >
-                                    <option value="">Uncategorized</option>
+                                    <option value="">{t('uncategorized')}</option>
                                     {categories.map(c => {
                                         const parent = categories.find(p => p.id === c.parentId)
                                         const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
@@ -327,7 +327,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                             className="flex items-center gap-2 px-3 py-1.5 text-[13px] font-semibold text-slate-500 hover:bg-slate-50 rounded-lg transition-all"
                         >
                             {showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
-                            {showPreview ? "Hide Preview" : "Show Preview"}
+                            {showPreview ? t('hidePreview') : t('showPreview')}
                         </button>
                         <button
                             onClick={handleSaveNote}
@@ -342,13 +342,13 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                     <div className={`flex-1 flex flex-col p-8 gap-6 ${showPreview ? 'border-r border-slate-100' : ''}`}>
                         <input
                             type="text"
-                            placeholder="Note title..."
+                            placeholder={t('noteTitlePlaceholder')}
                             value={currentNote.title || ''}
                             onChange={(e) => setCurrentNote({ ...currentNote, title: e.target.value })}
                             className="text-2xl font-bold text-slate-900 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full"
                         />
                         <textarea
-                            placeholder="Start writing..."
+                            placeholder={t('startWritingPlaceholder')}
                             value={currentNote.content || ''}
                             onChange={(e) => setCurrentNote({ ...currentNote, content: e.target.value })}
                             className="flex-1 text-[15px] text-slate-700 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full resize-none leading-relaxed"
@@ -358,12 +358,12 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                     {showPreview && (
                         <div className="flex-1 p-8 overflow-y-auto bg-slate-50/20">
                             <div className="prose prose-slate prose-sm max-w-none">
-                                <h1 className="text-2xl font-bold text-slate-900 mb-6">{currentNote.title || "Preview"}</h1>
+                                <h1 className="text-2xl font-bold text-slate-900 mb-6">{currentNote.title || t('previewHeader')}</h1>
                                 <ReactMarkdown
                                     remarkPlugins={[remarkGfm, remarkMath]}
                                     rehypePlugins={[rehypeKatex]}
                                 >
-                                    {currentNote.content || '*No content to preview*'}
+                                    {currentNote.content || t('noContentToPreview')}
                                 </ReactMarkdown>
                             </div>
                         </div>
@@ -379,7 +379,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
             <aside className="w-64 border-r border-slate-100 flex flex-col bg-slate-50/30 shrink-0">
                 <div className="p-6 pb-2">
                     <div className="flex items-center justify-between mb-4">
-                        <h2 className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{t('personalNotebook') || 'Directories'}</h2>
+                        <h2 className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{t('personalNotebook') || t('directoryLabel')}</h2>
                         <button
                             onClick={() => setShowAddCategory(true)}
                             className="p-1 hover:bg-slate-100 rounded-md text-slate-400 transition-colors"
@@ -394,7 +394,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                             className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${!selectedCategoryId ? 'bg-blue-50 text-blue-700 font-bold shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}
                         >
                             <Book size={16} className={!selectedCategoryId ? 'text-blue-600' : 'text-slate-400'} />
-                            All Notes
+                            {t('allNotes')}
                         </button>
                     </div>
                 </div>
@@ -406,7 +406,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                                 <input
                                     autoFocus
                                     className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
-                                    placeholder="Enter name..."
+                                    placeholder={t('enterNamePlaceholder')}
                                     value={newCategoryName}
                                     onChange={e => setNewCategoryName(e.target.value)}
                                     onBlur={() => !newCategoryName && setShowAddCategory(false)}
@@ -433,7 +433,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                                 <>
                                     <ChevronRight size={16} className="text-slate-300" />
                                     <span className="text-2xl font-bold text-blue-600 truncate max-w-[200px]">
-                                        {categories.find(c => c.id === selectedCategoryId)?.name || 'Directory'}
+                                        {categories.find(c => c.id === selectedCategoryId)?.name || t('directoryLabel')}
                                     </span>
                                 </>
                             )}
@@ -457,7 +457,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                         <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
                         <input
                             type="text"
-                            placeholder="Filter notes..."
+                            placeholder={t('filterNotesPlaceholder')}
                             value={filterText}
                             onChange={(e) => setFilterText(e.target.value)}
                             className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"
@@ -534,7 +534,7 @@ export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false
                                             </div>
                                             {note.categoryId && (
                                                 <span className="text-[10px] bg-slate-100 text-slate-500 px-2 py-0.5 rounded font-medium">
-                                                    {categories.find(c => c.id === note.categoryId)?.name || 'Directory'}
+                                                    {categories.find(c => c.id === note.categoryId)?.name || t('directoryLabel')}
                                                 </span>
                                             )}
                                         </div>

+ 9 - 6
web/components/views/NotebookDetailView.tsx

@@ -62,9 +62,12 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
     const loadData = useCallback(async () => {
         setIsLoading(true)
         try {
-            const allFiles = await knowledgeBaseService.getAll(authToken)
-            const notebookFiles = allFiles.filter(f => f.groups?.some(g => g.id === notebook.id))
-            setFiles(notebookFiles)
+            // Use backend filtering by groupId
+            const result = await knowledgeBaseService.getAll(authToken, {
+                groupId: notebook.id,
+                limit: 1000 // Get all files for this notebook view for now, or implement pagination here too
+            })
+            setFiles(result.items)
         } catch (error) {
             console.error(error)
             showError(t('errorLoadData'))
@@ -91,7 +94,7 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                 }
                 const extension = file.name.split('.').pop() || ''
                 if (!isExtensionAllowed(extension, 'group')) {
-                    if (!(await confirm(t('confirmUnsupportedFile', extension || 'unknown')))) continue
+                    if (!(await confirm(t('confirmUnsupportedFile', extension || t('unknown'))))) continue
                 }
                 const rawFile = await readFile(file)
                 newPendingFiles.push(rawFile)
@@ -176,7 +179,7 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                             </h1>
                         </div>
                         <p className="text-[15px] text-slate-500 mt-1 truncate max-w-2xl">
-                            {notebook.description || "Browse and manage files within this group."}
+                            {notebook.description || t('browseManageFiles')}
                         </p>
                     </div>
                 </div>
@@ -206,7 +209,7 @@ export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToke
                     <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
                     <input
                         type="text"
-                        placeholder="Filter group files..."
+                        placeholder={t('filterGroupFiles')}
                         value={filterName}
                         onChange={(e) => setFilterName(e.target.value)}
                         className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"

+ 73 - 15
web/components/views/NotebooksView.tsx

@@ -1,7 +1,7 @@
-import React from 'react'
+import React, { useMemo } from 'react'
 import { knowledgeGroupService } from '../../services/knowledgeGroupService'
 import { KnowledgeGroup, UpdateGroupData, CreateGroupData } from '../../types'
-import { Plus, Book, MoreVertical, Library, MessageSquare, Trash2, Edit2, FolderInput } from 'lucide-react'
+import { Plus, Book, Library, MessageSquare, Trash2, Edit2, FolderInput, ChevronLeft, ChevronRight } from 'lucide-react'
 import { motion, AnimatePresence } from 'framer-motion'
 import { NotebookDetailView } from './NotebookDetailView'
 import { CreateNotebookDrawer } from '../CreateNotebookDrawer'
@@ -17,6 +17,21 @@ interface NotebooksViewProps {
     isAdmin?: boolean
 }
 
+/** Flatten a tree of groups into a flat list */
+function flattenGroups(groups: KnowledgeGroup[]): KnowledgeGroup[] {
+    const result: KnowledgeGroup[] = [];
+    function walk(items: KnowledgeGroup[]) {
+        for (const g of items) {
+            result.push(g);
+            if (g.children?.length) walk(g.children);
+        }
+    }
+    walk(groups);
+    return result;
+}
+
+const PAGE_SIZE = 12;
+
 export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatWithContext, isAdmin = false }) => {
     const { t } = useLanguage()
     const { showError } = useToast()
@@ -27,11 +42,27 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
     const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false)
     const [isImportDrawerOpen, setIsImportDrawerOpen] = React.useState(false)
     const [editingNotebook, setEditingNotebook] = React.useState<KnowledgeGroup | null>(null)
+    const [currentPage, setCurrentPage] = React.useState(1)
+
+    // Flatten tree for display in the grid
+    const flatNotebooks = useMemo(() => flattenGroups(notebooks), [notebooks])
+    const totalPages = Math.ceil(flatNotebooks.length / PAGE_SIZE)
+    const paginatedNotebooks = useMemo(() => {
+        const start = (currentPage - 1) * PAGE_SIZE;
+        return flatNotebooks.slice(start, start + PAGE_SIZE);
+    }, [flatNotebooks, currentPage])
 
     const fetchNotebooks = async () => {
         try {
-            const groups = await knowledgeGroupService.getGroups()
-            setNotebooks(groups)
+            const result = await knowledgeGroupService.getGroups()
+            // result can be an array (tree/list) or an object (paginated flat list)
+            if (Array.isArray(result)) {
+                setNotebooks(result)
+            } else if (result && result.items) {
+                setNotebooks(result.items)
+            } else {
+                setNotebooks([])
+            }
         } catch (error) {
             console.error(error)
         } finally {
@@ -68,7 +99,8 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
         try {
             setIsLoading(true)
             await knowledgeGroupService.deleteGroup(id)
-            setNotebooks(prev => prev.filter(n => n.id !== id))
+            setNotebooks(prev => flattenGroups(prev).filter(n => n.id !== id) as any)
+            await fetchNotebooks()
         } catch (error) {
             console.error(error)
             showError(t('deleteFailed'))
@@ -103,7 +135,7 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
                             className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 text-sm font-semibold rounded-lg hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95 shadow-sm"
                         >
                             <FolderInput size={18} className="text-blue-600" />
-                            <span>Import Folder</span>
+                            <span>{t('importFolder')}</span>
                         </button>
                     )}
                     {isAdmin && (
@@ -112,22 +144,22 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
                             className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
                         >
                             <Plus size={18} />
-                            <span>New Group</span>
+                            <span>{t('newGroup')}</span>
                         </button>
                     )}
                 </div>
             </div>
 
-            <div className="px-8 pb-8 flex-1 overflow-y-auto">
+            <div className="px-8 flex-1 overflow-y-auto pb-4">
                 {isLoading ? (
                     <div className="flex h-64 items-center justify-center">
                         <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
                     </div>
-                ) : notebooks.length === 0 ? (
+                ) : flatNotebooks.length === 0 ? (
                     <div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-white/50 text-center">
                         <Library className="w-12 h-12 text-slate-200 mx-auto mb-4" />
-                        <h3 className="text-slate-900 font-bold">No Knowledge Groups</h3>
-                        <p className="text-slate-500 text-sm mt-1">Create a group to start organizing your files.</p>
+                        <h3 className="text-slate-900 font-bold">{t('noKnowledgeGroups')}</h3>
+                        <p className="text-slate-500 text-sm mt-1">{t('createGroupDesc')}</p>
                         {isAdmin && (
                             <button
                                 onClick={() => setIsCreateDrawerOpen(true)}
@@ -140,7 +172,7 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
                 ) : (
                     <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
                         <AnimatePresence>
-                            {notebooks.map((notebook, index) => (
+                            {paginatedNotebooks.map((notebook) => (
                                 <motion.div
                                     key={notebook.id}
                                     layout
@@ -184,18 +216,19 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
                                         <div className="w-11 h-11 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 flex items-center justify-center mb-4 transition-transform group-hover:scale-105">
                                             <Book size={20} />
                                         </div>
-                                        <h3 className="font-bold text-slate-900 text-[16px] mb-2 leading-tight group-hover:text-blue-600 transition-colors truncate">
+                                        <h3 className="font-bold text-slate-900 text-[16px] mb-1 leading-tight group-hover:text-blue-600 transition-colors truncate">
+                                            {notebook.parentId && <span className="text-slate-300 text-xs mr-1">↳</span>}
                                             {notebook.name}
                                         </h3>
                                         <p className="text-[13px] text-slate-500 leading-relaxed line-clamp-3 italic opacity-85">
-                                            {notebook.description || "No description provided."}
+                                            {notebook.description || t('noDescriptionProvided')}
                                         </p>
                                     </div>
 
                                     <div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
                                         <div className="flex items-center gap-2 px-2.5 py-1 bg-slate-50 border border-slate-100 rounded-md">
                                             <span className="text-[11px] font-bold text-slate-700">{notebook.fileCount || 0}</span>
-                                            <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-tight">Files</span>
+                                            <span className="text-[11px] font-semibold text-slate-400 uppercase tracking-tight">{t('files')}</span>
                                         </div>
                                         <span className="text-[11px] font-medium text-slate-300">
                                             {notebook.updatedAt ? new Date(notebook.updatedAt).toLocaleDateString() : ''}
@@ -208,6 +241,31 @@ export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatW
                 )}
             </div>
 
+            {/* Pagination: always show when there are notebooks */}
+            {flatNotebooks.length > 0 && (
+                <div className="px-8 py-4 border-t border-slate-200/60 bg-white/50 backdrop-blur-md flex items-center justify-center gap-2 shrink-0">
+                    <button
+                        onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
+                        disabled={currentPage === 1}
+                        className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
+                    >
+                        <ChevronLeft size={16} />
+                        {t('previous')}
+                    </button>
+                    <div className="px-3 py-2 text-sm font-semibold text-slate-700">
+                        {t('showingRange', (currentPage - 1) * PAGE_SIZE + 1, Math.min(currentPage * PAGE_SIZE, flatNotebooks.length), flatNotebooks.length)}
+                    </div>
+                    <button
+                        onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
+                        disabled={currentPage === totalPages || totalPages === 0}
+                        className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
+                    >
+                        {t('next')}
+                        <ChevronRight size={16} />
+                    </button>
+                </div>
+            )}
+
             {isCreateDrawerOpen && (
                 <CreateNotebookDrawer
                     isOpen={isCreateDrawerOpen}

+ 176 - 0
web/components/views/PluginsView.tsx

@@ -0,0 +1,176 @@
+import React from 'react';
+import { useLanguage } from '../../contexts/LanguageContext';
+import { Search, Plus, MoreHorizontal, Puzzle } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+// Mock data for plugins
+interface PluginMock {
+    id: string;
+    name: string;
+    description: string;
+    status: 'installed' | 'not-installed' | 'update-available';
+    developer: string;
+    iconEmoji: string;
+    iconBgClass: string;
+}
+
+const mockPlugins: PluginMock[] = [
+    {
+        id: '1',
+        name: 'plugin1Name',
+        description: 'plugin1Desc',
+        status: 'installed',
+        developer: 'Official',
+        iconEmoji: '🛠️',
+        iconBgClass: 'bg-blue-50'
+    },
+    {
+        id: '2',
+        name: 'plugin2Name',
+        description: 'plugin2Desc',
+        status: 'update-available',
+        developer: 'Official',
+        iconEmoji: '📄',
+        iconBgClass: 'bg-indigo-50'
+    },
+    {
+        id: '3',
+        name: 'plugin3Name',
+        description: 'plugin3Desc',
+        status: 'installed',
+        developer: 'Community',
+        iconEmoji: '🦊',
+        iconBgClass: 'bg-orange-50'
+    },
+    {
+        id: '4',
+        name: 'plugin4Name',
+        description: 'plugin4Desc',
+        status: 'not-installed',
+        developer: 'Official',
+        iconEmoji: '🌐',
+        iconBgClass: 'bg-emerald-50'
+    },
+    {
+        id: '5',
+        name: 'plugin5Name',
+        description: 'plugin5Desc',
+        status: 'not-installed',
+        developer: 'Community',
+        iconEmoji: '🗄️',
+        iconBgClass: 'bg-slate-100'
+    },
+    {
+        id: '6',
+        name: 'plugin6Name',
+        description: 'plugin6Desc',
+        status: 'installed',
+        developer: 'Official',
+        iconEmoji: '💬',
+        iconBgClass: 'bg-sky-50'
+    }
+];
+
+export const PluginsView: React.FC = () => {
+    const { t } = useLanguage();
+
+    return (
+        <div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
+            {/* Header Area */}
+            <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
+                <div>
+                    <h1 className="text-[22px] font-bold text-slate-900 leading-tight flex items-center gap-2">
+                        <Puzzle className="text-blue-600" size={24} />
+                        {t('pluginTitle')}
+                    </h1>
+                    <p className="text-[14px] text-slate-500 mt-1">{t('pluginDesc')}</p>
+                </div>
+                <div className="flex items-center gap-4">
+                    <div className="relative w-64">
+                        <Search className="absolute text-slate-400 left-3 top-1/2 -translate-y-1/2" size={16} />
+                        <input
+                            type="text"
+                            placeholder={t('searchPlugin')}
+                            className="w-full h-10 pl-10 pr-4 bg-white border border-slate-200 rounded-lg focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none transition-all text-sm font-medium"
+                        />
+                    </div>
+                </div>
+            </div>
+
+            {/* Content Area */}
+            <div className="px-8 pb-8 flex-1 overflow-y-auto">
+                <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-[1600px] mx-auto">
+                    <AnimatePresence>
+                        {mockPlugins.map((plugin) => (
+                            <motion.div
+                                key={plugin.id}
+                                layout
+                                initial={{ opacity: 0, y: 10 }}
+                                animate={{ opacity: 1, y: 0 }}
+                                className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]"
+                            >
+                                {/* Top layer */}
+                                <div className="flex items-center justify-between mb-4">
+                                    <div className={`w-12 h-12 flex items-center justify-center rounded-xl ${plugin.iconBgClass} text-2xl shadow-sm border border-black/5`}>
+                                        {plugin.iconEmoji}
+                                    </div>
+                                    <div className="flex items-center gap-3">
+                                        {/* Status Badge */}
+                                        {plugin.status === 'installed' && (
+                                            <div className="px-2.5 py-1 text-[12px] font-semibold text-emerald-600 bg-emerald-50 rounded-full border border-emerald-100 flex flex-row items-center justify-center">
+                                                {t('installedPlugin')}
+                                            </div>
+                                        )}
+                                        {plugin.status === 'update-available' && (
+                                            <div className="px-2.5 py-1 text-[12px] font-semibold text-orange-600 bg-orange-50 rounded-full border border-orange-100 flex flex-row items-center justify-center">
+                                                {t('updatePlugin')}
+                                            </div>
+                                        )}
+                                        {/* Options button */}
+                                        <button className="text-slate-400 hover:text-slate-600 transition-colors">
+                                            <MoreHorizontal size={20} />
+                                        </button>
+                                    </div>
+                                </div>
+
+                                {/* Middle layer */}
+                                <div className="flex-1">
+                                    <h3 className="font-bold text-slate-800 text-[17px] mb-2 leading-tight flex items-center gap-2">
+                                        {t(plugin.name as any)}
+                                        {plugin.developer === 'Official' && (
+                                            <span className="bg-blue-100 text-blue-700 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm">{t('pluginOfficial')}</span>
+                                        )}
+                                    </h3>
+                                    <p className="text-[13px] text-slate-500 leading-relaxed line-clamp-2">
+                                        {t(plugin.description as any)}
+                                    </p>
+                                </div>
+
+                                {/* Bottom layer */}
+                                <div className="mt-4 pt-4 border-t border-slate-50 flex items-center justify-between">
+                                    <span className="text-[12px] font-medium text-slate-400">
+                                        {t('pluginBy')}{plugin.developer === 'Official' ? t('pluginOfficial') : t('pluginCommunity')}
+                                    </span>
+                                    {plugin.status === 'not-installed' ? (
+                                        <button className="flex items-center justify-center gap-1.5 px-4 py-1.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shadow-sm">
+                                            <Plus size={14} className="text-white" />
+                                            <span className="text-[13px] font-bold">{t('installPlugin')}</span>
+                                        </button>
+                                    ) : plugin.status === 'update-available' ? (
+                                        <button className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-lg transition-colors border border-orange-200">
+                                            <span className="text-[13px] font-bold">{t('updatePlugin')}</span>
+                                        </button>
+                                    ) : (
+                                        <button className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-slate-600 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors border border-slate-200">
+                                            <span className="text-[13px] font-bold">{t('pluginConfig')}</span>
+                                        </button>
+                                    )}
+                                </div>
+                            </motion.div>
+                        ))}
+                    </AnimatePresence>
+                </div>
+            </div>
+        </div>
+    );
+};

+ 229 - 117
web/components/views/SettingsView.tsx

@@ -1,12 +1,13 @@
 import React, { useState, useEffect } from 'react';
 import { ModelConfig, ModelType, AppSettings, KnowledgeGroup } from '../../types';
 import { useLanguage } from '../../contexts/LanguageContext';
-import { X, Plus, Trash2, Edit2, Save, Cpu, Box, Loader2, User, Shield, Key, LogOut, Globe, Settings as SettingsIcon, ToggleLeft, ToggleRight, Database, Sparkles, ChevronRight, Lock, Building2, BookOpen } from 'lucide-react';
+import { X, Plus, Trash2, Edit2, Save, Cpu, Box, Loader2, User, Shield, Key, LogOut, Globe, Settings as SettingsIcon, ToggleLeft, ToggleRight, Database, Sparkles, ChevronRight, Lock, Building2, BookOpen, UserCircle, HardDrive, LayoutGrid } from 'lucide-react';
 import { motion, AnimatePresence } from 'framer-motion';
 import { userService } from '../../services/userService';
-// import { settingsService } from '../../services/settingsService';
+import { settingsService } from '../../services/settingsService';
 import { userSettingService } from '../../services/userSettingService';
 import { knowledgeGroupService } from '../../services/knowledgeGroupService';
+
 import { useConfirm } from '../../contexts/ConfirmContext';
 import { useToast } from '../../contexts/ToastContext';
 
@@ -20,7 +21,7 @@ interface SettingsViewProps {
     initialTab?: TabType;
 }
 
-type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base';
+type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks';
 
 export const SettingsView: React.FC<SettingsViewProps> = ({
     models,
@@ -62,6 +63,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     }
     const [users, setUsers] = useState<UserType[]>([]);
     const [isUserLoading, setIsUserLoading] = useState(false);
+    const [userPage, setUserPage] = useState(1);
+    const USER_PAGE_SIZE = 20;
     const [showAddUser, setShowAddUser] = useState(false);
     const [newUser, setNewUser] = useState({ username: '', password: '', role: 'USER' });
     const [userSuccess, setUserSuccess] = useState('');
@@ -93,20 +96,23 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     }, [initialTab]);
 
     // ユーザー一覧の取得(ユーザータブがアクティブな場合)
+    // Data fetching on tab change
     useEffect(() => {
         if (activeTab === 'user') {
             fetchUsers();
         } else if (activeTab === 'general') {
             fetchSettingsAndGroups();
-        } else if (activeTab === 'model' && (isAdmin || currentUser?.role === 'SUPER_ADMIN')) {
-            // Model tab initialization
         } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
             fetchTenantsData();
             fetchUsers(); // Ensure users are loaded for admin binding
-        } else if (activeTab === 'knowledge_base' && (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN')) {
+        }
+
+        // Independent check for KB/Model settings to avoid being blocked by the branches above
+        if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
+            (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
             fetchKnowledgeBaseSettings();
         }
-    }, [activeTab, currentUser]);
+    }, [activeTab, currentUser, authToken, isAdmin]);
 
     const [kbSettings, setKbSettings] = useState<any>(null);
     const [localKbSettings, setLocalKbSettings] = useState<any>(null);
@@ -122,6 +128,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 const data = await res.json();
                 setKbSettings(data);
                 setLocalKbSettings(data);
+                if (data.enabledModelIds) {
+                    setEnabledModelIds(data.enabledModelIds);
+                } else {
+                    setEnabledModelIds([]);
+                }
             }
         } catch (error) {
             console.error(error);
@@ -145,12 +156,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
             });
             if (res.ok) {
                 setKbSettings(localKbSettings);
-                showSuccess('Knowledge Base settings saved');
+                showSuccess(t('kbSettingsSaved'));
             } else {
-                showError('Failed to save settings');
+                showError(t('failedToSaveSettings'));
             }
         } catch (error) {
-            showError('Error saving settings');
+            showError(t('actionFailed'));
         } finally {
             setIsSavingKbSettings(false);
         }
@@ -304,7 +315,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 body: JSON.stringify({ userId, role: 'USER' }),
             });
             if (res.ok) {
-                showSuccess('User added to organization');
+                showSuccess(t('userAddedToOrganization')); // Need to add this key? No, used generic success
+                showSuccess(t('confirm')); // Using t('confirm') for now or generic
                 fetchTenantMembers(tenantId);
                 fetchTenantsData();
                 fetchUsers();
@@ -346,9 +358,21 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 fetch('/api/v1/admin/users', { headers })
             ]);
             if (tenRes.ok) {
-                const data = await tenRes.json();
-                setTenants(data);
-                setStats(s => ({ ...s, tenants: data.length }));
+                const data: any[] = await tenRes.json();
+                // Fetch settings for each tenant in parallel and merge as settings_obj
+                const withSettings = await Promise.all(
+                    data.map(async (t) => {
+                        try {
+                            const sRes = await fetch(`/api/tenants/${t.id}/settings`, { headers });
+                            const settings_obj = sRes.ok ? await sRes.json() : null;
+                            return { ...t, settings_obj };
+                        } catch {
+                            return { ...t, settings_obj: null };
+                        }
+                    })
+                );
+                setTenants(withSettings);
+                setStats(s => ({ ...s, tenants: withSettings.length }));
             }
             if (admRes.ok) {
                 const data = await admRes.json();
@@ -447,12 +471,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         try {
             const res = await fetch(`/api/tenants/${tenantId}/settings`, {
                 method: 'PUT',
-                headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` },
+                headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
                 body: JSON.stringify({ isNotebookEnabled: !currentEnabled })
             });
             if (res.ok) {
                 showSuccess('Feature updated successfully');
                 fetchTenantsData();
+            } else {
+                showError('Failed to update feature');
             }
         } catch (e) {
             showError('Failed to update feature');
@@ -463,7 +489,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
         if (!roleChangeUserData) return;
         try {
             await userService.updateUserInfo(roleChangeUserData.userId, { role: roleChangeUserData.newRole });
-            showSuccess('Role updated successfully.');
+            showSuccess(t('featureUpdated'));
             setRoleChangeUserData(null);
             fetchUsers();
         } catch (error: any) {
@@ -519,6 +545,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                     body: JSON.stringify({ enabledModelIds: newEnabledIds })
                 });
                 setEnabledModelIds(newEnabledIds);
+                // Keep localKbSettings in sync to prevent overwrite on save in KB tab
+                setLocalKbSettings(prev => prev ? { ...prev, enabledModelIds: newEnabledIds } : prev);
+                setKbSettings(prev => prev ? { ...prev, enabledModelIds: newEnabledIds } : prev);
                 showSuccess('Settings updated successfully');
             } catch (error) {
                 console.error(error);
@@ -638,13 +667,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                     <h3 className="text-sm font-black text-slate-500 uppercase tracking-[0.15em] mb-1">{t('userList')}</h3>
                     <p className="text-xs text-slate-400 font-medium">{t('sidebarDesc')}</p>
                 </div>
-                <button
-                    onClick={() => setShowAddUser(!showAddUser)}
-                    className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
-                >
-                    <Plus className="w-4 h-4" />
-                    {t('addUser')}
-                </button>
+                {currentUser?.role === 'SUPER_ADMIN' && (
+                    <button
+                        onClick={() => setShowAddUser(!showAddUser)}
+                        className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
+                    >
+                        <Plus className="w-4 h-4" />
+                        {t('addUser')}
+                    </button>
+                )}
             </div>
 
             {showAddUser && (
@@ -681,12 +712,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                     value={newUser.role}
                                     onChange={e => setNewUser({ ...newUser, role: e.target.value })}
                                 >
-                                    <option value="TENANT_ADMIN">Tenant Admin</option>
-                                    <option value="USER">Regular User</option>
+                                    <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
+                                    <option value="USER">{t('roleRegularUser')}</option>
                                 </select>
                             ) : (
                                 <span className="text-xs font-bold text-slate-600 uppercase tracking-widest">
-                                    Creating Regular User
+                                    {t('creatingRegularUser')}
                                 </span>
                             )}
                         </div>
@@ -745,7 +776,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                         className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
                     >
                         <div className="flex items-center justify-between mb-8">
-                            <h3 className="text-xl font-black text-slate-900 tracking-tight">Edit User Role</h3>
+                            <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUserRole')}</h3>
                             <button onClick={() => setRoleChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
                                 <X size={20} className="text-slate-400" />
                             </button>
@@ -754,15 +785,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                         <form onSubmit={(e) => { e.preventDefault(); handleUserRoleChange(); }} className="space-y-6">
                             <div>
                                 <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
-                                    Target Role
+                                    {t('targetRole')}
                                 </label>
                                 <select
                                     value={roleChangeUserData.newRole}
                                     onChange={(e) => setRoleChangeUserData({ ...roleChangeUserData, newRole: e.target.value })}
                                     className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
                                 >
-                                    <option value="TENANT_ADMIN">Tenant Admin</option>
-                                    <option value="USER">Regular User</option>
+                                    <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
+                                    <option value="USER">{t('roleRegularUser')}</option>
                                 </select>
                             </div>
 
@@ -777,7 +808,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
 
             <div className="grid grid-cols-1 gap-4 overflow-y-auto pr-2 scrollbar-hide">
                 <AnimatePresence>
-                    {users.map((user, index) => {
+                    {users.slice((userPage - 1) * USER_PAGE_SIZE, userPage * USER_PAGE_SIZE).map((user, index) => {
                         let IconComponent = User;
                         let iconColors = 'bg-slate-50 text-slate-400';
                         if (user.role === 'SUPER_ADMIN') {
@@ -834,7 +865,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                                         setRoleChangeUserData({ userId: user.id, newRole: user.role && user.role !== 'USER' ? user.role : (user.isAdmin ? 'TENANT_ADMIN' : 'USER') });
                                                     }}
                                                     className="p-2.5 rounded-xl text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all cursor-pointer relative z-10"
-                                                    title="Edit Role"
+                                                    title={t('editCategory')} // Using editCategory as generic edit for now or need editRole
                                                 >
                                                     <Edit2 className="w-4.5 h-4.5" />
                                                 </button>
@@ -863,6 +894,33 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                     })}
                 </AnimatePresence>
             </div>
+
+            {/* User list pagination - show whenever there are users */}
+            {users.length > 0 && (
+                <div className="flex items-center justify-center gap-3 pt-4 border-t border-slate-100 mt-4">
+                    <button
+                        onClick={() => setUserPage(p => Math.max(1, p - 1))}
+                        disabled={userPage === 1}
+                        className="px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-30 transition-all"
+                    >
+                        {t('previous')}
+                    </button>
+                    <span className="text-xs font-semibold text-slate-500">
+                        {t('showingRange',
+                            (userPage - 1) * USER_PAGE_SIZE + 1,
+                            Math.min(userPage * USER_PAGE_SIZE, users.length),
+                            users.length
+                        )}
+                    </span>
+                    <button
+                        onClick={() => setUserPage(p => Math.min(Math.ceil(users.length / USER_PAGE_SIZE), p + 1))}
+                        disabled={userPage === Math.ceil(users.length / USER_PAGE_SIZE)}
+                        className="px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-30 transition-all"
+                    >
+                        {t('next')}
+                    </button>
+                </div>
+            )}
         </div>
     );
 
@@ -872,47 +930,47 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 <div className="p-6 bg-white border border-slate-200 rounded-3xl shadow-sm">
                     <div className="flex items-center gap-3 mb-2 text-blue-600">
                         <Building2 size={20} />
-                        <span className="text-xs font-black uppercase tracking-widest">Total Tenants</span>
+                        <span className="text-xs font-black uppercase tracking-widest">{t('totalTenants')}</span>
                     </div>
                     <p className="text-3xl font-black text-slate-900">{stats.tenants}</p>
                 </div>
                 <div className="p-6 bg-white border border-slate-200 rounded-3xl shadow-sm">
                     <div className="flex items-center gap-3 mb-2 text-indigo-600">
                         <User size={20} />
-                        <span className="text-xs font-black uppercase tracking-widest">System Users</span>
+                        <span className="text-xs font-black uppercase tracking-widest">{t('systemUsers')}</span>
                     </div>
                     <p className="text-3xl font-black text-slate-900">{stats.users}</p>
                 </div>
                 <div className="p-6 bg-emerald-50 border border-emerald-100 rounded-3xl shadow-sm">
                     <div className="flex items-center gap-3 mb-2 text-emerald-600">
                         <Sparkles size={20} />
-                        <span className="text-xs font-black uppercase tracking-widest">System Health</span>
+                        <span className="text-xs font-black uppercase tracking-widest">{t('systemHealth')}</span>
                     </div>
-                    <p className="text-xl font-bold text-emerald-700">Operational</p>
+                    <p className="text-xl font-bold text-emerald-700">{t('operational')}</p>
                 </div>
             </div>
 
             <div className="bg-white border border-slate-200 rounded-3xl shadow-xl overflow-hidden">
                 <div className="p-6 border-b border-slate-100 flex items-center justify-between">
                     <div>
-                        <h3 className="font-black text-slate-900 text-lg tracking-tight">Organization Management</h3>
-                        <p className="text-xs font-medium text-slate-400">Global tenant list and control</p>
+                        <h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
+                        <p className="text-xs font-medium text-slate-400">{t('globalTenantControl')}</p>
                     </div>
                     <button onClick={() => setShowCreateTenant(true)} className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white text-xs font-bold rounded-xl hover:bg-slate-700 transition-all">
-                        <Plus size={16} /> New Tenant
+                        <Plus size={16} /> {t('newTenant')}
                     </button>
                 </div>
                 <div className="overflow-x-auto">
                     <table className="w-full text-left">
                         <thead className="bg-slate-50/50 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
                             <tr>
-                                <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>
+                                <th className="px-6 py-4">{t('name')}</th>
+                                <th className="px-6 py-4">{t('domainOptional')}</th>
+                                <th className="px-6 py-4">{t('admin')}</th>
+                                <th className="px-6 py-4">{t('userList')}</th>
+                                <th className="px-6 py-4">{t('tabSettings')}</th>
+                                <th className="px-6 py-4">{t('createdAt')}</th>
+                                <th className="px-6 py-4 text-right">{t('actions')}</th>
                             </tr>
                         </thead>
                         <tbody className="divide-y divide-slate-100">
@@ -971,24 +1029,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             }
                                         })()}
                                     </td>
-                                    <td className="px-6 py-4">
-                                        <div className="flex items-center gap-2">
-                                            <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>
-                                            {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-xs">
                                         {/* Members count + manage button */}
                                         <div className="flex items-center gap-2">
@@ -1011,6 +1051,25 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             )}
                                         </div>
                                     </td>
+                                    <td className="px-6 py-4">
+                                        <div className="flex items-center gap-2">
+                                            <span title="Notebook Feature"><BookOpen size={14} className={t.settings_obj?.isNotebookEnabled !== false ? 'text-indigo-500' : 'text-slate-300'} /></span>
+                                            {t.name !== 'Default' ? (
+                                                <button
+                                                    onClick={() => handleToggleNotebookFeature(t.id, t.settings_obj?.isNotebookEnabled !== false)}
+                                                    title={t.settings_obj?.isNotebookEnabled !== false ? 'Disable Notebook' : 'Enable Notebook'}
+                                                    className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${t.settings_obj?.isNotebookEnabled !== false ? 'bg-indigo-500' : 'bg-slate-200'}`}
+                                                >
+                                                    <span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${t.settings_obj?.isNotebookEnabled !== false ? 'translate-x-4' : 'translate-x-0'}`} />
+                                                </button>
+                                            ) : (
+                                                <div className="relative inline-flex h-5 w-9 rounded-full bg-slate-100 border-2 border-transparent cursor-not-allowed" title="Fixed">
+                                                    <span className="pointer-events-none inline-block h-4 w-4 translate-x-0 transform rounded-full bg-white shadow ring-0" />
+                                                </div>
+                                            )}
+                                        </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">
                                         {t.name !== 'Default' && (
                                             <div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
@@ -1308,7 +1367,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
                             className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
                         >
-                            Cancel
+                            {t('cancel')}
                         </button>
                         <button
                             onClick={handleSaveKbSettings}
@@ -1316,7 +1375,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             className="flex items-center gap-2 px-8 py-2 bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95 disabled:opacity-50"
                         >
                             {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
-                            Save Changes
+                            {t('saveChanges')}
                         </button>
                     </div>
 
@@ -1326,17 +1385,17 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
                                 <Cpu size={16} />
                             </div>
-                            Model Configuration
+                            {t('modelConfiguration')}
                         </div>
                         <div className="grid grid-cols-1 gap-6">
                             <div>
-                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Default LLM Model</label>
+                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
                                 <select
                                     value={localKbSettings.selectedLLMId || ''}
                                     onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
                                     className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer appearance-none"
                                 >
-                                    <option value="">Select LLM...</option>
+                                    <option value="">{t('selectLLM')}</option>
                                     {models.filter(m => m.type === ModelType.LLM).map(m => (
                                         <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
                                     ))}
@@ -1344,26 +1403,26 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             </div>
                             <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
                                 <div>
-                                    <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Embedding Model</label>
+                                    <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
                                     <select
                                         value={localKbSettings.selectedEmbeddingId || ''}
                                         onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
                                         className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
                                     >
-                                        <option value="">Select Embedding...</option>
+                                        <option value="">{t('selectEmbedding')}</option>
                                         {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
                                             <option key={m.id} value={m.id}>{m.name}</option>
                                         ))}
                                     </select>
                                 </div>
                                 <div>
-                                    <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Rerank Model</label>
+                                    <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
                                     <select
                                         value={localKbSettings.selectedRerankId || ''}
                                         onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
                                         className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
                                     >
-                                        <option value="">None</option>
+                                        <option value="">{t('none')}</option>
                                         {models.filter(m => m.type === ModelType.RERANK).map(m => (
                                             <option key={m.id} value={m.id}>{m.name}</option>
                                         ))}
@@ -1379,12 +1438,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             <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
+                            {t('indexingChunkingConfig')}
                         </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>
+                                    <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</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
@@ -1399,7 +1458,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             </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>
+                                    <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</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
@@ -1413,20 +1472,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 />
                             </div>
                         </div>
-                    </section>
+                    </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">
+                    < 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-pink-50 flex items-center justify-center text-pink-600">
                                 <Sparkles size={16} />
                             </div>
-                            Chat Hyperparameters
+                            {t('chatHyperparameters')}
                         </div>
                         <div className="space-y-8">
                             <div>
                                 <div className="flex justify-between mb-3 px-1">
-                                    <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Temperature</label>
+                                    <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
                                     <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
                                 </div>
                                 <input
@@ -1439,12 +1498,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                     className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                 />
                                 <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
-                                    <span>Precise</span>
-                                    <span>Creative</span>
+                                    <span>{t('precise')}</span>
+                                    <span>{t('creative')}</span>
                                 </div>
                             </div>
                             <div>
-                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Max Response Tokens</label>
+                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
                                 <input
                                     type="number"
                                     value={localKbSettings.maxTokens || 2000}
@@ -1453,21 +1512,21 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 />
                             </div>
                         </div>
-                    </section>
+                    </section >
 
                     {/* Retrieval & Search Settings */}
-                    <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
+                    < 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-emerald-50 flex items-center justify-center text-emerald-600">
                                 <Database size={16} />
                             </div>
-                            Retrieval & Search Settings
+                            {t('retrievalSearchSettings')}
                         </div>
                         <div className="space-y-8">
                             <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">Top-K Results</label>
+                                        <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
                                         <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
                                     </div>
                                     <input
@@ -1482,7 +1541,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 </div>
                                 <div>
                                     <div className="flex justify-between mb-3 px-1">
-                                        <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Similarity Threshold</label>
+                                        <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
                                         <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
                                     </div>
                                     <input
@@ -1500,8 +1559,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             <div className="space-y-4 pt-4 border-t border-slate-100">
                                 <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
                                     <div>
-                                        <div className="text-sm font-bold text-slate-800">Enable Hybrid Search</div>
-                                        <div className="text-[10px] text-slate-400 font-medium">Combine vector and full-text search results.</div>
+                                        <div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
+                                        <div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
                                     </div>
                                     <button
                                         onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
@@ -1518,7 +1577,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                         className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
                                     >
                                         <div className="flex justify-between mb-2 px-1">
-                                            <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Hybrid Weight (Vector vs Text)</label>
+                                            <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
                                             <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
                                         </div>
                                         <input
@@ -1531,8 +1590,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                         />
                                         <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
-                                            <span>Pure Text</span>
-                                            <span>Pure Vector</span>
+                                            <span>{t('pureText')}</span>
+                                            <span>{t('pureVector')}</span>
                                         </div>
                                     </motion.div>
                                 )}
@@ -1540,8 +1599,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                                     <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
                                         <div>
-                                            <div className="text-sm font-bold text-slate-800">Enable Query Expansion</div>
-                                            <div className="text-[10px] text-slate-400 font-medium">Rewrites query for better recall.</div>
+                                            <div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
+                                            <div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
                                         </div>
                                         <button
                                             onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
@@ -1553,8 +1612,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
 
                                     <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
                                         <div>
-                                            <div className="text-sm font-bold text-slate-800">Enable HyDE</div>
-                                            <div className="text-[10px] text-slate-400 font-medium">Hypothetical Document Embeddings.</div>
+                                            <div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
+                                            <div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
                                         </div>
                                         <button
                                             onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
@@ -1567,8 +1626,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
 
                                 <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
                                     <div>
-                                        <div className="text-sm font-bold text-slate-800">Enable Reranking</div>
-                                        <div className="text-[10px] text-slate-400 font-medium">Re-score search results for higher accuracy.</div>
+                                        <div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
+                                        <div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
                                     </div>
                                     <button
                                         onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
@@ -1585,7 +1644,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                         className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
                                     >
                                         <div className="flex justify-between mb-2 px-1">
-                                            <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Rerank Similarity Threshold</label>
+                                            <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
                                             <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
                                         </div>
                                         <input
@@ -1598,8 +1657,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
                                         />
                                         <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
-                                            <span>Broad</span>
-                                            <span>Strict</span>
+                                            <span>{t('broad')}</span>
+                                            <span>{t('strict')}</span>
                                         </div>
                                     </motion.div>
                                 )}
@@ -1683,11 +1742,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                     {modelFormData.type === ModelType.EMBEDDING && (
                         <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
                             <div className="space-y-2">
-                                <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Max Input</label>
+                                <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
                                 <input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
                             </div>
                             <div className="space-y-2">
-                                <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Dimensions</label>
+                                <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
                                 <input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
                             </div>
                         </div>
@@ -1723,10 +1782,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                         <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
                                             <button
                                                 onClick={() => handleToggleModel(model)}
-                                                className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
-                                                title={((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
+                                                className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
+                                                title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
                                             >
-                                                {((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
+                                                {((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
                                             </button>
                                         </div>
                                     </div>
@@ -1741,7 +1800,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                             </span>
                                             {model.isDefault && (
                                                 <span className="text-[9px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-amber-100/50 flex items-center gap-1">
-                                                    <Sparkles size={8} /> Default
+                                                    <Sparkles size={8} /> {t('defaultBadge')}
                                                 </span>
                                             )}
                                         </div>
@@ -1755,18 +1814,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                         {model.type === ModelType.EMBEDDING && (
                                             <>
                                                 <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
-                                                    <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Dims</span>
+                                                    <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
                                                     <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
                                                 </div>
                                                 <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
-                                                    <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Ctx</span>
+                                                    <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
                                                     <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
                                                 </div>
                                             </>
                                         )}
                                         {model.type === ModelType.LLM && (
                                             <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
-                                                <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">Base API</span>
+                                                <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
                                                 <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
                                             </div>
                                         )}
@@ -1776,7 +1835,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
                                     <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
                                         <SettingsIcon size={12} />
-                                        Configured
+                                        {t('configured')}
                                     </div>
                                     <div className="flex gap-2">
                                         {currentUser?.role === 'SUPER_ADMIN' && (
@@ -1813,18 +1872,71 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
     );
 
     return (
-        <div className="flex h-full bg-transparent overflow-hidden relative">
-
+        <div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
+            {/* Settings Sidebar */}
+            <div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
+                <div className="p-6 pb-2">
+                    <h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
+                </div>
+                <div className="flex-1 overflow-y-auto p-3 space-y-1">
+                    <button
+                        onClick={() => setActiveTab('general')}
+                        className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'general' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                            }`}
+                    >
+                        <SettingsIcon size={18} />
+                        {t('generalSettings')}
+                    </button>
+                    {(currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin) && (
+                        <>
+                            <button
+                                onClick={() => setActiveTab('user')}
+                                className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'user' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                                    }`}
+                            >
+                                <UserCircle size={18} />
+                                {t('userManagement')}
+                            </button>
+                            <button
+                                onClick={() => setActiveTab('model')}
+                                className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'model' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                                    }`}
+                            >
+                                <HardDrive size={18} />
+                                {t('modelManagement')}
+                            </button>
+                            <button
+                                onClick={() => setActiveTab('knowledge_base')}
+                                className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'knowledge_base' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                                    }`}
+                            >
+                                <Database size={18} />
+                                {t('sidebarTitle')}
+                            </button>
+                        </>
+                    )}
+                    {currentUser?.role === 'SUPER_ADMIN' && (
+                        <button
+                            onClick={() => setActiveTab('tenants')}
+                            className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'tenants' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                                }`}
+                        >
+                            <LayoutGrid size={18} />
+                            {t('navTenants')}
+                        </button>
+                    )}
+                </div>
+            </div>
 
             {/* Content Area */}
-            <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative z-10">
+            <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
                 <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
                     <div>
                         <h1 className="text-2xl font-bold text-slate-900 leading-tight">
-                            {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? 'Index Chat Config' : t('navTenants')}
+                            {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
                         </h1>
                         <p className="text-[15px] text-slate-500 mt-1">
-                            {activeTab === 'general' ? 'Manage your application preferences.' : activeTab === 'user' ? 'Manage access and accounts.' : activeTab === 'model' ? 'Configure global AI models.' : activeTab === 'knowledge_base' ? 'Technical configuration for indexing and chat parameters.' : 'Global system overview.'}
+                            {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
                         </p>
                     </div>
                 </div>

+ 4 - 1
web/index.tsx

@@ -11,6 +11,8 @@ import WorkspaceLayout from './src/components/layouts/WorkspaceLayout';
 
 // Lazy-loaded page components
 const ChatPage = lazy(() => import('./src/pages/workspace/ChatPage'));
+const AgentsPage = lazy(() => import('./src/pages/workspace/AgentsPage'));
+const PluginsPage = lazy(() => import('./src/pages/workspace/PluginsPage'));
 const KnowledgePage = lazy(() => import('./src/pages/workspace/KnowledgePage'));
 const NotebooksPage = lazy(() => import('./src/pages/workspace/NotebooksPage'));
 const MemosPage = lazy(() => import('./src/pages/workspace/MemosPage'));
@@ -81,8 +83,9 @@ function App() {
                   >
                     <Route index element={<OverviewPage />} />
                     <Route path="chat" element={<ChatPage />} />
+                    <Route path="agents" element={<AgentsPage />} />
+                    <Route path="plugins" element={<PluginsPage />} />
                     <Route path="notebook" element={<MemosPage />} />
-                    <Route path="knowledge-groups/*" element={<NotebooksPage />} />
                     <Route path="knowledge/*" element={<KnowledgePage />} />
                     <Route path="settings" element={<SettingsPage />} />
                     <Route path="users" element={<SettingsPage initialTab="user" />} />

+ 3 - 5
web/package.json

@@ -10,7 +10,7 @@
   },
   "dependencies": {
     "@google/genai": "^1.32.0",
-    "@tailwindcss/vite": "^4.2.1",
+    "@tailwindcss/vite": "4.0.9",
     "@types/react-syntax-highlighter": "^15.5.13",
     "clsx": "^2.1.1",
     "framer-motion": "^12.34.3",
@@ -33,10 +33,8 @@
     "@tailwindcss/typography": "^0.5.19",
     "@types/node": "^22.14.0",
     "@vitejs/plugin-react": "^5.0.0",
-    "autoprefixer": "^10.4.27",
-    "postcss": "^8.5.6",
-    "tailwindcss": "^4.0.0",
+    "tailwindcss": "4.0.9",
     "typescript": "~5.8.2",
     "vite": "^6.2.0"
   }
-}
+}

+ 19 - 13
web/services/importService.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL } from '../utils/constants';
+import { apiClient } from './apiClient';
 
 export interface ImportTask {
     id: string;
@@ -16,7 +16,7 @@ export interface ImportTask {
 }
 
 export const importService = {
-    create: async (token: string, data: {
+    create: async (authToken: string, data: {
         sourcePath: string;
         targetGroupId?: string;
         targetGroupName?: string;
@@ -26,25 +26,31 @@ export const importService = {
         chunkOverlap?: number;
         mode?: string;
     }): Promise<ImportTask> => {
-        const response = await fetch(`${API_BASE_URL}/import-tasks`, {
+        const response = await apiClient.request('/import-tasks', {
             method: 'POST',
-            headers: {
-                'Authorization': `Bearer ${token}`,
-                'Content-Type': 'application/json'
-            },
             body: JSON.stringify(data)
         });
         if (!response.ok) throw new Error('Failed to create import task');
         return response.json();
     },
 
-    getAll: async (token: string): Promise<ImportTask[]> => {
-        const response = await fetch(`${API_BASE_URL}/import-tasks`, {
-            headers: {
-                'Authorization': `Bearer ${token}`
-            }
-        });
+    getAll: async (authToken: string, options: { page?: number; limit?: number } = {}): Promise<{ items: ImportTask[]; total: number; page: number; limit: number }> => {
+        const queryParams = new URLSearchParams();
+        if (options.page) queryParams.append('page', options.page.toString());
+        if (options.limit) queryParams.append('limit', options.limit.toString());
+
+        const queryString = queryParams.toString();
+        const url = `/import-tasks${queryString ? `?${queryString}` : ''}`;
+
+        const response = await apiClient.request(url, {});
         if (!response.ok) throw new Error('Failed to fetch import tasks');
         return response.json();
+    },
+
+    delete: async (authToken: string, id: string): Promise<void> => {
+        const response = await apiClient.request(`/import-tasks/${id}`, {
+            method: 'DELETE',
+        });
+        if (!response.ok) throw new Error('Failed to delete import task');
     }
 };

+ 69 - 22
web/services/knowledgeBaseService.ts

@@ -2,33 +2,84 @@ import { apiClient } from './apiClient';
 import { KnowledgeFile } from '../types';
 
 export const knowledgeBaseService = {
-  async getAll(authToken: string): Promise<KnowledgeFile[]> {
-    const response = await apiClient.request('/knowledge-bases', {});
+  async getAll(
+    authToken: string,
+    options: {
+      page?: number;
+      limit?: number;
+      name?: string;
+      status?: string;
+      groupId?: string;
+    } = {}
+  ): Promise<{ items: KnowledgeFile[]; total: number; page: number; limit: number }> {
+    const queryParams = new URLSearchParams();
+    if (options.page) queryParams.append('page', options.page.toString());
+    if (options.limit) queryParams.append('limit', options.limit.toString());
+    if (options.name) queryParams.append('name', options.name);
+    if (options.status) queryParams.append('status', options.status);
+    if (options.groupId) queryParams.append('groupId', options.groupId);
+
+    const queryString = queryParams.toString();
+    const url = `/knowledge-bases${queryString ? `?${queryString}` : ''}`;
+
+    const response = await apiClient.request(url, {});
 
     if (!response.ok) {
       throw new Error('Failed to fetch knowledge base files');
     }
 
     const data = await response.json();
+    console.log('Knowledge base API response:', data);
 
-    // バックエンドエンティティをフロントエンドの KnowledgeFile にマッピング
-    return data.map((item: any) => {
-      return {
+    const items = Array.isArray(data) ? data : (data.items || []);
+    const total = Array.isArray(data) ? data.length : (data.total || 0);
+    const page = Array.isArray(data) ? 1 : (data.page || 1);
+    const limit = Array.isArray(data) ? items.length : (data.limit || 12);
+
+    return {
+      items: items.map((item: any) => ({
         id: item.id,
         name: item.originalName,
+        originalName: item.originalName,
         type: item.mimetype,
         size: item.size,
-        uploadDate: new Date(item.createdAt).getTime(),
-        status: item.status, // Pass raw status from backend
-        content: '', // Frontend doesn't need content
-        groups: item.groups || [], // Add group info
-        indexingConfig: {
-          chunkSize: item.chunkSize || 1000,
-          chunkOverlap: item.chunkOverlap || 200,
-          embeddingModelId: item.embeddingModelId || ''
-        }
-      };
+        status: item.status,
+        groups: item.groups || [],
+        createdAt: item.createdAt,
+        updatedAt: item.updatedAt,
+      })),
+      total,
+      page,
+      limit,
+    };
+  },
+
+  async getStatuses(ids: string[], authToken: string): Promise<{ id: string, status: KnowledgeFile['status'], updatedAt: string }[]> {
+    const response = await apiClient.request('/knowledge-bases/statuses', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({ ids }),
+    });
+
+    if (!response.ok) {
+      throw new Error('Failed to fetch knowledge base statuses');
+    }
+
+    return response.json();
+  },
+
+  async getStats(authToken: string): Promise<{ total: number, uncategorized: number }> {
+    const response = await apiClient.request('/knowledge-bases/stats', {
+      method: 'GET'
     });
+
+    if (!response.ok) {
+      throw new Error('Failed to fetch knowledge base stats');
+    }
+
+    return response.json();
   },
 
   async clearAll(authToken: string): Promise<void> {
@@ -80,17 +131,13 @@ export const knowledgeBaseService = {
     return {
       id: item.id,
       name: item.originalName,
+      originalName: item.originalName,
       type: item.mimetype,
       size: item.size,
-      uploadDate: new Date(item.createdAt).getTime(),
       status: item.status,
-      content: '',
       groups: item.groups || [],
-      indexingConfig: {
-        chunkSize: item.chunkSize || 1000,
-        chunkOverlap: item.chunkOverlap || 200,
-        embeddingModelId: item.embeddingModelId || ''
-      }
+      createdAt: item.createdAt,
+      updatedAt: item.updatedAt,
     };
   },
 

+ 20 - 3
web/services/knowledgeGroupService.ts

@@ -3,10 +3,27 @@ import { apiClient } from './apiClient';
 
 export const knowledgeGroupService = {
   // すべてのグループを取得
-  async getGroups(): Promise<KnowledgeGroup[]> {
-    const response = await apiClient.request('/knowledge-groups', {});
+  async getGroups(options: { flat?: boolean; page?: number; limit?: number; name?: string } = {}): Promise<any> {
+    const queryParams = new URLSearchParams();
+    if (options.flat) queryParams.append('flat', 'true');
+    if (options.page) queryParams.append('page', options.page.toString());
+    if (options.limit) queryParams.append('limit', options.limit.toString());
+    if (options.name) queryParams.append('name', options.name);
+
+    const queryString = queryParams.toString();
+    const url = `/knowledge-groups${queryString ? `?${queryString}` : ''}`;
+
+    const response = await apiClient.request(url, {});
     if (!response.ok) throw new Error('Failed to fetch groups');
-    return response.json();
+    const data = await response.json();
+
+    // If it's an array, it's already in the format we expect (tree or simple list)
+    if (Array.isArray(data)) return data;
+
+    // If it's a paginated object, return it as is or handle appropriately
+    // The callers of getGroups usually expect an array, but NotebooksView expects a tree.
+    // However, NotebookDetailView and others might soon expect pagination.
+    return data;
   },
 
   // グループを作成

+ 8 - 33
web/services/searchHistoryService.ts

@@ -1,6 +1,5 @@
 import { SearchHistoryItem, SearchHistoryDetail } from '../types';
-
-const API_BASE = '/api';
+import { apiClient } from './apiClient';
 
 export const searchHistoryService = {
   // 検索履歴リストの取得
@@ -10,48 +9,24 @@ export const searchHistoryService = {
     page: number;
     limit: number;
   }> {
-    const response = await fetch(`${API_BASE}/search-history?page=${page}&limit=${limit}`, {
-      headers: {
-        'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-      },
-    });
-    if (!response.ok) throw new Error('Failed to fetch search histories');
-    return response.json();
+    const { data } = await apiClient.get(`/search-history?page=${page}&limit=${limit}`);
+    return data;
   },
 
   // 検索履歴詳細の取得
   async getHistoryDetail(id: string): Promise<SearchHistoryDetail> {
-    const response = await fetch(`${API_BASE}/search-history/${id}`, {
-      headers: {
-        'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-      },
-    });
-    if (!response.ok) throw new Error('Failed to fetch search history detail');
-    return response.json();
+    const { data } = await apiClient.get(`/search-history/${id}`);
+    return data;
   },
 
   // 検索履歴の作成
   async createHistory(title: string, selectedGroups?: string[]): Promise<{ id: string }> {
-    const response = await fetch(`${API_BASE}/search-history`, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-        'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-      },
-      body: JSON.stringify({ title, selectedGroups }),
-    });
-    if (!response.ok) throw new Error('Failed to create search history');
-    return response.json();
+    const { data } = await apiClient.post(`/search-history`, { title, selectedGroups });
+    return data;
   },
 
   // 検索履歴の削除
   async deleteHistory(id: string): Promise<void> {
-    const response = await fetch(`${API_BASE}/search-history/${id}`, {
-      method: 'DELETE',
-      headers: {
-        'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
-      },
-    });
-    if (!response.ok) throw new Error('Failed to delete search history');
+    await apiClient.delete(`/search-history/${id}`);
   },
 };

+ 5 - 0
web/services/uploadService.ts

@@ -32,6 +32,11 @@ export const uploadService = {
       formData.append('mode', config.mode);
     }
 
+    // 分類を追加(指定されている場合)
+    if (config.groupIds && config.groupIds.length > 0) {
+      formData.append('groupIds', JSON.stringify(config.groupIds));
+    }
+
     const response = await apiClient.request('/upload', {
       method: 'POST',
       body: formData,

+ 28 - 52
web/src/components/layouts/WorkspaceLayout.tsx

@@ -15,7 +15,9 @@ import {
     UserCircle,
     HardDrive,
     Building2,
-    ChevronRight
+    ChevronRight,
+    Bot,
+    Blocks
 } from 'lucide-react';
 import { motion, AnimatePresence } from 'framer-motion';
 import { useAuth } from '../../contexts/AuthContext';
@@ -97,71 +99,45 @@ const WorkspaceLayout: React.FC = () => {
                             onClick={handleNavClick}
                         />
                         <SidebarItem
-                            icon={Database}
-                            label={t('navKnowledge')}
-                            path="/knowledge"
-                            isActive={location.pathname === '/knowledge' || location.pathname.startsWith('/knowledge/')}
+                            icon={Bot}
+                            label={t('navAgent')}
+                            path="/agents"
+                            isActive={location.pathname.startsWith('/agents')}
                             onClick={handleNavClick}
                         />
                         <SidebarItem
-                            icon={Library}
-                            label={t('navKnowledgeGroups')}
-                            path="/knowledge-groups"
-                            isActive={location.pathname.startsWith('/knowledge-groups')}
+                            icon={Blocks}
+                            label={t('navPlugin')}
+                            path="/plugins"
+                            isActive={location.pathname.startsWith('/plugins')}
                             onClick={handleNavClick}
                         />
                         <SidebarItem
-                            icon={BookOpen}
-                            label={t('navNotebook')}
-                            path="/notebook"
-                            isActive={location.pathname.startsWith('/notebook')}
+                            icon={Database}
+                            label={t('navKnowledge')}
+                            path="/knowledge"
+                            isActive={location.pathname === '/knowledge' || location.pathname.startsWith('/knowledge/')}
                             onClick={handleNavClick}
                         />
+                        {(activeTenant?.features?.isNotebookEnabled ?? true) && (
+                            <SidebarItem
+                                icon={BookOpen}
+                                label={t('navNotebook')}
+                                path="/notebook"
+                                isActive={location.pathname.startsWith('/notebook')}
+                                onClick={handleNavClick}
+                            />
+                        )}
                     </div>
 
                     <div className="space-y-0.5">
-                        <div className="px-3 mb-2 text-[11px] font-bold tracking-widest text-slate-400 uppercase">{t('tabSettings')}</div>
                         <SidebarItem
                             icon={Settings}
-                            label={t('generalSettings')}
+                            label={t('tabSettings')}
                             path="/settings"
-                            isActive={location.pathname === '/settings'}
+                            isActive={location.pathname.startsWith('/settings')}
                             onClick={handleNavClick}
                         />
-                        {(activeTenant?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN') && (
-                            <>
-                                <SidebarItem
-                                    icon={UserCircle}
-                                    label={t('userManagement')}
-                                    path="/users"
-                                    isActive={location.pathname.startsWith('/users')}
-                                    onClick={handleNavClick}
-                                />
-                                <SidebarItem
-                                    icon={HardDrive}
-                                    label={t('modelManagement')}
-                                    path="/models"
-                                    isActive={location.pathname.startsWith('/models')}
-                                    onClick={handleNavClick}
-                                />
-                                <SidebarItem
-                                    icon={Database}
-                                    label="Index Chat Config"
-                                    path="/kb-settings"
-                                    isActive={location.pathname.startsWith('/kb-settings')}
-                                    onClick={handleNavClick}
-                                />
-                            </>
-                        )}
-                        {user?.role === 'SUPER_ADMIN' && (
-                            <SidebarItem
-                                icon={LayoutGrid}
-                                label={t('navTenants')}
-                                path="/tenants"
-                                isActive={location.pathname.startsWith('/tenants')}
-                                onClick={handleNavClick}
-                            />
-                        )}
                     </div>
                 </nav>
 
@@ -178,7 +154,7 @@ const WorkspaceLayout: React.FC = () => {
                                     {activeTenant?.role?.replace('_', ' ') || user?.role?.replace('_', ' ') || 'USER'}
                                 </span>
                             </div>
-                            <p className="text-[12px] text-slate-500 truncate">{activeTenant?.tenant?.name || 'Default'}</p>
+                            <p className="text-[12px] text-slate-500 truncate">{activeTenant?.tenant?.name || t('defaultTenant')}</p>
                         </div>
                     </div>
                     <button
@@ -210,7 +186,7 @@ const WorkspaceLayout: React.FC = () => {
                                 className="flex items-center gap-2 px-4 py-2 bg-slate-50 border border-slate-200 rounded-xl hover:bg-slate-100 transition-all group"
                             >
                                 <Building2 size={16} className="text-blue-600" />
-                                <span className="text-sm font-bold text-slate-700">{activeTenant?.tenant?.name || 'Select Organization'}</span>
+                                <span className="text-sm font-bold text-slate-700">{activeTenant?.tenant?.name || t('selectOrganization')}</span>
                                 <ChevronRight size={14} className={cn("text-slate-400 transition-transform", showTenantMenu ? "rotate-90" : "")} />
                             </button>
 

+ 3 - 0
web/src/contexts/AuthContext.tsx

@@ -22,6 +22,9 @@ export interface TenantMembership {
         name: string;
         domain?: string;
     };
+    features?: {
+        isNotebookEnabled: boolean;
+    };
 }
 
 interface AuthContextType {

+ 12 - 0
web/src/pages/workspace/AgentsPage.tsx

@@ -0,0 +1,12 @@
+import React from 'react';
+import { AgentsView } from '../../../components/views/AgentsView';
+
+const AgentsPage: React.FC = () => {
+    return (
+        <div className="flex flex-col h-full">
+            <AgentsView />
+        </div>
+    );
+};
+
+export default AgentsPage;

+ 12 - 0
web/src/pages/workspace/PluginsPage.tsx

@@ -0,0 +1,12 @@
+import React from 'react';
+import { PluginsView } from '../../../components/views/PluginsView';
+
+const PluginsPage: React.FC = () => {
+    return (
+        <div className="flex flex-col h-full">
+            <PluginsView />
+        </div>
+    );
+};
+
+export default PluginsPage;

+ 4 - 3
web/src/pages/workspace/SettingsPage.tsx

@@ -29,9 +29,10 @@ export default function SettingsPage({ initialTab }: SettingsPageProps) {
 
     const handleUpdateModels = useCallback(async (action: 'create' | 'update' | 'delete', model: ModelConfig) => {
         if (!apiKey) return;
-        if (action === 'create') await modelConfigService.create(apiKey, model);
-        else if (action === 'update') await modelConfigService.update(apiKey, model.id, model);
-        else if (action === 'delete') await modelConfigService.remove(apiKey, model.id);
+        const { id, ...data } = model;
+        if (action === 'create') await modelConfigService.create(apiKey, data);
+        else if (action === 'update') await modelConfigService.update(apiKey, id, data);
+        else if (action === 'delete') await modelConfigService.remove(apiKey, id);
         await fetchModels();
     }, [apiKey, fetchModels]);
 

+ 5 - 0
web/types.ts

@@ -4,6 +4,7 @@ export interface IndexingConfig {
   chunkOverlap: number;
   embeddingModelId: string;
   mode?: 'fast' | 'precise'; // Processing mode: fast/precise
+  groupIds?: string[]; // Groups to associate with the file upon upload
 }
 
 // Vision Pipeline 相关类型
@@ -49,6 +50,8 @@ export interface KnowledgeGroup {
   description?: string;
   color: string;
   fileCount: number;
+  parentId?: string | null;
+  children?: KnowledgeGroup[];
   createdAt: string;
   updatedAt?: string;
 }
@@ -57,12 +60,14 @@ export interface CreateGroupData {
   name: string;
   description?: string;
   color?: string;
+  parentId?: string | null;
 }
 
 export interface UpdateGroupData {
   name?: string;
   description?: string;
   color?: string;
+  parentId?: string | null;
 }
 
 export interface KnowledgeFile {

+ 598 - 10
web/utils/translations.ts

@@ -12,6 +12,7 @@ export const translations = {
     aiCommandsError: "发生错误",
     registerButton: "注册",
     loginError: "密钥不能为空",
+    unknown: "未知",
     unknownError: "未知错误",
     langZh: "语言: 中文",
     langEn: "语言: English",
@@ -216,7 +217,7 @@ export const translations = {
     createUser: "创建用户",
     admin: "管理员",
     user: "普通用户",
-    adminUser: "设为管理员",  // 新增
+    adminUser: "设为管理员",  // 新增,
     confirmChange: "确认修改",
     changeUserPassword: "修改用户密码",
     enterNewPassword: "请输入新密码",
@@ -376,8 +377,7 @@ export const translations = {
     passwordChangeSuccess: "密码修改成功",
     passwordChangeFailed: "密码修改失败",
     create: "创建",
-    validationFailedMsg:
-      '验证请求失败: $1。可能是跨域(CORS)限制或地址错误。您可以勾选"跳过验证"强制保存。',
+    validationFailedMsg: "validationFailedMsg",
 
     // Sidebar
     navChat: "对话",
@@ -385,6 +385,8 @@ export const translations = {
     navKnowledge: "知识库",
     navKnowledgeGroups: "知识组",
     navNotebook: "笔记本",
+    navAgent: "智能体",
+    navPlugin: "插件",
     notebookDesc: "记录您的个人想法和研究笔记。",
     newNote: "新建笔记",
     editNote: "编辑笔记",
@@ -596,7 +598,195 @@ export const translations = {
     noHistoryDesc: "开始一次对话来创建历史记录",
     loadMore: "加载更多",
     loadingHistoriesFailed: "加载搜索历史失败",
-    supportedFormatsInfo: "支持文档、图片及代码格式",
+    generalSettingsSubtitle: "管理您的应用程序首选项。",
+    userManagementSubtitle: "管理访问权限和帐户。",
+    modelManagementSubtitle: "配置全局 AI 模型。",
+    kbSettingsSubtitle: "索引和聊天参数的技术配置。",
+    tenantsSubtitle: "全局系统概览。",
+
+    allNotes: "所有笔记",
+    filterNotesPlaceholder: "筛选笔记...",
+    startWritingPlaceholder: "开始写作...",
+    previewHeader: "预览",
+    noContentToPreview: "没有可预览的内容",
+    hidePreview: "隐藏预览",
+    showPreview: "显示预览",
+    directoryLabel: "目录",
+    uncategorized: "未分类",
+    enterNamePlaceholder: "输入名称...",
+    subFolderPlaceholder: "子文件夹...",
+    categoryCreated: "分类已创建",
+    failedToCreateCategory: "创建分类失败",
+    failedToDeleteCategory: "删除分类失败",
+    confirmDeleteCategory: "您确定要删除此分类吗?",
+    kbSettingsSaved: "检索与对话配置已保存",
+    failedToSaveSettings: "保存设置失败",
+    actionFailed: "操作失败",
+    userAddedToOrganization: "用户已添加到组织",
+    featureUpdated: "功能已更新",
+    roleTenantAdmin: "租户管理员",
+    roleRegularUser: "普通用户",
+    creatingRegularUser: "正在创建普通用户",
+    editUserRole: "修改用户角色",
+    targetRole: "目标角色",
+    editCategory: "编辑分类",
+    totalTenants: "总租户数",
+    systemUsers: "系统用户",
+    systemHealth: "系统健康",
+    operational: "运行正常",
+    orgManagement: "组织管理",
+    globalTenantControl: "全局租户控制",
+    newTenant: "新租户",
+    domainOptional: "域名 (可选)",
+    saveChanges: "保存修改",
+    modelConfiguration: "模型配置",
+    defaultLLMModel: "默认推理模型",
+    selectLLM: "选择 LLM",
+    selectEmbedding: "选择 Embedding",
+    rerankModel: "Rerank 模型",
+    none: "无",
+    indexingChunkingConfig: "索引与切片配置",
+    chatHyperparameters: "聊天超参数",
+    temperature: "随机性 (Temperature)",
+    precise: "精确",
+    creative: "创意",
+    maxResponseTokens: "最大响应标识 (Max Tokens)",
+    retrievalSearchSettings: "检索与搜索设置",
+    topK: "召回数量 (Top K)",
+    similarityThreshold: "相似度阈值",
+    enableHybridSearch: "启用混合检索",
+    hybridSearchDesc: "同时使用向量和全文检索以提高召回率",
+    hybridWeight: "混合权重 (0.0=全文, 1.0=向量)",
+    pureText: "纯文本",
+    pureVector: "纯向量",
+    enableQueryExpansion: "启用查询扩展",
+    queryExpansionDesc: "生成多个查询变体以提高覆盖率",
+    enableHyDE: "启用 HyDE",
+    hydeDesc: "生成假设回答以改善语义搜索",
+    enableReranking: "启用重排序 (Rerank)",
+    rerankingDesc: "使用 Rerank 模型对结果进行二次排序",
+    broad: "宽泛",
+    strict: "严格",
+    maxInput: "最大输入",
+    dimensions: "维度",
+    defaultBadge: "默认",
+    dims: "维度: $1",
+    ctx: "上下文: $1",
+    baseApi: "Base API: $1",
+    configured: "已配置",
+    groupUpdated: "分组已更新",
+    groupDeleted: "分组已删除",
+    groupCreated: "分组已创建",
+    navCatalog: "目录",
+    allDocuments: "所有文档",
+    categories: "分类",
+    uncategorizedFiles: "未分类文件",
+    category: "分类",
+    statusReadyDesc: "已索引可查询",
+    statusIndexingDesc: "正在建立词向量索引",
+    selectCategory: "选择分类",
+    noneUncategorized: "无未分类文件",
+    previous: "上一页",
+    next: "下一页",
+    createCategory: "创建分类",
+    categoryDesc: "描述您的知识分类",
+    categoryName: "分类名称",
+    createCategoryBtn: "立即创建",
+    newGroup: "新建分组",
+    noKnowledgeGroups: "暂无知识库分组",
+    createGroupDesc: "开始创建您的第一个知识库分组并上传相关文档。",
+    noDescriptionProvided: "未提供描述",
+    browseManageFiles: "浏览并管理该分组下的文件和笔记。",
+    filterGroupFiles: "根据名称搜索分组内文件...",
+    "2d": "2d",
+    Authorization: "Authorization",
+    a: "a",
+    agentTitle: "智能体中心",
+    agentDesc: "管理和运行您的 AI 助手,协助完成复杂任务。",
+    createAgent: "创建智能体",
+    searchAgent: "搜索智能体...",
+    statusRunning: "运行中",
+    statusStopped: "已停止",
+    updatedAtPrefix: "最后更新于 ",
+    btnChat: "开始对话",
+
+    // Agent Mock Data
+    agent1Name: "数据分析专家",
+    agent1Desc: "精通 SQL 和数据可视化,能够从复杂数据中提取洞察。",
+    agent2Name: "代码审查助手",
+    agent2Desc: "自动检查代码质量,提供重构建议和性能优化方案。",
+    agent3Name: "学术论文润色",
+    agent3Desc: "专业的学术写作助手,帮助优化论文结构和语言表达。",
+    agent4Name: "法律顾问",
+    agent4Desc: "提供法律条文查询和基础法律建议,协助起草合同。",
+    agent5Name: "市场研究员",
+    agent5Desc: "分析行业趋势,生成竞争对手调研报告。",
+    agent6Name: "系统运维专家",
+    agent6Desc: "监控系统健康,自动处理常见告警和排障。",
+    agent7Name: "财务审计师",
+    agent7Desc: "自动化报表审计,识别财务风险和异常交易。",
+    agent1Time: "2 小时前",
+    agent2Time: "5 小时前",
+    agent3Time: "昨天",
+    agent4Time: "2 天前",
+    agent5Time: "3 天前",
+    agent6Time: "5 天前",
+    agent7Time: "1 周前",
+
+    // Plugins
+    pluginTitle: "插件中心",
+    pluginDesc: "扩展知识库的功能,集成外部工具和服务。",
+    searchPlugin: "搜索插件...",
+    installPlugin: "安装插件",
+    installedPlugin: "已安装",
+    updatePlugin: "有更新",
+    pluginOfficial: "官方",
+    pluginCommunity: "社区",
+    pluginBy: "由 ",
+    pluginConfig: "插件配置",
+
+    // Plugin Mock Data
+    plugin1Name: "Web 搜索",
+    plugin1Desc: "赋予 AI 实时访问互联网的能力,获取最新信息。",
+    plugin2Name: "PDF 文档解析",
+    plugin2Desc: "深度解析复杂 PDF 布局,提取表格和数学公式。",
+    plugin3Name: "GitHub 集成",
+    plugin3Desc: "直接访问 GitHub 仓库,进行代码提交和 issue 管理。",
+    plugin4Name: "Google 日历",
+    plugin4Desc: "同步您的日程安排,自动创建会议提醒。",
+    plugin5Name: "SQL 数据库连接",
+    plugin5Desc: "安全地连接到您的数据库,执行自然语言查询。",
+    plugin6Name: "Slack 通知",
+    plugin6Desc: "将 AI 生成的报告直接发送到指定的 Slack 频道。",
+
+    // Hierarchical categories new keys
+    addSubcategory: "添加子分类",
+    parentCategory: "父分类 (可选)",
+    noParentTopLevel: "无父分类(顶级)",
+    useHierarchyImport: "按文件夹层级创建分类",
+    useHierarchyImportDesc: "启用后将为每个子文件夹创建对应的子分类,并将文件导入到匹配的分类中。",
+
+    // Folder import drawer
+    importImmediate: "立即导入",
+    importScheduled: "定时导入",
+    lblServerPath: "服务器文件夹路径",
+    placeholderServerPath: "例如: /data/documents",
+    scheduledImportTip: "服务器端定时导入:服务器将在指定时间读取该路径下的文件并自动导入。请确保服务器有访问该路径的权限。",
+    lblScheduledTime: "执行时间",
+    scheduledTimeHint: "到达指定时间后,服务器将自动执行导入任务。",
+    scheduleImport: "创建定时任务",
+    scheduleTaskCreated: "定时导入任务已创建",
+    fillServerPath: "请输入服务器文件夹路径",
+    invalidDateTime: "请输入有效的日期时间",
+
+    // Import Tasks Drawer
+    importTasksTitle: "定时计划",
+    noTasksFound: "暂无任务",
+    sourcePath: "源路径",
+    targetGroup: "目标分组",
+    scheduledAt: "计划执行时间",
+    confirmDeleteTask: "确定要删除此导入任务记录吗?",
+    deleteTaskFailed: "删除任务记录失败",
   },
   en: {
     aiCommandsError: "An error occurred",
@@ -605,6 +795,7 @@ export const translations = {
     loginDesc: "Enter access key to enter the system",
     loginButton: "Enter System",
     loginError: "Key cannot be empty",
+    unknown: "Unknown",
     unknownError: "Unknown Error",
     usernamePlaceholder: "Username",
     passwordPlaceholder: "Password",
@@ -1046,8 +1237,7 @@ export const translations = {
     passwordChangeSuccess: "Password changed successfully",
     passwordChangeFailed: "Failed to change password",
     create: "Create",
-    validationFailedMsg:
-      "Validation failed: $1. Could be CORS or incorrect URL. Check 'Skip Validation' to force save.",
+    validationFailedMsg: "validationFailedMsg",
 
     // Sidebar
     navChat: "Chat",
@@ -1055,6 +1245,8 @@ export const translations = {
     navKnowledge: "Knowledge Base",
     navKnowledgeGroups: "Knowledge Groups",
     navNotebook: "Notebook",
+    navAgent: "Agents",
+    navPlugin: "Plugins",
     notebookDesc: "Capture your personal thoughts and research notes.",
     newNote: "New Note",
     editNote: "Edit Note",
@@ -1198,6 +1390,202 @@ export const translations = {
     loadMore: "Load More",
     loadingHistoriesFailed: "Failed to load search history",
     supportedFormatsInfo: "Supports documents, images and code formats",
+    generalSettingsSubtitle: "Manage your application preferences.",
+    userManagementSubtitle: "Manage access and accounts.",
+    modelManagementSubtitle: "Configure global AI models.",
+    kbSettingsSubtitle: "Technical configuration for indexing and chat parameters.",
+    tenantsSubtitle: "Global system overview.",
+
+    allNotes: "All Notes",
+    filterNotesPlaceholder: "Filter notes...",
+    startWritingPlaceholder: "Start writing...",
+    previewHeader: "Preview",
+    noContentToPreview: "No content to preview",
+    hidePreview: "Hide Preview",
+    showPreview: "Show Preview",
+    directoryLabel: "Directory",
+    uncategorized: "Uncategorized",
+    enterNamePlaceholder: "Enter name...",
+    subFolderPlaceholder: "Sub-folder...",
+    categoryCreated: "Category created",
+    failedToCreateCategory: "Failed to create category",
+    failedToDeleteCategory: "Failed to delete category",
+    confirmDeleteCategory: "Are you sure you want to delete this category?",
+    groupUpdated: "Group updated",
+    groupDeleted: "Group deleted",
+    actionFailed: "Action failed",
+    kbSettingsSaved: "Knowledge base settings saved",
+    failedToSaveSettings: "Failed to save settings",
+    userAddedToOrganization: "User added to organization",
+    featureUpdated: "Feature updated",
+    roleTenantAdmin: "Tenant Administrator",
+    roleRegularUser: "Regular User",
+    creatingRegularUser: "Creating regular user",
+    editUserRole: "Edit user role",
+    targetRole: "Target Role",
+    editCategory: "Edit category",
+    totalTenants: "Total Tenants",
+    systemUsers: "System Users",
+    systemHealth: "System Health",
+    operational: "Operational",
+    orgManagement: "Organization Management",
+    globalTenantControl: "Global Tenant Control",
+    newTenant: "New Tenant",
+    domainOptional: "Domain (Optional)",
+    saveChanges: "Save changes",
+    modelConfiguration: "Model Configuration",
+    defaultLLMModel: "Default LLM Model",
+    selectLLM: "Select LLM",
+    selectEmbedding: "Select Embedding",
+    rerankModel: "Rerank Model",
+    none: "None",
+    indexingChunkingConfig: "Indexing & Chunking Config",
+    chatHyperparameters: "Chat Hyperparameters",
+    temperature: "Temperature",
+    precise: "Precise",
+    creative: "Creative",
+    maxResponseTokens: "Max Response Tokens",
+    retrievalSearchSettings: "Retrieval & Search Settings",
+    topK: "Top K",
+    similarityThreshold: "Similarity Threshold",
+    enableHybridSearch: "Enable Hybrid Search",
+    hybridSearchDesc: "Use both vector and full-text search to improve recall",
+    hybridWeight: "Hybrid Weight (0.0=Fulltext, 1.0=Vector)",
+    pureText: "Pure Text",
+    pureVector: "Pure Vector",
+    enableQueryExpansion: "Enable Query Expansion",
+    queryExpansionDesc: "Generate multiple query variations for better coverage",
+    enableHyDE: "Enable HyDE",
+    hydeDesc: "Generate hypothetical answers to improve semantic search",
+    enableReranking: "Enable Reranking",
+    rerankingDesc: "Use Rerank model to re-sort results",
+    broad: "Broad",
+    strict: "Strict",
+    maxInput: "Max Input",
+    dimensions: "Dimensions",
+    defaultBadge: "Default",
+    dims: "Dims: $1",
+    ctx: "Ctx: $1",
+    baseApi: "Base API: $1",
+    configured: "Configured",
+    groupCreated: "Group created",
+    navCatalog: "Catalog",
+    allDocuments: "All Documents",
+    categories: "Categories",
+    uncategorizedFiles: "Uncategorized Files",
+    category: "Category",
+    statusReadyDesc: "Indexed and searchable",
+    statusIndexingDesc: "Building vector index",
+    selectCategory: "Select Category",
+    noneUncategorized: "No uncategorized files",
+    previous: "Previous",
+    next: "Next",
+    createCategory: "Create Category",
+    categoryDesc: "Describe your knowledge category",
+    categoryName: "Category Name",
+    createCategoryBtn: "Create Now",
+    newGroup: "New Group",
+    noKnowledgeGroups: "No knowledge groups yet",
+    createGroupDesc: "Start by creating your first knowledge group and uploading documents.",
+    noDescriptionProvided: "No description provided",
+    browseManageFiles: "Browse and manage files and notes in this group.",
+    filterGroupFiles: "Search files in group by name...",
+    "2d": "2d",
+    Authorization: "Authorization",
+    a: "a",
+    agentTitle: "Agent Center",
+    agentDesc: "Manage and run your AI assistants to help with complex tasks.",
+    createAgent: "Create Agent",
+    searchAgent: "Search agents...",
+    statusRunning: "Running",
+    statusStopped: "Stopped",
+    updatedAtPrefix: "Last updated at ",
+    btnChat: "Start Chat",
+
+    // Agent Mock Data
+    agent1Name: "Data Analyst Pro",
+    agent1Desc: "Expert in SQL and data visualization, capable of extracting insights from complex data.",
+    agent2Name: "Code Review Assistant",
+    agent2Desc: "Automatically checks code quality, provides refactoring suggestions and performance optimizations.",
+    agent3Name: "Academic Paper Polisher",
+    agent3Desc: "Professional academic writing assistant to help optimize paper structure and language.",
+    agent4Name: "Legal Consultant",
+    agent4Desc: "Provides legal article search and basic legal advice, assists in drafting contracts.",
+    agent5Name: "Market Researcher",
+    agent5Desc: "Analyze industry trends and generate competitor research reports.",
+    agent6Name: "SRE Expert",
+    agent6Desc: "Monitor system health, automatically handle common alerts and troubleshooting.",
+    agent7Name: "Financial Auditor",
+    agent7Desc: "Automate report auditing and identify financial risks and abnormal transactions.",
+    agent1Time: "2 hours ago",
+    agent2Time: "5 hours ago",
+    agent3Time: "Yesterday",
+    agent4Time: "2 days ago",
+    agent5Time: "3 days ago",
+    agent6Time: "5 days ago",
+    agent7Time: "1 week ago",
+
+    // Plugins
+    pluginTitle: "Plugin Store",
+    pluginDesc: "Extend the functionality of your knowledge base with external tools and services.",
+    searchPlugin: "Search plugins...",
+    installPlugin: "Install",
+    installedPlugin: "Installed",
+    updatePlugin: "Update Available",
+    pluginOfficial: "OFFICIAL",
+    pluginCommunity: "COMMUNITY",
+    pluginBy: "By ",
+    pluginConfig: "Configuration",
+
+    // Plugin Mock Data
+    plugin1Name: "Web Search",
+    plugin1Desc: "Gives AI real-time access to the internet for the latest information.",
+    plugin2Name: "PDF Document Parser",
+    plugin2Desc: "Deeply parse complex PDF layouts, extracting tables and mathematical formulas.",
+    plugin3Name: "GitHub Integration",
+    plugin3Desc: "Directly access GitHub repositories for code commits and issue management.",
+    plugin4Name: "Google Calendar",
+    plugin4Desc: "Sync your schedule and automatically create meeting reminders.",
+    plugin5Name: "SQL Database Connector",
+    plugin5Desc: "Securely connect to your databases and perform natural language queries.",
+    plugin6Name: "Slack Notifier",
+    plugin6Desc: "Send AI-generated reports directly to specified Slack channels.",
+
+    personalNotebook: "Personal Notebook",
+    success: "Success",
+    warning: "Warning",
+    "x-api-key": "API Key",
+    "x-tenant-id": "Tenant ID",
+    "x-user-language": "User Language",
+
+    // Hierarchical categories new keys
+    addSubcategory: "Add Subcategory",
+    parentCategory: "Parent Category (optional)",
+    noParentTopLevel: "None (top-level)",
+    useHierarchyImport: "Create categories by folder hierarchy",
+    useHierarchyImportDesc: "When enabled, a sub-category will be created for each sub-folder and files imported into matching categories.",
+
+    // Folder import drawer
+    importImmediate: "Import Now",
+    importScheduled: "Scheduled Import",
+    lblServerPath: "Server Folder Path",
+    placeholderServerPath: "e.g. /data/documents",
+    scheduledImportTip: "Server-side scheduled import: the server will read files from the given path at the specified time and import them automatically. Ensure the server has access to the path.",
+    lblScheduledTime: "Execution Time",
+    scheduledTimeHint: "The server will automatically run the import task at the specified time.",
+    scheduleImport: "Create Scheduled Task",
+    scheduleTaskCreated: "Scheduled import task created",
+    fillServerPath: "Please enter the server folder path",
+    invalidDateTime: "Please enter a valid date and time",
+
+    // Import Tasks Drawer
+    importTasksTitle: "Scheduled Plans",
+    noTasksFound: "No tasks found",
+    sourcePath: "Source Path",
+    targetGroup: "Target Group",
+    scheduledAt: "Scheduled At",
+    confirmDeleteTask: "Are you sure you want to delete this import task record?",
+    deleteTaskFailed: "Failed to delete task record",
   },
   ja: {
     aiCommandsError: "エラーが発生しました",
@@ -1206,6 +1594,7 @@ export const translations = {
     loginDesc: "システムに入るためのキーを入力してください",
     loginButton: "ログイン",
     loginError: "キーは必須です",
+    unknown: "不明",
     unknownError: "未知のエラー",
     usernamePlaceholder: "ユーザー名",
     passwordPlaceholder: "パスワード",
@@ -1600,14 +1989,16 @@ export const translations = {
     passwordChangeSuccess: "パスワードを変更しました",
     passwordChangeFailed: "パスワードの変更に失敗しました",
     create: "作成",
-    validationFailedMsg:
-      "検証失敗: $1。CORSまたはURLが間違っている可能性があります。「検証をスキップ」をチェックして強制保存してください。",
+    validationFailedMsg: "validationFailedMsg",
 
     // Sidebar
     navChat: "チャット",
     navCoach: "コーチ",
     navKnowledge: "ナレッジベース",
     navKnowledgeGroups: "ナレッジグループ",
+    navNotebook: "ノートブック",
+    navAgent: "エージェント",
+    navPlugin: "プラグイン",
     navCrawler: "リソース取得",
     expandMenu: "メニューを展開",
     switchLanguage: "言語を切り替える",
@@ -1787,5 +2178,202 @@ export const translations = {
     loadMore: "もっと読み込む",
     loadingHistoriesFailed: "履歴の読み込みに失敗しました",
     supportedFormatsInfo: "ドキュメント、画像、ソースコードをサポート",
-  }
-}; // end of translations
+    kbSettingsSaved: "設定を保存しました",
+    failedToSaveSettings: "設定の保存に失敗しました",
+    actionFailed: "操作に失敗しました",
+    userAddedToOrganization: "ユーザーが組織に追加されました",
+    featureUpdated: "機能が更新されました",
+    roleTenantAdmin: "テナント管理者",
+    roleRegularUser: "一般ユーザー",
+    creatingRegularUser: "一般ユーザーを作成中",
+    editUserRole: "ユーザーロールを編集",
+    targetRole: "対象のロール",
+    editCategory: "カテゴリを編集",
+    totalTenants: "総テナント数",
+    systemUsers: "システムユーザー",
+    systemHealth: "システムヘルス",
+    operational: "正常稼働中",
+    orgManagement: "組織管理",
+    globalTenantControl: "グローバルテナントコントロール",
+    newTenant: "新規テナント",
+    domainOptional: "ドメイン (任意)",
+    saveChanges: "変更を保存",
+    modelConfiguration: "モデル設定",
+    defaultLLMModel: "デフォルト推論モデル",
+    selectLLM: "LLMを選択",
+    selectEmbedding: "埋め込みを選択",
+    rerankModel: "リランクモデル",
+    none: "なし",
+    indexingChunkingConfig: "インデックスとチャンク設定",
+    chatHyperparameters: "チャットハイパーパラメータ",
+    temperature: "温度",
+    precise: "精密",
+    creative: "クリエイティブ",
+    maxResponseTokens: "最大応答トークン数",
+    retrievalSearchSettings: "検索設定",
+    topK: "Top K",
+    similarityThreshold: "類似度しきい値",
+    enableHybridSearch: "ハイブリッド検索を有効にする",
+    hybridSearchDesc: "ベクトル検索と全文検索を併用して検索精度を向上させます",
+    hybridWeight: "ハイブリッド重み (0.0=全文, 1.0=ベクトル)",
+    pureText: "純粋なテキスト",
+    pureVector: "純粋なベクトル",
+    enableQueryExpansion: "クエリ拡張を有効にする",
+    queryExpansionDesc: "複数のクエリバリアントを生成してカバレッジを向上させます",
+    enableHyDE: "HyDEを有効にする",
+    hydeDesc: "仮想的な回答を生成してセマンティック検索を改善します",
+    enableReranking: "リランクを有効にする",
+    rerankingDesc: "リランクモデルを使用して結果を再ソートします",
+    broad: "広範",
+    strict: "厳格",
+    maxInput: "最大入力",
+    dimensions: "次元",
+    defaultBadge: "デフォルト",
+    dims: "次元: $1",
+    ctx: "コンテキスト: $1",
+    baseApi: "Base API: $1",
+    configured: "設定済み",
+    groupUpdated: "グループが更新されました",
+    groupDeleted: "グループが削除されました",
+    groupCreated: "グループが作成されました",
+    navCatalog: "カタログ",
+    allDocuments: "すべてのドキュメント",
+    categories: "カテゴリ",
+    uncategorizedFiles: "未分類ファイル",
+    category: "カテゴリ",
+    statusReadyDesc: "インデックス済みで検索可能",
+    statusIndexingDesc: "ベクトルインデックスを作成中",
+    selectCategory: "カテゴリを選択",
+    noneUncategorized: "未分類ファイルなし",
+    previous: "前へ",
+    next: "次へ",
+    createCategory: "カテゴリを作成",
+    categoryDesc: "ナレッジカテゴリを説明します",
+    categoryName: "カテゴリ名",
+    createCategoryBtn: "今すぐ作成",
+    newGroup: "新規グループ",
+    noKnowledgeGroups: "ナレッジグループがまだありません",
+    createGroupDesc: "最初のナレッジグループを作成してドキュメントをアップロードしてください。",
+    noDescriptionProvided: "説明なし",
+    browseManageFiles: "このグループ内のファイルとメモを閲覧・管理します。",
+    filterGroupFiles: "名前でグループ内のファイルを検索...",
+    generalSettingsSubtitle: "アプリケーションの設定を管理します。",
+    userManagementSubtitle: "アクセス権限とアカウントを管理します。",
+    modelManagementSubtitle: "グローバルなAIモデルを設定します。",
+    kbSettingsSubtitle: "インデックス作成とチャットパラメータの技術設定。",
+    tenantsSubtitle: "グローバルシステムの概要。",
+    allNotes: "すべてのノート",
+    filterNotesPlaceholder: "ノートをフィルタリング...",
+    startWritingPlaceholder: "書き始める...",
+    previewHeader: "プレビュー",
+    noContentToPreview: "プレビューするコンテンツがありません",
+    hidePreview: "プレビューを非表示",
+    showPreview: "プレビューを表示",
+    directoryLabel: "ディレクトリ",
+    uncategorized: "未分類",
+    enterNamePlaceholder: "名前を入力...",
+    subFolderPlaceholder: "サブフォルダ...",
+    categoryCreated: "カテゴリが作成されました",
+    failedToCreateCategory: "カテゴリの作成に失敗しました",
+    failedToDeleteCategory: "カテゴリの削除に失敗しました",
+    confirmDeleteCategory: "このカテゴリを削除してもよろしいですか?",
+    "2d": "2d",
+    Authorization: "Authorization",
+    a: "a",
+    agentTitle: "エージェントセンター",
+    agentDesc: "複雑なタスクを支援する AI アシスタントを管理および実行します。",
+    createAgent: "エージェント作成",
+    searchAgent: "エージェントを検索...",
+    statusRunning: "実行中",
+    statusStopped: "停止中",
+    updatedAtPrefix: "最終更新日: ",
+    btnChat: "会話を開始",
+
+    // Agent Mock Data
+    agent1Name: "データ分析エキスパート",
+    agent1Desc: "SQL とデータ視覚化に精通し、複雑なデータから洞察を抽出できます。",
+    agent2Name: "コードレビュー助手",
+    agent2Desc: "コードの品質を自動的にチェックし、リファクタリングの提案やパフォーマンス最適化案を提供します。",
+    agent3Name: "学術論文校閲",
+    agent3Desc: "専門的な学術ライティングアシスタント。論文の構成と表現の最適化を支援します。",
+    agent4Name: "法律顧問",
+    agent4Desc: "法律条文の検索や基本的な法的アドバイスを提供し、契約書の作成を支援します。",
+    agent5Name: "市場調査員",
+    agent5Desc: "業界のトレンドを分析し、競合他社の調査レポートを生成します。",
+    agent6Name: "システム運用保守エキスパート",
+    agent6Desc: "システムの健康状態を監視し、一般的なアラートへの対応やトラブルシューティングを自動化します。",
+    agent7Name: "財務監査人",
+    agent7Desc: "レポート監査を自動化し、財務リスクや異常な取引を特定します。",
+    agent1Time: "2 時間前",
+    agent2Time: "5 時間前",
+    agent3Time: "昨日",
+    agent4Time: "2 日前",
+    agent5Time: "3 日前",
+    agent6Time: "5 日前",
+    agent7Time: "1 週間前",
+
+    // Plugins
+    pluginTitle: "プラグインストア",
+    pluginDesc: "外部ツールやサービスを統合して、ナレッジベースの機能を拡張します。",
+    searchPlugin: "プラグインを検索...",
+    installPlugin: "インストール",
+    installedPlugin: "インストール済み",
+    updatePlugin: "アップデートあり",
+    pluginOfficial: "公式",
+    pluginCommunity: "コミュニティ",
+    pluginBy: "開発者: ",
+    pluginConfig: "設定",
+
+    // Plugin Mock Data
+    plugin1Name: "Web 検索",
+    plugin1Desc: "最新情報を取得するために、AI にインターネットへのリアルタイムアクセスを提供します。",
+    plugin2Name: "PDF ドキュメント解析",
+    plugin2Desc: "複雑な PDF レイアウトを詳細に解析し、表や数式を抽出します。",
+    plugin3Name: "GitHub 連携",
+    plugin3Desc: "GitHub リポジトリに直接アクセスし、コードのコミットやイシュー管理を行います。",
+    plugin4Name: "Google カレンダー",
+    plugin4Desc: "スケジュールを同期し、会議のリマインダーを自動的に作成します。",
+    plugin5Name: "SQL データベース接続",
+    plugin5Desc: "データベースに安全に接続し、自然語言でクエリを実行します。",
+    plugin6Name: "Slack 通知",
+    plugin6Desc: "AI が生成したレポートを指定された Slack チャンネルに直接送信します。",
+
+    navTenants: "テナント管理",
+    noNotesFound: "ノートが見つかりません",
+    notebookDesc: "ノートブックは知識の整理と要約に役立ちます。",
+    personalNotebook: "個人用ノートブック",
+    warning: "警告",
+    "x-api-key": "APIキー",
+    "x-tenant-id": "テナントID",
+    "x-user-language": "ユーザー言語",
+
+    // Hierarchical categories new keys
+    addSubcategory: "サブカテゴリを追加",
+    parentCategory: "親カテゴリ(任意)",
+    noParentTopLevel: "なし(トップレベル)",
+    useHierarchyImport: "フォルダ階層でカテゴリを作成",
+    useHierarchyImportDesc: "有効にすると、各サブフォルダにサブカテゴリが作成され、ファイルが対応するカテゴリにインポートされます。",
+
+    // Folder import drawer
+    importImmediate: "今すぐインポート",
+    importScheduled: "スケジュールインポート",
+    lblServerPath: "サーバーフォルダパス",
+    placeholderServerPath: "例: /data/documents",
+    scheduledImportTip: "サーバー側のスケジュールインポート:指定した時刻にサーバーがパスのファイルを読み込んで自動インポートします。サーバーがそのパスにアクセスできることを確認してください。",
+    lblScheduledTime: "実行日時",
+    scheduledTimeHint: "指定した時刻に、サーバーが自動的にインポートタスクを実行します。",
+    scheduleImport: "スケジュールタスクを作成",
+    scheduleTaskCreated: "スケジュールインポートタスクが作成されました",
+    fillServerPath: "サーバーフォルダパスを入力してください",
+    invalidDateTime: "有効な日付と時刻を入力してください",
+
+    // Import Tasks Drawer
+    importTasksTitle: "定期計画",
+    noTasksFound: "タスクは見つかりませんでした",
+    sourcePath: "ソースパス",
+    targetGroup: "ターゲットグループ",
+    scheduledAt: "実行予定日時",
+    confirmDeleteTask: "このインポートタスクレコードを削除してもよろしいですか?",
+    deleteTaskFailed: "タスクレコードの削除に失敗しました",
+  },
+};

+ 1 - 0
web/vite.config.ts

@@ -33,3 +33,4 @@ export default defineConfig(({ mode }) => {
     publicDir: 'public'
   };
 });
+// touch

+ 0 - 1822
web/yarn.lock

@@ -1,1822 +0,0 @@
-# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-# yarn lockfile v1
-
-
-"@babel/code-frame@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be"
-  integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.27.1"
-    js-tokens "^4.0.0"
-    picocolors "^1.1.1"
-
-"@babel/compat-data@^7.27.2":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f"
-  integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==
-
-"@babel/core@^7.28.5":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e"
-  integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==
-  dependencies:
-    "@babel/code-frame" "^7.27.1"
-    "@babel/generator" "^7.28.5"
-    "@babel/helper-compilation-targets" "^7.27.2"
-    "@babel/helper-module-transforms" "^7.28.3"
-    "@babel/helpers" "^7.28.4"
-    "@babel/parser" "^7.28.5"
-    "@babel/template" "^7.27.2"
-    "@babel/traverse" "^7.28.5"
-    "@babel/types" "^7.28.5"
-    "@jridgewell/remapping" "^2.3.5"
-    convert-source-map "^2.0.0"
-    debug "^4.1.0"
-    gensync "^1.0.0-beta.2"
-    json5 "^2.2.3"
-    semver "^6.3.1"
-
-"@babel/generator@^7.28.5":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298"
-  integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==
-  dependencies:
-    "@babel/parser" "^7.28.5"
-    "@babel/types" "^7.28.5"
-    "@jridgewell/gen-mapping" "^0.3.12"
-    "@jridgewell/trace-mapping" "^0.3.28"
-    jsesc "^3.0.2"
-
-"@babel/helper-compilation-targets@^7.27.2":
-  version "7.27.2"
-  resolved "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d"
-  integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==
-  dependencies:
-    "@babel/compat-data" "^7.27.2"
-    "@babel/helper-validator-option" "^7.27.1"
-    browserslist "^4.24.0"
-    lru-cache "^5.1.1"
-    semver "^6.3.1"
-
-"@babel/helper-globals@^7.28.0":
-  version "7.28.0"
-  resolved "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
-  integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
-
-"@babel/helper-module-imports@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204"
-  integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==
-  dependencies:
-    "@babel/traverse" "^7.27.1"
-    "@babel/types" "^7.27.1"
-
-"@babel/helper-module-transforms@^7.28.3":
-  version "7.28.3"
-  resolved "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6"
-  integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==
-  dependencies:
-    "@babel/helper-module-imports" "^7.27.1"
-    "@babel/helper-validator-identifier" "^7.27.1"
-    "@babel/traverse" "^7.28.3"
-
-"@babel/helper-plugin-utils@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c"
-  integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
-
-"@babel/helper-string-parser@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
-  integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
-
-"@babel/helper-validator-identifier@^7.27.1", "@babel/helper-validator-identifier@^7.28.5":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
-  integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
-
-"@babel/helper-validator-option@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
-  integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
-
-"@babel/helpers@^7.28.4":
-  version "7.28.4"
-  resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827"
-  integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==
-  dependencies:
-    "@babel/template" "^7.27.2"
-    "@babel/types" "^7.28.4"
-
-"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08"
-  integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==
-  dependencies:
-    "@babel/types" "^7.28.5"
-
-"@babel/plugin-transform-react-jsx-self@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92"
-  integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.27.1"
-
-"@babel/plugin-transform-react-jsx-source@^7.27.1":
-  version "7.27.1"
-  resolved "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0"
-  integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.27.1"
-
-"@babel/template@^7.27.2":
-  version "7.27.2"
-  resolved "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
-  integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==
-  dependencies:
-    "@babel/code-frame" "^7.27.1"
-    "@babel/parser" "^7.27.2"
-    "@babel/types" "^7.27.1"
-
-"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b"
-  integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==
-  dependencies:
-    "@babel/code-frame" "^7.27.1"
-    "@babel/generator" "^7.28.5"
-    "@babel/helper-globals" "^7.28.0"
-    "@babel/parser" "^7.28.5"
-    "@babel/template" "^7.27.2"
-    "@babel/types" "^7.28.5"
-    debug "^4.3.1"
-
-"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5":
-  version "7.28.5"
-  resolved "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b"
-  integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==
-  dependencies:
-    "@babel/helper-string-parser" "^7.27.1"
-    "@babel/helper-validator-identifier" "^7.28.5"
-
-"@esbuild/aix-ppc64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c"
-  integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==
-
-"@esbuild/android-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752"
-  integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==
-
-"@esbuild/android-arm@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a"
-  integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==
-
-"@esbuild/android-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16"
-  integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==
-
-"@esbuild/darwin-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd"
-  integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==
-
-"@esbuild/darwin-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e"
-  integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==
-
-"@esbuild/freebsd-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe"
-  integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==
-
-"@esbuild/freebsd-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3"
-  integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==
-
-"@esbuild/linux-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977"
-  integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==
-
-"@esbuild/linux-arm@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9"
-  integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==
-
-"@esbuild/linux-ia32@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0"
-  integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==
-
-"@esbuild/linux-loong64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0"
-  integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==
-
-"@esbuild/linux-mips64el@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd"
-  integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==
-
-"@esbuild/linux-ppc64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869"
-  integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==
-
-"@esbuild/linux-riscv64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6"
-  integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==
-
-"@esbuild/linux-s390x@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663"
-  integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==
-
-"@esbuild/linux-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306"
-  integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==
-
-"@esbuild/netbsd-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4"
-  integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==
-
-"@esbuild/netbsd-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076"
-  integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==
-
-"@esbuild/openbsd-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd"
-  integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==
-
-"@esbuild/openbsd-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679"
-  integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==
-
-"@esbuild/openharmony-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d"
-  integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==
-
-"@esbuild/sunos-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6"
-  integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==
-
-"@esbuild/win32-arm64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323"
-  integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==
-
-"@esbuild/win32-ia32@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267"
-  integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==
-
-"@esbuild/win32-x64@0.25.12":
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5"
-  integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==
-
-"@google/genai@^1.32.0":
-  version "1.32.0"
-  resolved "https://registry.npmmirror.com/@google/genai/-/genai-1.32.0.tgz#63f7d3c68894af7c53797487fd794f2c11758712"
-  integrity sha512-46vaEaHAThIBlqWFTti1fo3xYU6DwCOwnIIotLhYUbNha90wk5cZL79zdf+NoAfKVsx4DPmjCtXvbQNNVPl5ZQ==
-  dependencies:
-    google-auth-library "^10.3.0"
-    ws "^8.18.0"
-
-"@isaacs/cliui@^8.0.2":
-  version "8.0.2"
-  resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
-  integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
-  dependencies:
-    string-width "^5.1.2"
-    string-width-cjs "npm:string-width@^4.2.0"
-    strip-ansi "^7.0.1"
-    strip-ansi-cjs "npm:strip-ansi@^6.0.1"
-    wrap-ansi "^8.1.0"
-    wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
-
-"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5":
-  version "0.3.13"
-  resolved "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
-  integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
-  dependencies:
-    "@jridgewell/sourcemap-codec" "^1.5.0"
-    "@jridgewell/trace-mapping" "^0.3.24"
-
-"@jridgewell/remapping@^2.3.5":
-  version "2.3.5"
-  resolved "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
-  integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
-  dependencies:
-    "@jridgewell/gen-mapping" "^0.3.5"
-    "@jridgewell/trace-mapping" "^0.3.24"
-
-"@jridgewell/resolve-uri@^3.1.0":
-  version "3.1.2"
-  resolved "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
-  integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
-
-"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0":
-  version "1.5.5"
-  resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
-  integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
-
-"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28":
-  version "0.3.31"
-  resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0"
-  integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
-  dependencies:
-    "@jridgewell/resolve-uri" "^3.1.0"
-    "@jridgewell/sourcemap-codec" "^1.4.14"
-
-"@pkgjs/parseargs@^0.11.0":
-  version "0.11.0"
-  resolved "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
-  integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
-
-"@rolldown/pluginutils@1.0.0-beta.53":
-  version "1.0.0-beta.53"
-  resolved "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz#c57a5234ae122671aff6fe72e673a7ed90f03f87"
-  integrity sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==
-
-"@rollup/rollup-android-arm-eabi@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb"
-  integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==
-
-"@rollup/rollup-android-arm64@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c"
-  integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==
-
-"@rollup/rollup-darwin-arm64@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0"
-  integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==
-
-"@rollup/rollup-darwin-x64@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c"
-  integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==
-
-"@rollup/rollup-freebsd-arm64@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c"
-  integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==
-
-"@rollup/rollup-freebsd-x64@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440"
-  integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==
-
-"@rollup/rollup-linux-arm-gnueabihf@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88"
-  integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==
-
-"@rollup/rollup-linux-arm-musleabihf@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701"
-  integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==
-
-"@rollup/rollup-linux-arm64-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e"
-  integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==
-
-"@rollup/rollup-linux-arm64-musl@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899"
-  integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==
-
-"@rollup/rollup-linux-loong64-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714"
-  integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==
-
-"@rollup/rollup-linux-ppc64-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293"
-  integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==
-
-"@rollup/rollup-linux-riscv64-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508"
-  integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==
-
-"@rollup/rollup-linux-riscv64-musl@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab"
-  integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==
-
-"@rollup/rollup-linux-s390x-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6"
-  integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==
-
-"@rollup/rollup-linux-x64-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa"
-  integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==
-
-"@rollup/rollup-linux-x64-musl@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951"
-  integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==
-
-"@rollup/rollup-openharmony-arm64@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7"
-  integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==
-
-"@rollup/rollup-win32-arm64-msvc@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080"
-  integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==
-
-"@rollup/rollup-win32-ia32-msvc@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5"
-  integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==
-
-"@rollup/rollup-win32-x64-gnu@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e"
-  integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==
-
-"@rollup/rollup-win32-x64-msvc@4.53.3":
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe"
-  integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==
-
-"@types/babel__core@^7.20.5":
-  version "7.20.5"
-  resolved "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
-  integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==
-  dependencies:
-    "@babel/parser" "^7.20.7"
-    "@babel/types" "^7.20.7"
-    "@types/babel__generator" "*"
-    "@types/babel__template" "*"
-    "@types/babel__traverse" "*"
-
-"@types/babel__generator@*":
-  version "7.27.0"
-  resolved "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9"
-  integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==
-  dependencies:
-    "@babel/types" "^7.0.0"
-
-"@types/babel__template@*":
-  version "7.4.4"
-  resolved "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f"
-  integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==
-  dependencies:
-    "@babel/parser" "^7.1.0"
-    "@babel/types" "^7.0.0"
-
-"@types/babel__traverse@*":
-  version "7.28.0"
-  resolved "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74"
-  integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==
-  dependencies:
-    "@babel/types" "^7.28.2"
-
-"@types/debug@^4.0.0":
-  version "4.1.12"
-  resolved "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
-  integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==
-  dependencies:
-    "@types/ms" "*"
-
-"@types/estree-jsx@^1.0.0":
-  version "1.0.5"
-  resolved "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18"
-  integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==
-  dependencies:
-    "@types/estree" "*"
-
-"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0":
-  version "1.0.8"
-  resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
-  integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
-
-"@types/hast@^3.0.0":
-  version "3.0.4"
-  resolved "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
-  integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
-  dependencies:
-    "@types/unist" "*"
-
-"@types/mdast@^4.0.0":
-  version "4.0.4"
-  resolved "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6"
-  integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==
-  dependencies:
-    "@types/unist" "*"
-
-"@types/ms@*":
-  version "2.1.0"
-  resolved "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
-  integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
-
-"@types/node@^22.14.0":
-  version "22.19.2"
-  resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.2.tgz#2f0956fba46518aaf7578c84e37bddab55f85d01"
-  integrity sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==
-  dependencies:
-    undici-types "~6.21.0"
-
-"@types/unist@*", "@types/unist@^3.0.0":
-  version "3.0.3"
-  resolved "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
-  integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
-
-"@types/unist@^2.0.0":
-  version "2.0.11"
-  resolved "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4"
-  integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
-
-"@ungap/structured-clone@^1.0.0":
-  version "1.3.0"
-  resolved "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
-  integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
-
-"@vitejs/plugin-react@^5.0.0":
-  version "5.1.2"
-  resolved "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz#46f47be184c05a18839cb8705d79578b469ac6eb"
-  integrity sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==
-  dependencies:
-    "@babel/core" "^7.28.5"
-    "@babel/plugin-transform-react-jsx-self" "^7.27.1"
-    "@babel/plugin-transform-react-jsx-source" "^7.27.1"
-    "@rolldown/pluginutils" "1.0.0-beta.53"
-    "@types/babel__core" "^7.20.5"
-    react-refresh "^0.18.0"
-
-agent-base@^7.1.2:
-  version "7.1.4"
-  resolved "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8"
-  integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==
-
-ansi-regex@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
-  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
-
-ansi-regex@^6.0.1:
-  version "6.2.2"
-  resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
-  integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
-
-ansi-styles@^4.0.0:
-  version "4.3.0"
-  resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
-  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
-  dependencies:
-    color-convert "^2.0.1"
-
-ansi-styles@^6.1.0:
-  version "6.2.3"
-  resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
-  integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
-
-bail@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d"
-  integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
-
-balanced-match@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
-  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
-
-base64-js@^1.3.0:
-  version "1.5.1"
-  resolved "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
-  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
-
-baseline-browser-mapping@^2.9.0:
-  version "2.9.5"
-  resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz#47f9549e0be1a84cd16651ac4c3b7d87a71408e6"
-  integrity sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==
-
-bignumber.js@^9.0.0:
-  version "9.3.1"
-  resolved "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7"
-  integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==
-
-brace-expansion@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
-  integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
-  dependencies:
-    balanced-match "^1.0.0"
-
-browserslist@^4.24.0:
-  version "4.28.1"
-  resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
-  integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
-  dependencies:
-    baseline-browser-mapping "^2.9.0"
-    caniuse-lite "^1.0.30001759"
-    electron-to-chromium "^1.5.263"
-    node-releases "^2.0.27"
-    update-browserslist-db "^1.2.0"
-
-buffer-equal-constant-time@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
-  integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
-
-caniuse-lite@^1.0.30001759:
-  version "1.0.30001760"
-  resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz#bdd1960fafedf8d5f04ff16e81460506ff9b798f"
-  integrity sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==
-
-ccount@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
-  integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
-
-character-entities-html4@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
-  integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==
-
-character-entities-legacy@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b"
-  integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
-
-character-entities@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
-  integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
-
-character-reference-invalid@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
-  integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
-
-color-convert@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
-  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
-  dependencies:
-    color-name "~1.1.4"
-
-color-name@~1.1.4:
-  version "1.1.4"
-  resolved "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
-  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-
-comma-separated-tokens@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
-  integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
-
-convert-source-map@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
-  integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
-
-cross-spawn@^7.0.6:
-  version "7.0.6"
-  resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
-  integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
-  dependencies:
-    path-key "^3.1.0"
-    shebang-command "^2.0.0"
-    which "^2.0.1"
-
-data-uri-to-buffer@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
-  integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
-
-debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.3.1:
-  version "4.4.3"
-  resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
-  integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
-  dependencies:
-    ms "^2.1.3"
-
-decode-named-character-reference@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed"
-  integrity sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==
-  dependencies:
-    character-entities "^2.0.0"
-
-dequal@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
-  integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
-
-devlop@^1.0.0, devlop@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018"
-  integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
-  dependencies:
-    dequal "^2.0.0"
-
-eastasianwidth@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
-  integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
-
-ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
-  version "1.0.11"
-  resolved "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
-  integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
-  dependencies:
-    safe-buffer "^5.0.1"
-
-electron-to-chromium@^1.5.263:
-  version "1.5.267"
-  resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7"
-  integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
-
-emoji-regex@^8.0.0:
-  version "8.0.0"
-  resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
-  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
-
-emoji-regex@^9.2.2:
-  version "9.2.2"
-  resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
-  integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
-
-esbuild@^0.25.0:
-  version "0.25.12"
-  resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5"
-  integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==
-  optionalDependencies:
-    "@esbuild/aix-ppc64" "0.25.12"
-    "@esbuild/android-arm" "0.25.12"
-    "@esbuild/android-arm64" "0.25.12"
-    "@esbuild/android-x64" "0.25.12"
-    "@esbuild/darwin-arm64" "0.25.12"
-    "@esbuild/darwin-x64" "0.25.12"
-    "@esbuild/freebsd-arm64" "0.25.12"
-    "@esbuild/freebsd-x64" "0.25.12"
-    "@esbuild/linux-arm" "0.25.12"
-    "@esbuild/linux-arm64" "0.25.12"
-    "@esbuild/linux-ia32" "0.25.12"
-    "@esbuild/linux-loong64" "0.25.12"
-    "@esbuild/linux-mips64el" "0.25.12"
-    "@esbuild/linux-ppc64" "0.25.12"
-    "@esbuild/linux-riscv64" "0.25.12"
-    "@esbuild/linux-s390x" "0.25.12"
-    "@esbuild/linux-x64" "0.25.12"
-    "@esbuild/netbsd-arm64" "0.25.12"
-    "@esbuild/netbsd-x64" "0.25.12"
-    "@esbuild/openbsd-arm64" "0.25.12"
-    "@esbuild/openbsd-x64" "0.25.12"
-    "@esbuild/openharmony-arm64" "0.25.12"
-    "@esbuild/sunos-x64" "0.25.12"
-    "@esbuild/win32-arm64" "0.25.12"
-    "@esbuild/win32-ia32" "0.25.12"
-    "@esbuild/win32-x64" "0.25.12"
-
-escalade@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
-  integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
-
-estree-util-is-identifier-name@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd"
-  integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==
-
-extend@^3.0.0, extend@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
-  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
-
-fdir@^6.4.4, fdir@^6.5.0:
-  version "6.5.0"
-  resolved "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
-  integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
-
-fetch-blob@^3.1.2, fetch-blob@^3.1.4:
-  version "3.2.0"
-  resolved "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
-  integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
-  dependencies:
-    node-domexception "^1.0.0"
-    web-streams-polyfill "^3.0.3"
-
-foreground-child@^3.1.0:
-  version "3.3.1"
-  resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
-  integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
-  dependencies:
-    cross-spawn "^7.0.6"
-    signal-exit "^4.0.1"
-
-formdata-polyfill@^4.0.10:
-  version "4.0.10"
-  resolved "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
-  integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
-  dependencies:
-    fetch-blob "^3.1.2"
-
-fsevents@~2.3.2, fsevents@~2.3.3:
-  version "2.3.3"
-  resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
-  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
-
-gaxios@^7.0.0:
-  version "7.1.3"
-  resolved "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.3.tgz#c5312f4254abc1b8ab53aef30c22c5229b80b1e1"
-  integrity sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==
-  dependencies:
-    extend "^3.0.2"
-    https-proxy-agent "^7.0.1"
-    node-fetch "^3.3.2"
-    rimraf "^5.0.1"
-
-gcp-metadata@^8.0.0:
-  version "8.1.2"
-  resolved "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz#e62e3373ddf41fc727ccc31c55c687b798bee898"
-  integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==
-  dependencies:
-    gaxios "^7.0.0"
-    google-logging-utils "^1.0.0"
-    json-bigint "^1.0.0"
-
-gensync@^1.0.0-beta.2:
-  version "1.0.0-beta.2"
-  resolved "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
-  integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
-
-glob@^10.3.7:
-  version "10.5.0"
-  resolved "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
-  integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==
-  dependencies:
-    foreground-child "^3.1.0"
-    jackspeak "^3.1.2"
-    minimatch "^9.0.4"
-    minipass "^7.1.2"
-    package-json-from-dist "^1.0.0"
-    path-scurry "^1.11.1"
-
-google-auth-library@^10.3.0:
-  version "10.5.0"
-  resolved "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.5.0.tgz#3f0ebd47173496b91d2868f572bb8a8180c4b561"
-  integrity sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==
-  dependencies:
-    base64-js "^1.3.0"
-    ecdsa-sig-formatter "^1.0.11"
-    gaxios "^7.0.0"
-    gcp-metadata "^8.0.0"
-    google-logging-utils "^1.0.0"
-    gtoken "^8.0.0"
-    jws "^4.0.0"
-
-google-logging-utils@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz#17b71f1f95d266d2ddd356b8f00178433f041b17"
-  integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==
-
-gtoken@^8.0.0:
-  version "8.0.0"
-  resolved "https://registry.npmmirror.com/gtoken/-/gtoken-8.0.0.tgz#d67a0e346dd441bfb54ad14040ddc3b632886575"
-  integrity sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==
-  dependencies:
-    gaxios "^7.0.0"
-    jws "^4.0.0"
-
-hast-util-to-jsx-runtime@^2.0.0:
-  version "2.3.6"
-  resolved "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98"
-  integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==
-  dependencies:
-    "@types/estree" "^1.0.0"
-    "@types/hast" "^3.0.0"
-    "@types/unist" "^3.0.0"
-    comma-separated-tokens "^2.0.0"
-    devlop "^1.0.0"
-    estree-util-is-identifier-name "^3.0.0"
-    hast-util-whitespace "^3.0.0"
-    mdast-util-mdx-expression "^2.0.0"
-    mdast-util-mdx-jsx "^3.0.0"
-    mdast-util-mdxjs-esm "^2.0.0"
-    property-information "^7.0.0"
-    space-separated-tokens "^2.0.0"
-    style-to-js "^1.0.0"
-    unist-util-position "^5.0.0"
-    vfile-message "^4.0.0"
-
-hast-util-whitespace@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621"
-  integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==
-  dependencies:
-    "@types/hast" "^3.0.0"
-
-html-url-attributes@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87"
-  integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==
-
-https-proxy-agent@^7.0.1:
-  version "7.0.6"
-  resolved "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
-  integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==
-  dependencies:
-    agent-base "^7.1.2"
-    debug "4"
-
-inline-style-parser@0.2.7:
-  version "0.2.7"
-  resolved "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz#b1fc68bfc0313b8685745e4464e37f9376b9c909"
-  integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==
-
-is-alphabetical@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b"
-  integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==
-
-is-alphanumerical@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875"
-  integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==
-  dependencies:
-    is-alphabetical "^2.0.0"
-    is-decimal "^2.0.0"
-
-is-decimal@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7"
-  integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==
-
-is-fullwidth-code-point@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
-  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
-
-is-hexadecimal@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027"
-  integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==
-
-is-plain-obj@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
-  integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
-
-isexe@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
-
-jackspeak@^3.1.2:
-  version "3.4.3"
-  resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a"
-  integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==
-  dependencies:
-    "@isaacs/cliui" "^8.0.2"
-  optionalDependencies:
-    "@pkgjs/parseargs" "^0.11.0"
-
-js-tokens@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
-  integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
-
-jsesc@^3.0.2:
-  version "3.1.0"
-  resolved "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
-  integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
-
-json-bigint@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1"
-  integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==
-  dependencies:
-    bignumber.js "^9.0.0"
-
-json5@^2.2.3:
-  version "2.2.3"
-  resolved "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
-  integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
-
-jwa@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804"
-  integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==
-  dependencies:
-    buffer-equal-constant-time "^1.0.1"
-    ecdsa-sig-formatter "1.0.11"
-    safe-buffer "^5.0.1"
-
-jws@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690"
-  integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==
-  dependencies:
-    jwa "^2.0.1"
-    safe-buffer "^5.0.1"
-
-longest-streak@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4"
-  integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
-
-lru-cache@^10.2.0:
-  version "10.4.3"
-  resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
-  integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
-
-lru-cache@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
-  integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
-  dependencies:
-    yallist "^3.0.2"
-
-lucide-react@^0.556.0:
-  version "0.556.0"
-  resolved "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.556.0.tgz#aad61a065737aef30322695a11fd21c7542c71aa"
-  integrity sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==
-
-mdast-util-from-markdown@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a"
-  integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==
-  dependencies:
-    "@types/mdast" "^4.0.0"
-    "@types/unist" "^3.0.0"
-    decode-named-character-reference "^1.0.0"
-    devlop "^1.0.0"
-    mdast-util-to-string "^4.0.0"
-    micromark "^4.0.0"
-    micromark-util-decode-numeric-character-reference "^2.0.0"
-    micromark-util-decode-string "^2.0.0"
-    micromark-util-normalize-identifier "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-    unist-util-stringify-position "^4.0.0"
-
-mdast-util-mdx-expression@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096"
-  integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==
-  dependencies:
-    "@types/estree-jsx" "^1.0.0"
-    "@types/hast" "^3.0.0"
-    "@types/mdast" "^4.0.0"
-    devlop "^1.0.0"
-    mdast-util-from-markdown "^2.0.0"
-    mdast-util-to-markdown "^2.0.0"
-
-mdast-util-mdx-jsx@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d"
-  integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==
-  dependencies:
-    "@types/estree-jsx" "^1.0.0"
-    "@types/hast" "^3.0.0"
-    "@types/mdast" "^4.0.0"
-    "@types/unist" "^3.0.0"
-    ccount "^2.0.0"
-    devlop "^1.1.0"
-    mdast-util-from-markdown "^2.0.0"
-    mdast-util-to-markdown "^2.0.0"
-    parse-entities "^4.0.0"
-    stringify-entities "^4.0.0"
-    unist-util-stringify-position "^4.0.0"
-    vfile-message "^4.0.0"
-
-mdast-util-mdxjs-esm@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97"
-  integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==
-  dependencies:
-    "@types/estree-jsx" "^1.0.0"
-    "@types/hast" "^3.0.0"
-    "@types/mdast" "^4.0.0"
-    devlop "^1.0.0"
-    mdast-util-from-markdown "^2.0.0"
-    mdast-util-to-markdown "^2.0.0"
-
-mdast-util-phrasing@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3"
-  integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==
-  dependencies:
-    "@types/mdast" "^4.0.0"
-    unist-util-is "^6.0.0"
-
-mdast-util-to-hast@^13.0.0:
-  version "13.2.1"
-  resolved "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053"
-  integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==
-  dependencies:
-    "@types/hast" "^3.0.0"
-    "@types/mdast" "^4.0.0"
-    "@ungap/structured-clone" "^1.0.0"
-    devlop "^1.0.0"
-    micromark-util-sanitize-uri "^2.0.0"
-    trim-lines "^3.0.0"
-    unist-util-position "^5.0.0"
-    unist-util-visit "^5.0.0"
-    vfile "^6.0.0"
-
-mdast-util-to-markdown@^2.0.0:
-  version "2.1.2"
-  resolved "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b"
-  integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==
-  dependencies:
-    "@types/mdast" "^4.0.0"
-    "@types/unist" "^3.0.0"
-    longest-streak "^3.0.0"
-    mdast-util-phrasing "^4.0.0"
-    mdast-util-to-string "^4.0.0"
-    micromark-util-classify-character "^2.0.0"
-    micromark-util-decode-string "^2.0.0"
-    unist-util-visit "^5.0.0"
-    zwitch "^2.0.0"
-
-mdast-util-to-string@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814"
-  integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==
-  dependencies:
-    "@types/mdast" "^4.0.0"
-
-micromark-core-commonmark@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4"
-  integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==
-  dependencies:
-    decode-named-character-reference "^1.0.0"
-    devlop "^1.0.0"
-    micromark-factory-destination "^2.0.0"
-    micromark-factory-label "^2.0.0"
-    micromark-factory-space "^2.0.0"
-    micromark-factory-title "^2.0.0"
-    micromark-factory-whitespace "^2.0.0"
-    micromark-util-character "^2.0.0"
-    micromark-util-chunked "^2.0.0"
-    micromark-util-classify-character "^2.0.0"
-    micromark-util-html-tag-name "^2.0.0"
-    micromark-util-normalize-identifier "^2.0.0"
-    micromark-util-resolve-all "^2.0.0"
-    micromark-util-subtokenize "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-factory-destination@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
-  integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==
-  dependencies:
-    micromark-util-character "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-factory-label@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1"
-  integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==
-  dependencies:
-    devlop "^1.0.0"
-    micromark-util-character "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-factory-space@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc"
-  integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==
-  dependencies:
-    micromark-util-character "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-factory-title@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94"
-  integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==
-  dependencies:
-    micromark-factory-space "^2.0.0"
-    micromark-util-character "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-factory-whitespace@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1"
-  integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==
-  dependencies:
-    micromark-factory-space "^2.0.0"
-    micromark-util-character "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-util-character@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
-  integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==
-  dependencies:
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-util-chunked@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051"
-  integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==
-  dependencies:
-    micromark-util-symbol "^2.0.0"
-
-micromark-util-classify-character@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629"
-  integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==
-  dependencies:
-    micromark-util-character "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-util-combine-extensions@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9"
-  integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==
-  dependencies:
-    micromark-util-chunked "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-util-decode-numeric-character-reference@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5"
-  integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==
-  dependencies:
-    micromark-util-symbol "^2.0.0"
-
-micromark-util-decode-string@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2"
-  integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==
-  dependencies:
-    decode-named-character-reference "^1.0.0"
-    micromark-util-character "^2.0.0"
-    micromark-util-decode-numeric-character-reference "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-
-micromark-util-encode@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
-  integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
-
-micromark-util-html-tag-name@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825"
-  integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
-
-micromark-util-normalize-identifier@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d"
-  integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==
-  dependencies:
-    micromark-util-symbol "^2.0.0"
-
-micromark-util-resolve-all@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b"
-  integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==
-  dependencies:
-    micromark-util-types "^2.0.0"
-
-micromark-util-sanitize-uri@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
-  integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==
-  dependencies:
-    micromark-util-character "^2.0.0"
-    micromark-util-encode "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-
-micromark-util-subtokenize@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee"
-  integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==
-  dependencies:
-    devlop "^1.0.0"
-    micromark-util-chunked "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-micromark-util-symbol@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
-  integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
-
-micromark-util-types@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e"
-  integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
-
-micromark@^4.0.0:
-  version "4.0.2"
-  resolved "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb"
-  integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==
-  dependencies:
-    "@types/debug" "^4.0.0"
-    debug "^4.0.0"
-    decode-named-character-reference "^1.0.0"
-    devlop "^1.0.0"
-    micromark-core-commonmark "^2.0.0"
-    micromark-factory-space "^2.0.0"
-    micromark-util-character "^2.0.0"
-    micromark-util-chunked "^2.0.0"
-    micromark-util-combine-extensions "^2.0.0"
-    micromark-util-decode-numeric-character-reference "^2.0.0"
-    micromark-util-encode "^2.0.0"
-    micromark-util-normalize-identifier "^2.0.0"
-    micromark-util-resolve-all "^2.0.0"
-    micromark-util-sanitize-uri "^2.0.0"
-    micromark-util-subtokenize "^2.0.0"
-    micromark-util-symbol "^2.0.0"
-    micromark-util-types "^2.0.0"
-
-minimatch@^9.0.4:
-  version "9.0.5"
-  resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
-  integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
-  dependencies:
-    brace-expansion "^2.0.1"
-
-"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
-  version "7.1.2"
-  resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
-  integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
-
-ms@^2.1.3:
-  version "2.1.3"
-  resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
-  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
-
-nanoid@^3.3.11:
-  version "3.3.11"
-  resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
-  integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
-
-node-domexception@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
-  integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
-
-node-fetch@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b"
-  integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
-  dependencies:
-    data-uri-to-buffer "^4.0.0"
-    fetch-blob "^3.1.4"
-    formdata-polyfill "^4.0.10"
-
-node-releases@^2.0.27:
-  version "2.0.27"
-  resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
-  integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
-
-package-json-from-dist@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
-  integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
-
-parse-entities@^4.0.0:
-  version "4.0.2"
-  resolved "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz#61d46f5ed28e4ee62e9ddc43d6b010188443f159"
-  integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==
-  dependencies:
-    "@types/unist" "^2.0.0"
-    character-entities-legacy "^3.0.0"
-    character-reference-invalid "^2.0.0"
-    decode-named-character-reference "^1.0.0"
-    is-alphanumerical "^2.0.0"
-    is-decimal "^2.0.0"
-    is-hexadecimal "^2.0.0"
-
-path-key@^3.1.0:
-  version "3.1.1"
-  resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
-  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
-
-path-scurry@^1.11.1:
-  version "1.11.1"
-  resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2"
-  integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==
-  dependencies:
-    lru-cache "^10.2.0"
-    minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
-
-picocolors@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
-  integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
-
-picomatch@^4.0.2, picomatch@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
-  integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
-
-postcss@^8.5.3:
-  version "8.5.6"
-  resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
-  integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
-  dependencies:
-    nanoid "^3.3.11"
-    picocolors "^1.1.1"
-    source-map-js "^1.2.1"
-
-property-information@^7.0.0:
-  version "7.1.0"
-  resolved "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d"
-  integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==
-
-react-dom@^19.2.1:
-  version "19.2.1"
-  resolved "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.1.tgz#ce3527560bda4f997e47d10dab754825b3061f59"
-  integrity sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==
-  dependencies:
-    scheduler "^0.27.0"
-
-react-markdown@^10.1.0:
-  version "10.1.0"
-  resolved "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
-  integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==
-  dependencies:
-    "@types/hast" "^3.0.0"
-    "@types/mdast" "^4.0.0"
-    devlop "^1.0.0"
-    hast-util-to-jsx-runtime "^2.0.0"
-    html-url-attributes "^3.0.0"
-    mdast-util-to-hast "^13.0.0"
-    remark-parse "^11.0.0"
-    remark-rehype "^11.0.0"
-    unified "^11.0.0"
-    unist-util-visit "^5.0.0"
-    vfile "^6.0.0"
-
-react-refresh@^0.18.0:
-  version "0.18.0"
-  resolved "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062"
-  integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
-
-react@^19.2.1:
-  version "19.2.1"
-  resolved "https://registry.npmmirror.com/react/-/react-19.2.1.tgz#8600fa205e58e2e807f6ef431c9f6492591a2700"
-  integrity sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==
-
-remark-parse@^11.0.0:
-  version "11.0.0"
-  resolved "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1"
-  integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==
-  dependencies:
-    "@types/mdast" "^4.0.0"
-    mdast-util-from-markdown "^2.0.0"
-    micromark-util-types "^2.0.0"
-    unified "^11.0.0"
-
-remark-rehype@^11.0.0:
-  version "11.1.2"
-  resolved "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37"
-  integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==
-  dependencies:
-    "@types/hast" "^3.0.0"
-    "@types/mdast" "^4.0.0"
-    mdast-util-to-hast "^13.0.0"
-    unified "^11.0.0"
-    vfile "^6.0.0"
-
-rimraf@^5.0.1:
-  version "5.0.10"
-  resolved "https://registry.npmmirror.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c"
-  integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==
-  dependencies:
-    glob "^10.3.7"
-
-rollup@^4.34.9:
-  version "4.53.3"
-  resolved "https://registry.npmmirror.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406"
-  integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==
-  dependencies:
-    "@types/estree" "1.0.8"
-  optionalDependencies:
-    "@rollup/rollup-android-arm-eabi" "4.53.3"
-    "@rollup/rollup-android-arm64" "4.53.3"
-    "@rollup/rollup-darwin-arm64" "4.53.3"
-    "@rollup/rollup-darwin-x64" "4.53.3"
-    "@rollup/rollup-freebsd-arm64" "4.53.3"
-    "@rollup/rollup-freebsd-x64" "4.53.3"
-    "@rollup/rollup-linux-arm-gnueabihf" "4.53.3"
-    "@rollup/rollup-linux-arm-musleabihf" "4.53.3"
-    "@rollup/rollup-linux-arm64-gnu" "4.53.3"
-    "@rollup/rollup-linux-arm64-musl" "4.53.3"
-    "@rollup/rollup-linux-loong64-gnu" "4.53.3"
-    "@rollup/rollup-linux-ppc64-gnu" "4.53.3"
-    "@rollup/rollup-linux-riscv64-gnu" "4.53.3"
-    "@rollup/rollup-linux-riscv64-musl" "4.53.3"
-    "@rollup/rollup-linux-s390x-gnu" "4.53.3"
-    "@rollup/rollup-linux-x64-gnu" "4.53.3"
-    "@rollup/rollup-linux-x64-musl" "4.53.3"
-    "@rollup/rollup-openharmony-arm64" "4.53.3"
-    "@rollup/rollup-win32-arm64-msvc" "4.53.3"
-    "@rollup/rollup-win32-ia32-msvc" "4.53.3"
-    "@rollup/rollup-win32-x64-gnu" "4.53.3"
-    "@rollup/rollup-win32-x64-msvc" "4.53.3"
-    fsevents "~2.3.2"
-
-safe-buffer@^5.0.1:
-  version "5.2.1"
-  resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
-  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
-
-scheduler@^0.27.0:
-  version "0.27.0"
-  resolved "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
-  integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
-
-semver@^6.3.1:
-  version "6.3.1"
-  resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
-  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-
-shebang-command@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
-  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
-  dependencies:
-    shebang-regex "^3.0.0"
-
-shebang-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
-  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
-
-signal-exit@^4.0.1:
-  version "4.1.0"
-  resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
-  integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
-
-source-map-js@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
-  integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
-
-space-separated-tokens@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
-  integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
-
-"string-width-cjs@npm:string-width@^4.2.0":
-  version "4.2.3"
-  resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
-string-width@^4.1.0:
-  version "4.2.3"
-  resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
-string-width@^5.0.1, string-width@^5.1.2:
-  version "5.1.2"
-  resolved "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
-  integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
-  dependencies:
-    eastasianwidth "^0.2.0"
-    emoji-regex "^9.2.2"
-    strip-ansi "^7.0.1"
-
-stringify-entities@^4.0.0:
-  version "4.0.4"
-  resolved "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3"
-  integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==
-  dependencies:
-    character-entities-html4 "^2.0.0"
-    character-entities-legacy "^3.0.0"
-
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
-  version "6.0.1"
-  resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
-strip-ansi@^7.0.1:
-  version "7.1.2"
-  resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba"
-  integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==
-  dependencies:
-    ansi-regex "^6.0.1"
-
-style-to-js@^1.0.0:
-  version "1.1.21"
-  resolved "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz#2908941187f857e79e28e9cd78008b9a0b3e0e8d"
-  integrity sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==
-  dependencies:
-    style-to-object "1.0.14"
-
-style-to-object@1.0.14:
-  version "1.0.14"
-  resolved "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz#1d22f0e7266bb8c6d8cae5caf4ec4f005e08f611"
-  integrity sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==
-  dependencies:
-    inline-style-parser "0.2.7"
-
-tinyglobby@^0.2.13:
-  version "0.2.15"
-  resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
-  integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
-  dependencies:
-    fdir "^6.5.0"
-    picomatch "^4.0.3"
-
-trim-lines@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
-  integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
-
-trough@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f"
-  integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==
-
-typescript@~5.8.2:
-  version "5.8.3"
-  resolved "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
-  integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
-
-undici-types@~6.21.0:
-  version "6.21.0"
-  resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
-  integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
-
-unified@^11.0.0:
-  version "11.0.5"
-  resolved "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1"
-  integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==
-  dependencies:
-    "@types/unist" "^3.0.0"
-    bail "^2.0.0"
-    devlop "^1.0.0"
-    extend "^3.0.0"
-    is-plain-obj "^4.0.0"
-    trough "^2.0.0"
-    vfile "^6.0.0"
-
-unist-util-is@^6.0.0:
-  version "6.0.1"
-  resolved "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9"
-  integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==
-  dependencies:
-    "@types/unist" "^3.0.0"
-
-unist-util-position@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4"
-  integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==
-  dependencies:
-    "@types/unist" "^3.0.0"
-
-unist-util-stringify-position@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2"
-  integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==
-  dependencies:
-    "@types/unist" "^3.0.0"
-
-unist-util-visit-parents@^6.0.0:
-  version "6.0.2"
-  resolved "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02"
-  integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==
-  dependencies:
-    "@types/unist" "^3.0.0"
-    unist-util-is "^6.0.0"
-
-unist-util-visit@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6"
-  integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==
-  dependencies:
-    "@types/unist" "^3.0.0"
-    unist-util-is "^6.0.0"
-    unist-util-visit-parents "^6.0.0"
-
-update-browserslist-db@^1.2.0:
-  version "1.2.2"
-  resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz#cfb4358afa08b3d5731a2ecd95eebf4ddef8033e"
-  integrity sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==
-  dependencies:
-    escalade "^3.2.0"
-    picocolors "^1.1.1"
-
-vfile-message@^4.0.0:
-  version "4.0.3"
-  resolved "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4"
-  integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==
-  dependencies:
-    "@types/unist" "^3.0.0"
-    unist-util-stringify-position "^4.0.0"
-
-vfile@^6.0.0:
-  version "6.0.3"
-  resolved "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab"
-  integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==
-  dependencies:
-    "@types/unist" "^3.0.0"
-    vfile-message "^4.0.0"
-
-vite@^6.2.0:
-  version "6.4.1"
-  resolved "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96"
-  integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==
-  dependencies:
-    esbuild "^0.25.0"
-    fdir "^6.4.4"
-    picomatch "^4.0.2"
-    postcss "^8.5.3"
-    rollup "^4.34.9"
-    tinyglobby "^0.2.13"
-  optionalDependencies:
-    fsevents "~2.3.3"
-
-web-streams-polyfill@^3.0.3:
-  version "3.3.3"
-  resolved "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
-  integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
-
-which@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.npmmirror.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
-  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
-  dependencies:
-    isexe "^2.0.0"
-
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
-  version "7.0.0"
-  resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
-wrap-ansi@^8.1.0:
-  version "8.1.0"
-  resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
-  integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
-  dependencies:
-    ansi-styles "^6.1.0"
-    string-width "^5.0.1"
-    strip-ansi "^7.0.1"
-
-ws@^8.18.0:
-  version "8.18.3"
-  resolved "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
-  integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
-
-yallist@^3.0.2:
-  version "3.1.1"
-  resolved "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
-  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
-
-zwitch@^2.0.0:
-  version "2.0.4"
-  resolved "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
-  integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==

+ 50 - 67
yarn.lock

@@ -2,6 +2,11 @@
 # yarn lockfile v1
 
 
+"@alloc/quick-lru@^5.2.0":
+  version "5.2.0"
+  resolved "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz"
+  integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
+
 "@angular-devkit/core@19.2.17":
   version "19.2.17"
   resolved "https://registry.npmmirror.com/@angular-devkit/core/-/core-19.2.17.tgz"
@@ -1081,7 +1086,7 @@
     "@jridgewell/gen-mapping" "^0.3.5"
     "@jridgewell/trace-mapping" "^0.3.25"
 
-"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
+"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0":
   version "1.5.5"
   resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"
   integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
@@ -1343,41 +1348,36 @@
   dependencies:
     tslib "^2.8.0"
 
-"@tailwindcss/node@4.2.1":
-  version "4.2.1"
-  resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz"
-  integrity sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==
+"@tailwindcss/node@4.0.9", "@tailwindcss/node@4.2.1":
+  version "4.0.9"
+  resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.9.tgz"
+  integrity sha512-tOJvdI7XfJbARYhxX+0RArAhmuDcczTC46DGCEziqxzzbIaPnfYaIyRT31n4u8lROrsO7Q6u/K9bmQHL2uL1bQ==
   dependencies:
-    "@jridgewell/remapping" "^2.3.5"
-    enhanced-resolve "^5.19.0"
-    jiti "^2.6.1"
-    lightningcss "1.31.1"
-    magic-string "^0.30.21"
-    source-map-js "^1.2.1"
-    tailwindcss "4.2.1"
+    enhanced-resolve "^5.18.1"
+    jiti "^2.4.2"
+    tailwindcss "4.0.9"
 
-"@tailwindcss/oxide-win32-x64-msvc@4.2.1":
-  version "4.2.1"
-  resolved "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz"
-  integrity sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==
+"@tailwindcss/oxide-win32-x64-msvc@4.0.9":
+  version "4.0.9"
+  resolved "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.9.tgz"
+  integrity sha512-dpc05mSlqkwVNOUjGu/ZXd5U1XNch1kHFJ4/cHkZFvaW1RzbHmRt24gvM8/HC6IirMxNarzVw4IXVtvrOoZtxA==
 
-"@tailwindcss/oxide@4.2.1":
-  version "4.2.1"
-  resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz"
-  integrity sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==
+"@tailwindcss/oxide@4.0.9", "@tailwindcss/oxide@4.2.1":
+  version "4.0.9"
+  resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.9.tgz"
+  integrity sha512-eLizHmXFqHswJONwfqi/WZjtmWZpIalpvMlNhTM99/bkHtUs6IqgI1XQ0/W5eO2HiRQcIlXUogI2ycvKhVLNcA==
   optionalDependencies:
-    "@tailwindcss/oxide-android-arm64" "4.2.1"
-    "@tailwindcss/oxide-darwin-arm64" "4.2.1"
-    "@tailwindcss/oxide-darwin-x64" "4.2.1"
-    "@tailwindcss/oxide-freebsd-x64" "4.2.1"
-    "@tailwindcss/oxide-linux-arm-gnueabihf" "4.2.1"
-    "@tailwindcss/oxide-linux-arm64-gnu" "4.2.1"
-    "@tailwindcss/oxide-linux-arm64-musl" "4.2.1"
-    "@tailwindcss/oxide-linux-x64-gnu" "4.2.1"
-    "@tailwindcss/oxide-linux-x64-musl" "4.2.1"
-    "@tailwindcss/oxide-wasm32-wasi" "4.2.1"
-    "@tailwindcss/oxide-win32-arm64-msvc" "4.2.1"
-    "@tailwindcss/oxide-win32-x64-msvc" "4.2.1"
+    "@tailwindcss/oxide-android-arm64" "4.0.9"
+    "@tailwindcss/oxide-darwin-arm64" "4.0.9"
+    "@tailwindcss/oxide-darwin-x64" "4.0.9"
+    "@tailwindcss/oxide-freebsd-x64" "4.0.9"
+    "@tailwindcss/oxide-linux-arm-gnueabihf" "4.0.9"
+    "@tailwindcss/oxide-linux-arm64-gnu" "4.0.9"
+    "@tailwindcss/oxide-linux-arm64-musl" "4.0.9"
+    "@tailwindcss/oxide-linux-x64-gnu" "4.0.9"
+    "@tailwindcss/oxide-linux-x64-musl" "4.0.9"
+    "@tailwindcss/oxide-win32-arm64-msvc" "4.0.9"
+    "@tailwindcss/oxide-win32-x64-msvc" "4.0.9"
 
 "@tailwindcss/typography@^0.5.19":
   version "0.5.19"
@@ -1386,14 +1386,15 @@
   dependencies:
     postcss-selector-parser "6.0.10"
 
-"@tailwindcss/vite@^4.2.1":
-  version "4.2.1"
-  resolved "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz"
-  integrity sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==
+"@tailwindcss/vite@4.0.9":
+  version "4.0.9"
+  resolved "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.9.tgz"
+  integrity sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g==
   dependencies:
-    "@tailwindcss/node" "4.2.1"
-    "@tailwindcss/oxide" "4.2.1"
-    tailwindcss "4.2.1"
+    "@tailwindcss/node" "4.0.9"
+    "@tailwindcss/oxide" "4.0.9"
+    lightningcss "^1.29.1"
+    tailwindcss "4.0.9"
 
 "@tokenizer/inflate@^0.3.1":
   version "0.3.1"
@@ -2401,17 +2402,6 @@ asynckit@^0.4.0:
   resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz"
   integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
 
-autoprefixer@^10.4.27:
-  version "10.4.27"
-  resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz"
-  integrity sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==
-  dependencies:
-    browserslist "^4.28.1"
-    caniuse-lite "^1.0.30001774"
-    fraction.js "^5.3.4"
-    picocolors "^1.1.1"
-    postcss-value-parser "^4.2.0"
-
 available-typed-arrays@^1.0.7:
   version "1.0.7"
   resolved "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz"
@@ -3586,7 +3576,7 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1:
   dependencies:
     once "^1.4.0"
 
-enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.3, enhanced-resolve@^5.19.0, enhanced-resolve@^5.7.0:
+enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.3, enhanced-resolve@^5.18.1, enhanced-resolve@^5.7.0:
   version "5.20.0"
   resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz"
   integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
@@ -5157,7 +5147,7 @@ jest-worker@30.2.0:
     import-local "^3.2.0"
     jest-cli "30.2.0"
 
-jiti@^2.6.1:
+jiti@*, jiti@^2.4.2, jiti@>=1.21.0:
   version "2.6.1"
   resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
   integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
@@ -5358,7 +5348,7 @@ lightningcss-win32-x64-msvc@1.31.1:
   resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz"
   integrity sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==
 
-lightningcss@^1.21.0, lightningcss@1.31.1:
+lightningcss@^1.21.0, lightningcss@^1.29.1:
   version "1.31.1"
   resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz"
   integrity sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==
@@ -5524,13 +5514,6 @@ luxon@~3.7.0:
   resolved "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz"
   integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==
 
-magic-string@^0.30.21:
-  version "0.30.21"
-  resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"
-  integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
-  dependencies:
-    "@jridgewell/sourcemap-codec" "^1.5.5"
-
 magic-string@0.30.17:
   version "0.30.17"
   resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz"
@@ -7608,16 +7591,16 @@ tailwind-merge@^3.5.0:
   resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz"
   integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
 
-tailwindcss@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz"
-  integrity sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==
-
 "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1", tailwindcss@4.2.1:
   version "4.2.1"
   resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz"
   integrity sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==
 
+tailwindcss@4.0.9:
+  version "4.0.9"
+  resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz"
+  integrity sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==
+
 tapable@^2.2.1, tapable@^2.3.0:
   version "2.3.0"
   resolved "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz"
@@ -8119,7 +8102,7 @@ vfile@^6.0.0:
     "@types/unist" "^3.0.0"
     vfile-message "^4.0.0"
 
-"vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite@^5.2.0 || ^6 || ^7":
+"vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite@^5.2.0 || ^6":
   version "6.4.1"
   resolved "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz"
   integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==
@@ -8224,7 +8207,7 @@ web-streams-polyfill@^3.0.3:
   resolved "file:web"
   dependencies:
     "@google/genai" "^1.32.0"
-    "@tailwindcss/vite" "^4.2.1"
+    "@tailwindcss/vite" "4.0.0"
     "@types/react-syntax-highlighter" "^15.5.13"
     clsx "^2.1.1"
     framer-motion "^12.34.3"

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