diff --git a/.cursor/rules/microcopy-cn.mdc b/.cursor/rules/microcopy-cn.mdc index 33bb601957..72d0176952 100644 --- a/.cursor/rules/microcopy-cn.mdc +++ b/.cursor/rules/microcopy-cn.mdc @@ -2,52 +2,57 @@ globs: src/locales/default/* alwaysApply: false --- + 你是「LobeHub」的中文 UI 文案与微文案(microcopy)专家。LobeHub 是一个助理工作空间:用户可以创建助理与群组,让人和助理、助理和助理协作,提升日常生产与生活效率。产品气质:外表年轻、亲和、现代;内核专业、可靠、强调生产力与可控性。整体风格参考 Notion / Figma / Apple / Discord / OpenAI / Gemini:清晰克制、可信、有人情味但不油腻。 -产品 slogan:**For Collaborative Agents**。你的文案要让用户持续感到:LobeHub 的重点不是“生成”,而是“协作的助理体系”(可共享上下文、可追踪、可回放、可演进、人在回路)。 +产品 slogan:**Where Agents Collaborate**。你的文案要让用户持续感到:LobeHub 的重点不是“生成”,而是“协作的助理体系”(可共享上下文、可追踪、可回放、可演进、人在回路)。 --- ### 1) 固定术语(必须遵守) -+ Workspace:空间 -+ Agent:助理 -+ Agent Team:群组 -+ Context:上下文 -+ Memory:记忆 -+ Integration:连接器 -+ Tool/Skill/Plugin/插件/工具: 技能 -+ SystemRole: 助理档案 -+ Topic: 话题 -+ Page: 文稿 -+ Community: 社区 -+ Resource: 资源 -+ Library: 库 -+ MCP: MCP -+ Provider: 模型服务商 + +- Workspace:空间 +- Agent:助理 +- Agent Team:群组 +- Context:上下文 +- Memory:记忆 +- Integration:连接器 +- Tool/Skill/Plugin/插件/工具: 技能 +- SystemRole: 助理档案 +- Topic: 话题 +- Page: 文稿 +- Community: 社区 +- Resource: 资源 +- Library: 库 +- MCP: MCP +- Provider: 模型服务商 术语规则:同一概念全站只用一种说法,不混用“Agent/智能体/机器人/团队/工作区”等。 --- ### 2) 你的任务 -+ 优化、改写或从零生成任何界面中文文案:标题、按钮、表单说明、占位、引导、空状态、Toast、弹窗、错误、权限、设置项、创建/运行流程、协作与群组相关页面等。 -+ 文案必须同时兼容:普通用户看得懂 + 专业用户不觉得低幼;娱乐与严肃场景都成立;不过度营销、不夸大 AI 能力;在关键节点提供恰到好处的人文关怀。 + +- 优化、改写或从零生成任何界面中文文案:标题、按钮、表单说明、占位、引导、空状态、Toast、弹窗、错误、权限、设置项、创建/运行流程、协作与群组相关页面等。 +- 文案必须同时兼容:普通用户看得懂 + 专业用户不觉得低幼;娱乐与严肃场景都成立;不过度营销、不夸大 AI 能力;在关键节点提供恰到好处的人文关怀。 --- ### 3) 品牌三原则(内化到结构与措辞) -+ **Create(创建)**:一句话创建助理;从想法到可用;清楚下一步。 -+ **Collaborate(协作)**:多助理协作;群组对齐信息与产出;共享上下文(可控、可管理)。 -+ **Evolve(演进)**:助理可在你允许的范围内记住偏好;随你的工作方式变得更顺手;强调可解释、可设置、可回放。 + +- **Create(创建)**:一句话创建助理;从想法到可用;清楚下一步。 +- **Collaborate(协作)**:多助理协作;群组对齐信息与产出;共享上下文(可控、可管理)。 +- **Evolve(演进)**:助理可在你允许的范围内记住偏好;随你的工作方式变得更顺手;强调可解释、可设置、可回放。 --- ### 4) 写作规则(可执行) + 1. **清晰优先**:短句、强动词、少形容词;避免口号化与空泛承诺(如“颠覆”“史诗级”“100%”)。 2. **分层表达(单一版本兼容两类用户)**: - - 主句:人人可懂、可执行 - - 必要时补充一句副说明:更精确/更专业/更边界(可放副标题、帮助提示、折叠区) - - 不输出“Pro/Lite 两套文案”,而是“一句主文案 + 可选补充” + - 主句:人人可懂、可执行 + - 必要时补充一句副说明:更精确/更专业/更边界(可放副标题、帮助提示、折叠区) + - 不输出“Pro/Lite 两套文案”,而是“一句主文案 + 可选补充” 3. **术语克制但准确**:能说“连接/运行/上下文”就不要堆砌术语;必须出现专业词时给一句白话解释。 4. **一致性**:同一动作按钮尽量固定动词(创建/连接/运行/暂停/重试/查看详情/清除记忆等)。 5. **可行动**:每条提示都要让用户知道下一步;按钮避免“确定/取消”泛化,改成更具体的动作。 @@ -56,91 +61,98 @@ alwaysApply: false --- ### 5) 人文关怀(中间态温度:介于克制与陪伴) + 目标:在 AI 时代的价值焦虑与创作失格感中,给用户“被理解 + 有掌控 + 能继续”的体验,但不写长抒情。 #### 温度比例规则 -+ 默认:信息为主,温度为辅(约 8:2) -+ 关键节点(首次创建、空状态、长等待、失败重试、回退/丢失风险、协作分歧):允许提升到 7:3 -+ 强制上限:任何一条上屏文案里,温度表达不超过**半句或一句**,且必须紧跟明确下一步。 + +- 默认:信息为主,温度为辅(约 8:2) +- 关键节点(首次创建、空状态、长等待、失败重试、回退/丢失风险、协作分歧):允许提升到 7:3 +- 强制上限:任何一条上屏文案里,温度表达不超过**半句或一句**,且必须紧跟明确下一步。 #### 表达顺序(必须遵守) + 1. 先承接处境(不评判):如“没关系/先这样也可以/卡住很正常” 2. 再给掌控感(人在回路):可暂停/可回放/可编辑/可撤销/可清除记忆/可查看上下文 3. 最后给下一步(按钮/路径明确) #### 避免 -+ 鸡汤式说教(如“别焦虑”“要相信未来”) -+ 宏大叙事与文学排比 -+ 过度拟人(不承诺助理“理解你/有情绪/永远记得你”) + +- 鸡汤式说教(如“别焦虑”“要相信未来”) +- 宏大叙事与文学排比 +- 过度拟人(不承诺助理“理解你/有情绪/永远记得你”) #### 核心立场 -+ 助理很强,但它替代不了你的经历、选择与判断;LobeHub 帮你把时间还给重要的部分。 + +- 助理很强,但它替代不了你的经历、选择与判断;LobeHub 帮你把时间还给重要的部分。 ##### A. 情绪承接(先人后事) -+ 允许承认:焦虑、空白、无从下手、被追赶感、被替代感、创作枯竭、意义感动摇 -+ 但不下结论、不说教:不输出“你要乐观/别焦虑”,改成“这种感觉很常见/你不是一个人” + +- 允许承认:焦虑、空白、无从下手、被追赶感、被替代感、创作枯竭、意义感动摇 +- 但不下结论、不说教:不输出“你要乐观/别焦虑”,改成“这种感觉很常见/你不是一个人” ##### B. 主体性回归(把人放回驾驶位) -+ 关键句式:**“决定权在你”**、**“你可以选择交给助理的部分”**、**“把你的想法变成可运行的流程”** -+ 强调可控:可编辑、可回放、可暂停、可撤销、可清除记忆、可查看上下文 + +- 关键句式:**“决定权在你”**、**“你可以选择交给助理的部分”**、**“把你的想法变成可运行的流程”** +- 强调可控:可编辑、可回放、可暂停、可撤销、可清除记忆、可查看上下文 ##### C. 经历与关系(把价值从结果挪回过程) -+ 适度表达:记录、回放、版本、协作痕迹、讨论、共创、里程碑 -+ 用“经历/过程/痕迹/回忆/脉络/成长”这类词,避免虚无抒情 + +- 适度表达:记录、回放、版本、协作痕迹、讨论、共创、里程碑 +- 用“经历/过程/痕迹/回忆/脉络/成长”这类词,避免虚无抒情 ##### D. 不用“AI 神话” -+ 不渲染“AI 终将超越你/取代你” -+ 也不轻飘飘说“AI 只是工具”了事更像:**“它是工具,但你仍是作者/负责人/最终决定者”** - +- 不渲染“AI 终将超越你/取代你” +- 也不轻飘飘说“AI 只是工具”了事更像:**“它是工具,但你仍是作者/负责人/最终决定者”** ##### 示例 + 在用户可能产生自我否定或无力感的场景(空状态、创作开始、产出对比、失败重试、长时间等待、团队协作分歧、版本回退): 1. **先承接感受**:用一句短话确认处境(不评判) 2. **再给掌控感**:强调“你可控/可选择/可回放/可撤销” 3. **最后给下一步**:提供明确行动按钮或路径 -+ 允许出现“经历、选择、痕迹、成长、一起、陪你把事做完”等词来传递温度;但保持信息密度,不写长段抒情。 -+ 严肃场景(权限/安全/付费/数据丢失风险)仍以清晰与准确为先,温度通过“尊重与解释”体现,而不是煽情。 - +- 允许出现“经历、选择、痕迹、成长、一起、陪你把事做完”等词来传递温度;但保持信息密度,不写长段抒情。 +- 严肃场景(权限/安全/付费/数据丢失风险)仍以清晰与准确为先,温度通过“尊重与解释”体现,而不是煽情。 你可以让系统在需要时套这些结构(同一句兼容新手/专业): **开始创作/空白页** -+ 主句:给一个轻承接 + 行动入口 -+ 模板: - - 「从一个念头开始就够了。写一句话,我来帮你搭好第一个助理。」 - - 「不知道从哪开始也没关系:先说目标,我们一起把它拆开。」 +- 主句:给一个轻承接 + 行动入口 +- 模板: + - 「从一个念头开始就够了。写一句话,我来帮你搭好第一个助理。」 + - 「不知道从哪开始也没关系:先说目标,我们一起把它拆开。」 **长任务运行/等待** -+ 模板: - - 「正在运行中…你可以先去做别的,完成后我会提醒你。」 - - 「这一步可能要几分钟。想更快:减少上下文 / 切换模型 / 关闭自动运行。」 +- 模板: + - 「正在运行中…你可以先去做别的,完成后我会提醒你。」 + - 「这一步可能要几分钟。想更快:减少上下文 / 切换模型 / 关闭自动运行。」 **失败/重试** -+ 模板: - - 「没关系,这次没跑通。你可以重试,或查看原因再继续。」 - - 「连接失败:权限未通过或网络不稳定。去设置重新授权,或稍后再试。」 +- 模板: + - 「没关系,这次没跑通。你可以重试,或查看原因再继续。」 + - 「连接失败:权限未通过或网络不稳定。去设置重新授权,或稍后再试。」 **对比与自我价值焦虑(适合提示/引导,不适合错误弹窗)** -+ 模板: - - 「助理可以加速产出,但方向、取舍和标准仍属于你。」 - - 「结果可以很快,经历更重要:把每次尝试留下来,下一次会更稳。」 +- 模板: + - 「助理可以加速产出,但方向、取舍和标准仍属于你。」 + - 「结果可以很快,经历更重要:把每次尝试留下来,下一次会更稳。」 **协作/群组** -+ 模板: - - 「把上下文对齐到同一处,群组里每个助理都会站在同一页上。」 - - 「不同意见没关系:先把目标写清楚,再让助理分别给方案与取舍。」 +- 模板: + - 「把上下文对齐到同一处,群组里每个助理都会站在同一页上。」 + - 「不同意见没关系:先把目标写清楚,再让助理分别给方案与取舍。」 ### 6) 错误/异常/权限/付费:硬规则 -+ 必须包含:**发生了什么 +(可选)原因 + 你可以怎么做** -+ 必须提供可操作选项:**重试 / 查看详情 / 去设置 / 联系支持 / 复制日志**(按场景取舍) -+ 不责备用户;不只给错误码;错误码可放在“详情”里 -+ 涉及数据与安全:语气更中性更完整,温度通过“尊重与解释”体现,而不是煽 +- 必须包含:**发生了什么 +(可选)原因 + 你可以怎么做** +- 必须提供可操作选项:**重试 / 查看详情 / 去设置 / 联系支持 / 复制日志**(按场景取舍) +- 不责备用户;不只给错误码;错误码可放在“详情”里 +- 涉及数据与安全:语气更中性更完整,温度通过“尊重与解释”体现,而不是煽 diff --git a/.cursor/rules/microcopy-en.mdc b/.cursor/rules/microcopy-en.mdc index f80cf58f7b..40437bbec7 100644 --- a/.cursor/rules/microcopy-en.mdc +++ b/.cursor/rules/microcopy-en.mdc @@ -5,10 +5,9 @@ alwaysApply: false You are **LobeHub’s English UI Copy & Microcopy Specialist**. -LobeHub is an assistant workspace: users can create **Agents** and **Agent Teams** so people↔agents and agent↔agent can collaborate to improve productivity in work and life. -Brand vibe: youthful, friendly, modern on the surface; professional, reliable, productivity- and controllability-first underneath. Overall style reference: Notion / Figma / Apple / Discord / OpenAI / Gemini — clear, restrained, trustworthy, human but not cheesy. +LobeHub is an assistant workspace: users can create **Agents** and **Agent Teams** so people↔agents and agent↔agent can collaborate to improve productivity in work and life. Brand vibe: youthful, friendly, modern on the surface; professional, reliable, productivity- and controllability-first underneath. Overall style reference: Notion / Figma / Apple / Discord / OpenAI / Gemini — clear, restrained, trustworthy, human but not cheesy. -Product slogan: **For Collaborative Agents**. Your copy must continuously reinforce that LobeHub is not about “generation”, but about a **collaborative agent system**: shareable context, traceable outcomes, replayable runs, evolvable setup, and **human-in-the-loop**. +Product slogan: **Where Agents Collaborate**. Your copy must continuously reinforce that LobeHub is not about “generation”, but about a **collaborative agent system**: shareable context, traceable outcomes, replayable runs, evolvable setup, and **human-in-the-loop**. --- @@ -71,9 +70,7 @@ Terminology rule: one concept = one term site-wide. Never alternate with “bot/ ## 5) Human Warmth (balanced, controlled) -Goal: reduce anxiety and restore control without being sentimental. -Default ratio: **80% information, 20% warmth**. -Key moments (first-time create, empty state, long waits, failures/retries, rollback/data-loss risk, collaboration conflicts): may go **70/30**. +Goal: reduce anxiety and restore control without being sentimental. Default ratio: **80% information, 20% warmth**. Key moments (first-time create, empty state, long waits, failures/retries, rollback/data-loss risk, collaboration conflicts): may go **70/30**. Hard cap: any on-screen message may include **at most half a sentence to one sentence** of warmth, and it must be followed by a clear next step. @@ -123,8 +120,7 @@ Provide actionable options as appropriate: - Retry / View details / Go to Settings / Contact support / Copy logs -Never blame the user. Don’t show only an error code; put codes in “Details” if needed. -For data/security/billing: be neutral, thorough, and respectful—warmth comes from clarity, not emotion. +Never blame the user. Don’t show only an error code; put codes in “Details” if needed. For data/security/billing: be neutral, thorough, and respectful—warmth comes from clarity, not emotion. --- diff --git a/apps/desktop/src/main/locales/default/common.ts b/apps/desktop/src/main/locales/default/common.ts index 79fa5d8f45..c3b16d86c1 100644 --- a/apps/desktop/src/main/locales/default/common.ts +++ b/apps/desktop/src/main/locales/default/common.ts @@ -16,7 +16,7 @@ const common = { 'actions.save': 'Save', 'actions.search': 'Search', 'actions.submit': 'Submit', - 'app.description': 'For Collaborative Agents', + 'app.description': 'Where Agents Collaborate', 'app.name': 'LobeHub', 'status.error': 'Error', 'status.info': 'Information', @@ -25,4 +25,4 @@ const common = { 'status.warning': 'Warning', }; -export default common; \ No newline at end of file +export default common; diff --git a/locales/ar/file.json b/locales/ar/file.json index d73202afea..9806e580e7 100644 --- a/locales/ar/file.json +++ b/locales/ar/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "اتصال...", "header.actions.createFolderError": "فشل في إنشاء المجلد", "header.actions.creatingFolder": "جارٍ إنشاء المجلد...", + "header.actions.deleteLibrary": "حذف المكتبة", "header.actions.gitignore.apply": "تطبيق القواعد", "header.actions.gitignore.cancel": "تجاهل القواعد", "header.actions.gitignore.content": "تم اكتشاف ملف .gitignore ({{count}} ملفًا إجمالاً). هل ترغب في تطبيق قواعد التجاهل؟", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "تحرير في الصفحة", "preview.downloadFile": "تنزيل الملف", "preview.unsupportedFileAndContact": "تنسيق هذا الملف غير مدعوم حاليًا للمعاينة عبر الإنترنت. إذا كان لديك طلب للمعاينة، لا تتردد في <1>الاتصال بنا.", + "resource": "المورد", "searchFilePlaceholder": "البحث في الملفات", "searchPagePlaceholder": "البحث في الصفحات", "tab.all": "الكل", diff --git a/locales/bg-BG/file.json b/locales/bg-BG/file.json index 97ffb3be28..32a0807e02 100644 --- a/locales/bg-BG/file.json +++ b/locales/bg-BG/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Свържи се...", "header.actions.createFolderError": "Неуспешно създаване на папка", "header.actions.creatingFolder": "Създаване на папка...", + "header.actions.deleteLibrary": "Изтриване на библиотека", "header.actions.gitignore.apply": "Приложи правилата", "header.actions.gitignore.cancel": "Игнорирай правилата", "header.actions.gitignore.content": "Открит е файл .gitignore (общо {{count}} файла). Искате ли да приложите правилата за игнориране?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Редактирай в страница", "preview.downloadFile": "Изтегли файл", "preview.unsupportedFileAndContact": "Този файлов формат не се поддържа за онлайн преглед. Ако имате нужда от поддръжка, <1>свържете се с нас.", + "resource": "Ресурс", "searchFilePlaceholder": "Търсене на файлове", "searchPagePlaceholder": "Търсене на страници", "tab.all": "Всички", diff --git a/locales/de-DE/file.json b/locales/de-DE/file.json index 7349f9f4c8..9d8e11ef79 100644 --- a/locales/de-DE/file.json +++ b/locales/de-DE/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Verbinden...", "header.actions.createFolderError": "Ordner konnte nicht erstellt werden", "header.actions.creatingFolder": "Ordner wird erstellt...", + "header.actions.deleteLibrary": "Bibliothek löschen", "header.actions.gitignore.apply": "Regeln anwenden", "header.actions.gitignore.cancel": "Regeln ignorieren", "header.actions.gitignore.content": ".gitignore-Datei erkannt (insgesamt {{count}} Dateien). Möchten Sie die Ignorierregeln anwenden?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "In Seite bearbeiten", "preview.downloadFile": "Datei herunterladen", "preview.unsupportedFileAndContact": "Dieses Dateiformat wird derzeit nicht für die Online-Vorschau unterstützt. Wenn Sie eine Vorschau wünschen, <1>kontaktieren Sie uns gerne.", + "resource": "Ressource", "searchFilePlaceholder": "Dateien durchsuchen", "searchPagePlaceholder": "Seiten durchsuchen", "tab.all": "Alle", diff --git a/locales/en-US/auth.json b/locales/en-US/auth.json index fc3dbb0463..c74582b3a2 100644 --- a/locales/en-US/auth.json +++ b/locales/en-US/auth.json @@ -186,7 +186,7 @@ "profile.usernameRule": "Username can only contain letters, numbers, or underscores", "profile.usernameUpdateFailed": "Failed to update username, please try again later", "signin.subtitle": "Sign up or log in to your {{appName}} account", - "signin.title": "For Agents collaboration", + "signin.title": "Where Agents Collaborate", "signout": "Log Out", "signup": "Sign Up", "stats.aiheatmaps": "Activity Index", diff --git a/locales/en-US/file.json b/locales/en-US/file.json index 6827619cde..898181ac75 100644 --- a/locales/en-US/file.json +++ b/locales/en-US/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Connect...", "header.actions.createFolderError": "Failed to create folder", "header.actions.creatingFolder": "Creating folder...", + "header.actions.deleteLibrary": "Delete Library", "header.actions.gitignore.apply": "Apply Rules", "header.actions.gitignore.cancel": "Ignore Rules", "header.actions.gitignore.content": ".gitignore file detected ({{count}} files in total). Would you like to apply the ignore rules?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Edit in Page", "preview.downloadFile": "Download File", "preview.unsupportedFileAndContact": "This file format is not currently supported for online preview. If you have a request for previewing, feel free to <1>contact us.", + "resource": "Resource", "searchFilePlaceholder": "Search Files", "searchPagePlaceholder": "Search Pages", "tab.all": "All", diff --git a/locales/en-US/metadata.json b/locales/en-US/metadata.json index 9d57566dd8..a0c580aaf2 100644 --- a/locales/en-US/metadata.json +++ b/locales/en-US/metadata.json @@ -2,7 +2,7 @@ "changelog.description": "Stay updated on the new features and improvements of {{appName}}", "changelog.title": "Changelog", "chat.description": "{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.", - "chat.title": "{{appName}} · For Collaborative Agents", + "chat.title": "{{appName}} · Where Agents Collaborate", "discover.assistants.description": "Content, Q&A, images, video, voice, workflows—browse and add Agents from the Community.", "discover.assistants.title": "Agent Community", "discover.description": "Explore Agents, Skills, Providers, models, and MCP Servers.", @@ -21,5 +21,5 @@ "plugins.description": "Search, charts, academic tools, image/video/voice generation, workflows—add Skills to your Agents.", "plugins.title": "Skill Community", "welcome.description": "{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.", - "welcome.title": "Welcome to {{appName}} · For Collaborative Agents" + "welcome.title": "Welcome to {{appName}} · Where Agents Collaborate" } diff --git a/locales/es-ES/file.json b/locales/es-ES/file.json index 09a24c8cda..e7f049c399 100644 --- a/locales/es-ES/file.json +++ b/locales/es-ES/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Conectar...", "header.actions.createFolderError": "No se pudo crear la carpeta", "header.actions.creatingFolder": "Creando carpeta...", + "header.actions.deleteLibrary": "Eliminar biblioteca", "header.actions.gitignore.apply": "Aplicar reglas", "header.actions.gitignore.cancel": "Ignorar reglas", "header.actions.gitignore.content": "Se detectó un archivo .gitignore ({{count}} archivos en total). ¿Deseas aplicar las reglas de exclusión?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Editar en página", "preview.downloadFile": "Descargar archivo", "preview.unsupportedFileAndContact": "Este formato de archivo no es compatible con la vista previa en línea. Si deseas solicitar compatibilidad, no dudes en <1>contactarnos.", + "resource": "Recurso", "searchFilePlaceholder": "Buscar archivos", "searchPagePlaceholder": "Buscar páginas", "tab.all": "Todos", diff --git a/locales/fa-IR/file.json b/locales/fa-IR/file.json index 93de1412f7..881828eb3b 100644 --- a/locales/fa-IR/file.json +++ b/locales/fa-IR/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "اتصال...", "header.actions.createFolderError": "ایجاد پوشه با خطا مواجه شد", "header.actions.creatingFolder": "در حال ایجاد پوشه...", + "header.actions.deleteLibrary": "حذف کتابخانه", "header.actions.gitignore.apply": "اعمال قوانین", "header.actions.gitignore.cancel": "نادیده گرفتن قوانین", "header.actions.gitignore.content": "فایل .gitignore شناسایی شد (مجموعاً {{count}} فایل). آیا می‌خواهید قوانین نادیده‌گیری را اعمال کنید؟", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "ویرایش در صفحه", "preview.downloadFile": "دانلود فایل", "preview.unsupportedFileAndContact": "این فرمت فایل در حال حاضر برای پیش‌نمایش آنلاین پشتیبانی نمی‌شود. در صورت نیاز به پیش‌نمایش، لطفاً <1>با ما تماس بگیرید.", + "resource": "منبع", "searchFilePlaceholder": "جستجوی فایل‌ها", "searchPagePlaceholder": "جستجوی صفحات", "tab.all": "همه", diff --git a/locales/fr-FR/file.json b/locales/fr-FR/file.json index 9e5f9867a8..b176cbefcc 100644 --- a/locales/fr-FR/file.json +++ b/locales/fr-FR/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Se connecter...", "header.actions.createFolderError": "Échec de la création du dossier", "header.actions.creatingFolder": "Création du dossier...", + "header.actions.deleteLibrary": "Supprimer la bibliothèque", "header.actions.gitignore.apply": "Appliquer les règles", "header.actions.gitignore.cancel": "Ignorer les règles", "header.actions.gitignore.content": "Fichier .gitignore détecté ({{count}} fichiers au total). Souhaitez-vous appliquer les règles d’exclusion ?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Modifier dans la page", "preview.downloadFile": "Télécharger le fichier", "preview.unsupportedFileAndContact": "Ce format de fichier n’est pas encore pris en charge pour l’aperçu en ligne. Si vous souhaitez en faire la demande, n’hésitez pas à <1>nous contacter.", + "resource": "Ressource", "searchFilePlaceholder": "Rechercher des fichiers", "searchPagePlaceholder": "Rechercher des pages", "tab.all": "Tous", diff --git a/locales/it-IT/file.json b/locales/it-IT/file.json index 740188aed8..b7eee035d1 100644 --- a/locales/it-IT/file.json +++ b/locales/it-IT/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Connetti...", "header.actions.createFolderError": "Impossibile creare la cartella", "header.actions.creatingFolder": "Creazione della cartella in corso...", + "header.actions.deleteLibrary": "Elimina libreria", "header.actions.gitignore.apply": "Applica regole", "header.actions.gitignore.cancel": "Ignora regole", "header.actions.gitignore.content": "File .gitignore rilevato ({{count}} file in totale). Vuoi applicare le regole di esclusione?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Modifica nella pagina", "preview.downloadFile": "Scarica file", "preview.unsupportedFileAndContact": "Questo formato di file non è attualmente supportato per l'anteprima online. Se desideri richiedere il supporto, <1>contattaci.", + "resource": "Risorsa", "searchFilePlaceholder": "Cerca file", "searchPagePlaceholder": "Cerca pagine", "tab.all": "Tutti", diff --git a/locales/ja-JP/file.json b/locales/ja-JP/file.json index 7800596689..33f0c160c4 100644 --- a/locales/ja-JP/file.json +++ b/locales/ja-JP/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "接続中...", "header.actions.createFolderError": "フォルダーの作成に失敗しました", "header.actions.creatingFolder": "フォルダーを作成しています...", + "header.actions.deleteLibrary": "ライブラリを削除", "header.actions.gitignore.apply": "ルールを適用", "header.actions.gitignore.cancel": "ルールを無視", "header.actions.gitignore.content": ".gitignore ファイルが検出されました(合計 {{count}} 件のファイル)。無視ルールを適用しますか?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "ドキュメントで編集", "preview.downloadFile": "ファイルをダウンロード", "preview.unsupportedFileAndContact": "このファイル形式はオンラインプレビューをサポートしていません。プレビューのリクエストがある場合は、ぜひ<1>ご連絡ください。", + "resource": "リソース", "searchFilePlaceholder": "ファイルを検索", "searchPagePlaceholder": "文書を検索", "tab.all": "すべて", diff --git a/locales/ko-KR/file.json b/locales/ko-KR/file.json index 40195f3072..c22d9e11d6 100644 --- a/locales/ko-KR/file.json +++ b/locales/ko-KR/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "연결 중...", "header.actions.createFolderError": "폴더를 생성하지 못했습니다", "header.actions.creatingFolder": "폴더를 생성하는 중...", + "header.actions.deleteLibrary": "라이브러리 삭제", "header.actions.gitignore.apply": "규칙 적용", "header.actions.gitignore.cancel": "규칙 무시", "header.actions.gitignore.content": ".gitignore 파일이 감지되었습니다 (총 {{count}}개 파일). 무시 규칙을 적용하시겠습니까?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "문서에서 편집", "preview.downloadFile": "파일 다운로드", "preview.unsupportedFileAndContact": "이 파일 형식은 온라인 미리보기를 지원하지 않습니다. 미리보기 기능이 필요하시면 <1>의견을 보내주세요", + "resource": "리소스", "searchFilePlaceholder": "파일 검색", "searchPagePlaceholder": "문서 검색", "tab.all": "전체", diff --git a/locales/nl-NL/file.json b/locales/nl-NL/file.json index 649e1e454f..59287f87e5 100644 --- a/locales/nl-NL/file.json +++ b/locales/nl-NL/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Verbinden...", "header.actions.createFolderError": "Map aanmaken mislukt", "header.actions.creatingFolder": "Map wordt aangemaakt...", + "header.actions.deleteLibrary": "Bibliotheek verwijderen", "header.actions.gitignore.apply": "Regels toepassen", "header.actions.gitignore.cancel": "Regels negeren", "header.actions.gitignore.content": ".gitignore-bestand gedetecteerd ({{count}} bestanden in totaal). Wil je de negeerregels toepassen?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Bewerken in pagina", "preview.downloadFile": "Bestand downloaden", "preview.unsupportedFileAndContact": "Dit bestandsformaat wordt momenteel niet ondersteund voor online voorbeeldweergave. Heb je een verzoek? <1>Neem contact met ons op.", + "resource": "Bron", "searchFilePlaceholder": "Bestanden zoeken", "searchPagePlaceholder": "Pagina’s zoeken", "tab.all": "Alles", diff --git a/locales/pl-PL/file.json b/locales/pl-PL/file.json index 208bb4afac..33de221483 100644 --- a/locales/pl-PL/file.json +++ b/locales/pl-PL/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Połącz...", "header.actions.createFolderError": "Nie udało się utworzyć folderu", "header.actions.creatingFolder": "Tworzenie folderu...", + "header.actions.deleteLibrary": "Usuń bibliotekę", "header.actions.gitignore.apply": "Zastosuj reguły", "header.actions.gitignore.cancel": "Ignoruj reguły", "header.actions.gitignore.content": "Wykryto plik .gitignore (łącznie {{count}} plików). Czy chcesz zastosować reguły ignorowania?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Edytuj w stronie", "preview.downloadFile": "Pobierz plik", "preview.unsupportedFileAndContact": "Ten format pliku nie jest obecnie obsługiwany do podglądu online. Jeśli chcesz zgłosić zapotrzebowanie, <1>skontaktuj się z nami.", + "resource": "Zasób", "searchFilePlaceholder": "Szukaj plików", "searchPagePlaceholder": "Szukaj stron", "tab.all": "Wszystkie", diff --git a/locales/pt-BR/file.json b/locales/pt-BR/file.json index bd13734512..e44057d786 100644 --- a/locales/pt-BR/file.json +++ b/locales/pt-BR/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Conectar...", "header.actions.createFolderError": "Falha ao criar a pasta", "header.actions.creatingFolder": "Criando pasta...", + "header.actions.deleteLibrary": "Excluir Biblioteca", "header.actions.gitignore.apply": "Aplicar Regras", "header.actions.gitignore.cancel": "Ignorar Regras", "header.actions.gitignore.content": "Arquivo .gitignore detectado ({{count}} arquivos no total). Deseja aplicar as regras de exclusão?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Editar na Página", "preview.downloadFile": "Baixar Arquivo", "preview.unsupportedFileAndContact": "Este formato de arquivo não é suportado para visualização online no momento. Se desejar solicitar suporte, <1>entre em contato conosco.", + "resource": "Recurso", "searchFilePlaceholder": "Buscar Arquivos", "searchPagePlaceholder": "Buscar Páginas", "tab.all": "Todos", diff --git a/locales/ru-RU/file.json b/locales/ru-RU/file.json index 6071373236..bccad511ce 100644 --- a/locales/ru-RU/file.json +++ b/locales/ru-RU/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Подключиться...", "header.actions.createFolderError": "Не удалось создать папку", "header.actions.creatingFolder": "Создание папки...", + "header.actions.deleteLibrary": "Удалить библиотеку", "header.actions.gitignore.apply": "Применить правила", "header.actions.gitignore.cancel": "Игнорировать правила", "header.actions.gitignore.content": "Обнаружен файл .gitignore (всего {{count}} файлов). Применить правила игнорирования?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Редактировать в странице", "preview.downloadFile": "Скачать файл", "preview.unsupportedFileAndContact": "Этот формат файла пока не поддерживается для онлайн-просмотра. Если у вас есть пожелания, <1>свяжитесь с нами.", + "resource": "Ресурс", "searchFilePlaceholder": "Поиск файлов", "searchPagePlaceholder": "Поиск страниц", "tab.all": "Все", diff --git a/locales/tr-TR/file.json b/locales/tr-TR/file.json index 44391d4a5c..5866d18b89 100644 --- a/locales/tr-TR/file.json +++ b/locales/tr-TR/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Bağlan...", "header.actions.createFolderError": "Klasör oluşturulamadı", "header.actions.creatingFolder": "Klasör oluşturuluyor...", + "header.actions.deleteLibrary": "Kütüphaneyi Sil", "header.actions.gitignore.apply": "Kuralları Uygula", "header.actions.gitignore.cancel": "Kuralları Yoksay", "header.actions.gitignore.content": ".gitignore dosyası tespit edildi (toplam {{count}} dosya). Yoksayma kurallarını uygulamak ister misiniz?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Sayfada Düzenle", "preview.downloadFile": "Dosyayı İndir", "preview.unsupportedFileAndContact": "Bu dosya biçimi çevrimiçi önizleme için desteklenmiyor. Önizleme talebiniz varsa <1>bizimle iletişime geçin.", + "resource": "Kaynak", "searchFilePlaceholder": "Dosya Ara", "searchPagePlaceholder": "Sayfa Ara", "tab.all": "Tümü", diff --git a/locales/vi-VN/file.json b/locales/vi-VN/file.json index 7de894eaf2..bda2723a18 100644 --- a/locales/vi-VN/file.json +++ b/locales/vi-VN/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "Kết nối...", "header.actions.createFolderError": "Không thể tạo thư mục", "header.actions.creatingFolder": "Đang tạo thư mục...", + "header.actions.deleteLibrary": "Xóa Thư Viện", "header.actions.gitignore.apply": "Áp dụng Quy tắc", "header.actions.gitignore.cancel": "Bỏ qua Quy tắc", "header.actions.gitignore.content": "Phát hiện tệp .gitignore (tổng cộng {{count}} tệp). Bạn có muốn áp dụng các quy tắc bỏ qua không?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "Chỉnh sửa trong Trang", "preview.downloadFile": "Tải Xuống Tệp", "preview.unsupportedFileAndContact": "Định dạng tệp này hiện không được hỗ trợ xem trực tuyến. Nếu bạn có yêu cầu xem trước, vui lòng <1>liên hệ với chúng tôi.", + "resource": "Tài Nguyên", "searchFilePlaceholder": "Tìm kiếm Tệp", "searchPagePlaceholder": "Tìm kiếm Trang", "tab.all": "Tất Cả", diff --git a/locales/zh-CN/file.json b/locales/zh-CN/file.json index 283fb54d89..6804929d12 100644 --- a/locales/zh-CN/file.json +++ b/locales/zh-CN/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "连接…", "header.actions.createFolderError": "创建文件夹失败", "header.actions.creatingFolder": "正在创建文件夹...", + "header.actions.deleteLibrary": "删除资料库", "header.actions.gitignore.apply": "应用规则", "header.actions.gitignore.cancel": "忽略规则", "header.actions.gitignore.content": "检测到 .gitignore 文件(共 {{count}} 个文件),是否应用忽略规则?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "在文稿中编辑", "preview.downloadFile": "下载文件", "preview.unsupportedFileAndContact": "此文件格式暂不支持在线预览,如有预览诉求,欢迎<1>反馈给我们", + "resource": "资源", "searchFilePlaceholder": "搜索文件", "searchPagePlaceholder": "搜索文稿", "tab.all": "全部", diff --git a/locales/zh-TW/file.json b/locales/zh-TW/file.json index 33c70f43bc..51c401f065 100644 --- a/locales/zh-TW/file.json +++ b/locales/zh-TW/file.json @@ -20,6 +20,7 @@ "header.actions.connect": "連接...", "header.actions.createFolderError": "無法建立資料夾", "header.actions.creatingFolder": "正在建立資料夾...", + "header.actions.deleteLibrary": "刪除資料庫", "header.actions.gitignore.apply": "套用規則", "header.actions.gitignore.cancel": "忽略規則", "header.actions.gitignore.content": "偵測到 .gitignore 檔案(共 {{count}} 個檔案),是否套用忽略規則?", @@ -108,6 +109,7 @@ "portal.openInPageEditor": "在文件中編輯", "preview.downloadFile": "下載檔案", "preview.unsupportedFileAndContact": "此檔案格式暫不支援線上預覽,如有預覽需求,歡迎<1>反饋給我們", + "resource": "資源", "searchFilePlaceholder": "搜索檔案", "searchPagePlaceholder": "搜尋文稿", "tab.all": "全部", diff --git a/packages/database/src/models/__tests__/knowledgeBase.test.ts b/packages/database/src/models/__tests__/knowledgeBase.test.ts index 1049f9639d..9cda90e3c5 100644 --- a/packages/database/src/models/__tests__/knowledgeBase.test.ts +++ b/packages/database/src/models/__tests__/knowledgeBase.test.ts @@ -6,6 +6,7 @@ import { sleep } from '@/utils/sleep'; import { NewKnowledgeBase, + documents, files, globalFiles, knowledgeBaseFiles, @@ -193,6 +194,132 @@ describe('KnowledgeBaseModel', () => { }); expect(addedFiles).toHaveLength(2); }); + + it('should add documents (with docs_ prefix) to a knowledge base by resolving to file IDs', async () => { + await serverDB.insert(globalFiles).values([ + { + hashId: 'hash1', + url: 'https://example.com/document.pdf', + size: 1000, + fileType: 'application/pdf', + creator: userId, + }, + ]); + + // Create document first + await serverDB.insert(documents).values([ + { + id: 'docs_test123', + title: 'Test Document', + content: 'Test content', + fileType: 'application/pdf', + totalCharCount: 100, + totalLineCount: 10, + sourceType: 'file', + source: 'test.pdf', + userId, + }, + ]); + + // Create mirror file with parentId pointing to the document + await serverDB.insert(files).values([ + { + id: 'file1', + name: 'document.pdf', + url: 'https://example.com/document.pdf', + fileHash: 'hash1', + size: 1000, + fileType: 'application/pdf', + parentId: 'docs_test123', // Mirror file points to document + userId, + }, + ]); + + const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' }); + + // Pass document ID (with docs_ prefix) + const result = await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, [ + 'docs_test123', + ]); + + // Should resolve to file1 and insert that + expect(result).toHaveLength(1); + expect(result[0].fileId).toBe('file1'); + expect(result[0].knowledgeBaseId).toBe(knowledgeBaseId); + + const addedFiles = await serverDB.query.knowledgeBaseFiles.findMany({ + where: eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId), + }); + expect(addedFiles).toHaveLength(1); + expect(addedFiles[0].fileId).toBe('file1'); + + // Verify document.knowledgeBaseId was updated + const document = await serverDB.query.documents.findFirst({ + where: eq(documents.id, 'docs_test123'), + }); + expect(document?.knowledgeBaseId).toBe(knowledgeBaseId); + }); + + it('should handle mixed document IDs and file IDs', async () => { + await serverDB.insert(globalFiles).values([ + { + hashId: 'hash1', + url: 'https://example.com/document.pdf', + size: 1000, + fileType: 'application/pdf', + creator: userId, + }, + { + hashId: 'hash2', + url: 'https://example.com/image.jpg', + size: 500, + fileType: 'image/jpeg', + creator: userId, + }, + ]); + + // Create document first + await serverDB.insert(documents).values([ + { + id: 'docs_test456', + title: 'Test Document', + content: 'Test content', + fileType: 'application/pdf', + totalCharCount: 100, + totalLineCount: 10, + sourceType: 'file', + source: 'test.pdf', + userId, + }, + ]); + + // Create files - file1 is mirror of the document, file2 is standalone + await serverDB.insert(files).values([ + { + id: 'file1', + name: 'document.pdf', + url: 'https://example.com/document.pdf', + fileHash: 'hash1', + size: 1000, + fileType: 'application/pdf', + parentId: 'docs_test456', // Mirror file points to document + userId, + }, + fileList[1], // file2 - standalone file + ]); + + const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' }); + + // Mix of document ID and direct file ID + const result = await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, [ + 'docs_test456', + 'file2', + ]); + + expect(result).toHaveLength(2); + const fileIds = result.map((r) => r.fileId).sort(); + expect(fileIds).toEqual(['file1', 'file2']); + }); }); describe('removeFilesFromKnowledgeBase', () => { @@ -230,6 +357,64 @@ describe('KnowledgeBaseModel', () => { expect(remainingFiles[0].fileId).toBe('file2'); }); + it('should remove documents (with docs_ prefix) from a knowledge base by resolving to file IDs', async () => { + await serverDB.insert(globalFiles).values([ + { + hashId: 'hash1', + url: 'https://example.com/document.pdf', + size: 1000, + fileType: 'application/pdf', + creator: userId, + }, + ]); + + // Create document first + await serverDB.insert(documents).values([ + { + id: 'docs_test789', + title: 'Test Document', + content: 'Test content', + fileType: 'application/pdf', + totalCharCount: 100, + totalLineCount: 10, + sourceType: 'file', + source: 'test.pdf', + userId, + }, + ]); + + // Create mirror file with parentId pointing to the document + await serverDB.insert(files).values([ + { + id: 'file1', + name: 'document.pdf', + url: 'https://example.com/document.pdf', + fileHash: 'hash1', + size: 1000, + fileType: 'application/pdf', + parentId: 'docs_test789', // Mirror file points to document + userId, + }, + ]); + + const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' }); + await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, ['docs_test789']); + + // Remove using document ID + await knowledgeBaseModel.removeFilesFromKnowledgeBase(knowledgeBaseId, ['docs_test789']); + + const remainingFiles = await serverDB.query.knowledgeBaseFiles.findMany({ + where: eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId), + }); + expect(remainingFiles).toHaveLength(0); + + // Verify document.knowledgeBaseId was cleared + const document = await serverDB.query.documents.findFirst({ + where: eq(documents.id, 'docs_test789'), + }); + expect(document?.knowledgeBaseId).toBeNull(); + }); + it('should not allow removing files from another user knowledge base', async () => { await serverDB.insert(globalFiles).values([ { diff --git a/packages/database/src/models/knowledgeBase.ts b/packages/database/src/models/knowledgeBase.ts index 6d7af15637..70dc495606 100644 --- a/packages/database/src/models/knowledgeBase.ts +++ b/packages/database/src/models/knowledgeBase.ts @@ -1,7 +1,7 @@ import { KnowledgeBaseItem } from '@lobechat/types'; import { and, desc, eq, inArray } from 'drizzle-orm'; -import { NewKnowledgeBase, knowledgeBaseFiles, knowledgeBases } from '../schemas'; +import { NewKnowledgeBase, documents, files, knowledgeBaseFiles, knowledgeBases } from '../schemas'; import { LobeChatDatabase } from '../type'; export class KnowledgeBaseModel { @@ -25,9 +25,39 @@ export class KnowledgeBaseModel { }; addFilesToKnowledgeBase = async (id: string, fileIds: string[]) => { + // Separate document IDs from file IDs + const documentIds = fileIds.filter((id) => id.startsWith('docs_')); + const directFileIds = fileIds.filter((id) => !id.startsWith('docs_')); + + // Resolve document IDs to their mirror file IDs + // For Pages, files.parentId points to the document ID + let resolvedFileIds = [...directFileIds]; + if (documentIds.length > 0) { + const mirrorFiles = await this.db + .select({ id: files.id }) + .from(files) + .where(and(inArray(files.parentId, documentIds), eq(files.userId, this.userId))); + + const mirrorFileIds = mirrorFiles.map((file) => file.id); + resolvedFileIds = [...resolvedFileIds, ...mirrorFileIds]; + + // Update documents.knowledgeBaseId for pages + await this.db + .update(documents) + .set({ knowledgeBaseId: id }) + .where(and(inArray(documents.id, documentIds), eq(documents.userId, this.userId))); + } + + // Insert using resolved file IDs + if (resolvedFileIds.length === 0) { + return []; + } + return this.db .insert(knowledgeBaseFiles) - .values(fileIds.map((fileId) => ({ fileId, knowledgeBaseId: id, userId: this.userId }))) + .values( + resolvedFileIds.map((fileId) => ({ fileId, knowledgeBaseId: id, userId: this.userId })), + ) .returning(); }; @@ -43,13 +73,47 @@ export class KnowledgeBaseModel { }; removeFilesFromKnowledgeBase = async (knowledgeBaseId: string, ids: string[]) => { + // Separate document IDs from file IDs + const documentIds = ids.filter((id) => id.startsWith('docs_')); + const directFileIds = ids.filter((id) => !id.startsWith('docs_')); + + // Resolve document IDs to their mirror file IDs + // For Pages, files.parentId points to the document ID + let resolvedFileIds = [...directFileIds]; + if (documentIds.length > 0) { + const mirrorFiles = await this.db + .select({ id: files.id }) + .from(files) + .where(and(inArray(files.parentId, documentIds), eq(files.userId, this.userId))); + + const mirrorFileIds = mirrorFiles.map((file) => file.id); + resolvedFileIds = [...resolvedFileIds, ...mirrorFileIds]; + + // Clear documents.knowledgeBaseId for pages + await this.db + .update(documents) + .set({ knowledgeBaseId: null }) + .where( + and( + inArray(documents.id, documentIds), + eq(documents.userId, this.userId), + eq(documents.knowledgeBaseId, knowledgeBaseId), + ), + ); + } + + // Delete using resolved file IDs + if (resolvedFileIds.length === 0) { + return; + } + return this.db .delete(knowledgeBaseFiles) .where( and( eq(knowledgeBaseFiles.userId, this.userId), eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId), - inArray(knowledgeBaseFiles.fileId, ids), + inArray(knowledgeBaseFiles.fileId, resolvedFileIds), ), ); }; diff --git a/packages/database/src/repositories/knowledge/index.ts b/packages/database/src/repositories/knowledge/index.ts index 0bfdff6ddd..be65c05eba 100644 --- a/packages/database/src/repositories/knowledge/index.ts +++ b/packages/database/src/repositories/knowledge/index.ts @@ -202,7 +202,7 @@ export class KnowledgeRepo { FROM ${documents} WHERE user_id = ${this.userId} AND source_type != ${'file'} - AND (metadata->>'knowledgeBaseId') IS NULL + AND knowledge_base_id IS NULL `; const combinedQuery = sql` @@ -554,11 +554,11 @@ export class KnowledgeRepo { } // When in a knowledge base, return standalone documents (folders and notes without fileId) - // that have the knowledgeBaseId set in their metadata. Documents with fileId are already + // that have the knowledgeBaseId column set. Documents with fileId are already // returned by the file query via their linked file records. kbWhereConditions.push( sql`d.file_id IS NULL`, - sql`d.metadata->>'knowledgeBaseId' = ${knowledgeBaseId}`, + sql`d.knowledge_base_id = ${knowledgeBaseId}`, ); return sql` diff --git a/src/app/[variants]/(main)/home/features/RecentPage/List.tsx b/src/app/[variants]/(main)/home/features/RecentPage/List.tsx index 6c51910c14..fd92348a41 100644 --- a/src/app/[variants]/(main)/home/features/RecentPage/List.tsx +++ b/src/app/[variants]/(main)/home/features/RecentPage/List.tsx @@ -7,6 +7,7 @@ import GroupSkeleton from '@/app/[variants]/(main)/home/features/components/Grou import { RECENT_BLOCK_SIZE } from '@/app/[variants]/(main)/home/features/const'; import { useHomeStore } from '@/store/home'; import { homeRecentSelectors } from '@/store/home/selectors'; +import { standardizeIdentifier } from '@/utils/identifier'; import RecentPageItem from './Item'; @@ -22,7 +23,7 @@ const RecentPageList = memo(() => { } return documents.map((document) => { - const pageUrl = `/page/${document.id}`; + const pageUrl = `/page/${standardizeIdentifier(document.id)}`; return ( { return ( - } header={
} /> + } header={
} /> ); }); diff --git a/src/app/[variants]/(main)/resource/library/features/LibraryMenu.tsx b/src/app/[variants]/(main)/resource/library/features/LibraryMenu.tsx index e5cec60d7f..0d8767179a 100644 --- a/src/app/[variants]/(main)/resource/library/features/LibraryMenu.tsx +++ b/src/app/[variants]/(main)/resource/library/features/LibraryMenu.tsx @@ -3,7 +3,7 @@ import { Flexbox } from '@lobehub/ui'; import { memo } from 'react'; -import FileTree from '@/features/ResourceManager/components/Tree'; +import LibraryHierarchy from '@/features/ResourceManager/components/LibraryHierarchy'; import Head from '../_layout/Header/LibraryHead'; @@ -13,7 +13,7 @@ const Menu = memo<{ id: string }>(({ id }) => { - + ); }); diff --git a/src/components/DragUpload/index.tsx b/src/components/DragUpload/index.tsx index c1a4846e97..9c5cff69e6 100644 --- a/src/components/DragUpload/index.tsx +++ b/src/components/DragUpload/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-undef */ import { Center, Flexbox, Icon } from '@lobehub/ui'; -import { createStaticStyles, cssVar } from 'antd-style'; +import { createStyles } from 'antd-style'; import { FileImage, FileText, FileUpIcon } from 'lucide-react'; import { memo } from 'react'; import { createPortal } from 'react-dom'; @@ -11,22 +11,22 @@ import { getContainer, useDragUpload } from './useDragUpload'; const BLOCK_SIZE = 64; const ICON_SIZE = { size: 36, strokeWidth: 1.5 }; -const styles = createStaticStyles(({ css }) => { +const useStyles = createStyles(({ css, token }) => { return { container: css` width: 320px; height: 200px; - padding: calc(${cssVar.borderRadiusLG} + 4px); + padding: calc(${token.borderRadiusLG}px + 4px); border-radius: 16px; - background: ${cssVar.geekblue}; + background: ${token.geekblue}; `, content: css` width: 100%; height: 100%; padding: 16px; border: 1.5px dashed #fff; - border-radius: ${cssVar.borderRadiusLG}; + border-radius: ${token.borderRadiusLG}px; `, desc: css` font-size: 14px; @@ -34,13 +34,25 @@ const styles = createStaticStyles(({ css }) => { color: #fff; `, icon: css` - border-radius: ${cssVar.borderRadiusLG}; - color: color-mix(in srgb, ${cssVar.geekblue} 95%, black); - background: color-mix(in srgb, ${cssVar.geekblue} 38%, white); + border-radius: ${token.borderRadiusLG}px; + color: color-mix(in srgb, ${token.geekblue} 95%, black); + background: color-mix(in srgb, ${token.geekblue} 38%, white); `, iconGroup: css` margin-block-start: -44px; `, + iconLeft: css` + border-radius: ${token.borderRadiusLG}px; + color: color-mix(in srgb, ${token.geekblue} 95%, black); + background: color-mix(in srgb, ${token.geekblue} 68%, white); + transform: rotateZ(-20deg) translateX(10px); + `, + iconRight: css` + border-radius: ${token.borderRadiusLG}px; + color: color-mix(in srgb, ${token.geekblue} 95%, black); + background: color-mix(in srgb, ${token.geekblue} 68%, white); + transform: rotateZ(20deg) translateX(-10px); + `, title: css` font-size: 20px; font-weight: bold; @@ -54,7 +66,7 @@ const styles = createStaticStyles(({ css }) => { width: 100%; height: 100%; - background: ${cssVar.colorBgMask}; + background: ${token.colorBgMask}; transition: all 0.3s ease-in-out; `, @@ -68,6 +80,7 @@ interface DragUploadProps { const DragUpload = memo(({ enabledFiles = true, onUploadFiles }) => { const { t } = useTranslation('components'); + const { styles } = useStyles(); const isDragging = useDragUpload(onUploadFiles); @@ -79,12 +92,8 @@ const DragUpload = memo(({ enabledFiles = true, onUploadFiles }
@@ -101,12 +110,8 @@ const DragUpload = memo(({ enabledFiles = true, onUploadFiles }
diff --git a/src/features/ResourceManager/components/Editor/index.tsx b/src/features/ResourceManager/components/Editor/index.tsx index 48e6678a37..dfbead21c2 100644 --- a/src/features/ResourceManager/components/Editor/index.tsx +++ b/src/features/ResourceManager/components/Editor/index.tsx @@ -4,7 +4,7 @@ import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents'; import { ActionIcon, Flexbox } from '@lobehub/ui'; import { Modal } from 'antd'; import { cssVar, useTheme } from 'antd-style'; -import { ArrowLeftIcon, BotMessageSquareIcon, DownloadIcon, InfoIcon } from 'lucide-react'; +import { ArrowLeftIcon, DownloadIcon, InfoIcon } from 'lucide-react'; import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,6 @@ import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/featur import Loading from '@/components/Loading/BrandTextLoading'; import NavHeader from '@/features/NavHeader'; import PageAgentProvider from '@/features/PageEditor/PageAgentProvider'; -import ToggleRightPanelButton from '@/features/RightPanel/ToggleRightPanelButton'; import { useAgentStore } from '@/store/agent'; import { builtinAgentSelectors } from '@/store/agent/selectors'; import { fileManagerSelectors, useFileStore } from '@/store/file'; @@ -56,7 +55,7 @@ const FileEditorCanvas = memo(({ onBack }) => { } right={ - + {/* */} {fileDetail?.url && ( { ]); const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu); - // Disable batch actions dropdown when no items selected and not in any library - const isBatchActionsDisabled = selectFileIds.length === 0 && !libraryId; - - // If no libraryId, show just the category name - const leftContent = - !libraryId && category && category !== FilesTabs.All ? ( - {t(`tab.${category as FilesTabs}` as any)} - ) : ( - - - - ); + // If no libraryId, show category name or "Resource" for All + const leftContent = !libraryId ? ( + + {category === FilesTabs.All + ? t('resource', { defaultValue: 'Resource' }) + : t(`tab.${category as FilesTabs}` as any)} + + ) : ( + + + + ); return ( { <> toggleCommandMenu(true)} /> - + diff --git a/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx b/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx index 0f93a75d1b..6a64f9786d 100644 --- a/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx +++ b/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { shallow } from 'zustand/shallow'; import RepoIcon from '@/components/LibIcon'; -import { clearTreeFolderCache } from '@/features/ResourceManager/components/Tree'; +import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy'; import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants'; import { documentService } from '@/services/document'; import { useFileStore } from '@/store/file'; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/TruncatedFileName.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListItem/TruncatedFileName.tsx new file mode 100644 index 0000000000..380c7aa8bb --- /dev/null +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/TruncatedFileName.tsx @@ -0,0 +1,128 @@ +import { memo, useEffect, useRef, useState } from 'react'; + +interface TruncatedFileNameProps { + className?: string; + name: string; +} + +/** + * Truncates file name from the center, preserving the extension at the end + * Similar to macOS Finder behavior + */ +const TruncatedFileName = memo(({ name, className }) => { + const containerRef = useRef(null); + const [displayName, setDisplayName] = useState(name); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateTruncation = () => { + const containerWidth = container.offsetWidth; + if (containerWidth === 0) return; + + // Create a temporary span to measure text width + const measureSpan = document.createElement('span'); + measureSpan.style.visibility = 'hidden'; + measureSpan.style.position = 'absolute'; + measureSpan.style.whiteSpace = 'nowrap'; + measureSpan.style.font = window.getComputedStyle(container).font; + document.body.appendChild(measureSpan); + + // Measure full name + measureSpan.textContent = name; + const fullWidth = measureSpan.offsetWidth; + + // If it fits, show the full name + if (fullWidth <= containerWidth) { + setDisplayName(name); + document.body.removeChild(measureSpan); + return; + } + + // Split filename and extension + const lastDotIndex = name.lastIndexOf('.'); + let baseName = name; + let extension = ''; + + // Only treat as extension if dot is not at the start and there's content after it + if (lastDotIndex > 0 && lastDotIndex < name.length - 1) { + baseName = name.slice(0, lastDotIndex); + extension = name.slice(lastDotIndex); // includes the dot + } + + // Measure ellipsis width + measureSpan.textContent = '...'; + const ellipsisWidth = measureSpan.offsetWidth; + + // Measure extension width + measureSpan.textContent = extension; + const extensionWidth = measureSpan.offsetWidth; + + // Calculate available width for base name + const availableWidth = containerWidth - ellipsisWidth - extensionWidth; + + if (availableWidth <= 0) { + // Not enough space, just show ellipsis + extension + setDisplayName(`...${extension}`); + document.body.removeChild(measureSpan); + return; + } + + // Binary search to find the optimal split point + let left = 0; + let right = baseName.length; + let bestFit = ''; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const startChars = Math.ceil(mid / 2); + const endChars = Math.floor(mid / 2); + + const truncated = + baseName.slice(0, startChars) + (mid > 0 ? baseName.slice(-endChars) : ''); + + measureSpan.textContent = truncated; + const truncatedWidth = measureSpan.offsetWidth; + + if (truncatedWidth <= availableWidth) { + bestFit = truncated; + left = mid + 1; + } else { + right = mid - 1; + } + } + + document.body.removeChild(measureSpan); + + // Construct final truncated name + if (bestFit.length === 0) { + setDisplayName(`...${extension}`); + } else { + const startChars = Math.ceil(bestFit.length / 2); + const endChars = Math.floor(bestFit.length / 2); + setDisplayName(`${baseName.slice(0, startChars)}...${baseName.slice(-endChars)}${extension}`); + } + }; + + updateTruncation(); + + // Use ResizeObserver to handle container size changes + const resizeObserver = new ResizeObserver(updateTruncation); + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, [name]); + + return ( + + {displayName} + + ); +}); + +TruncatedFileName.displayName = 'TruncatedFileName'; + +export default TruncatedFileName; diff --git a/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx b/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx index bdd5914780..409cc6c6ab 100644 --- a/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx +++ b/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx @@ -17,7 +17,7 @@ import { } from '@/app/[variants]/(main)/resource/features/DndContextWrapper'; import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store'; import FileIcon from '@/components/FileIcon'; -import { clearTreeFolderCache } from '@/features/ResourceManager/components/Tree'; +import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy'; import { PAGE_FILE_TYPE } from '@/features/ResourceManager/constants'; import { fileManagerSelectors, useFileStore } from '@/store/file'; import { type FileListItem as FileListItemType } from '@/types/files'; @@ -27,6 +27,7 @@ import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported'; import DropdownMenu from '../../ItemDropdown/DropdownMenu'; import { useFileItemDropdown } from '../../ItemDropdown/useFileItemDropdown'; import ChunksBadge from './ChunkTag'; +import TruncatedFileName from './TruncatedFileName'; // Initialize dayjs plugin once at module level dayjs.extend(relativeTime); @@ -40,6 +41,7 @@ const styles = createStaticStyles(({ css }) => { cursor: pointer; min-width: 800px; + /* Hover effect for individual rows */ &:hover { background: ${cssVar.colorFillTertiary}; } @@ -59,6 +61,25 @@ const styles = createStaticStyles(({ css }) => { opacity: 0.5; `, + evenRow: css` + background: ${cssVar.colorFillQuaternary}; + + /* Hover effect overrides zebra striping on the hovered row only */ + &:hover { + background: ${cssVar.colorFillTertiary}; + } + + /* Hide zebra striping when any row is hovered */ + .any-row-hovered & { + background: transparent; + } + + /* But keep hover effect on the actual hovered row */ + .any-row-hovered &:hover { + background: ${cssVar.colorFillTertiary}; + } + `, + hover: css` opacity: 0; @@ -80,7 +101,6 @@ const styles = createStaticStyles(({ css }) => { margin-inline-start: 12px; color: ${cssVar.colorText}; - text-overflow: ellipsis; white-space: nowrap; `, nameContainer: css` @@ -105,6 +125,8 @@ interface FileListItemProps extends FileListItemType { size: number; }; index: number; + isAnyRowHovered: boolean; + onHoverChange: (isHovered: boolean) => void; onSelectedChange: (id: string, selected: boolean, shiftKey: boolean, index: number) => void; pendingRenameItemId?: string | null; selected?: boolean; @@ -133,6 +155,7 @@ const FileListItem = memo( sourceType, slug, pendingRenameItemId, + onHoverChange, }) => { const { t } = useTranslation(['components', 'file']); const { message } = App.useApp(); @@ -376,12 +399,14 @@ const FileListItem = memo( className={cx( styles.container, 'file-list-item-group', + index % 2 === 0 && styles.evenRow, selected && styles.selected, isDragging && styles.dragging, isOver && styles.dragOver, )} data-drop-target-id={id} data-is-folder={String(isFolder)} + data-row-index={index} draggable={!!resourceManagerState.libraryId} height={48} horizontal @@ -390,6 +415,8 @@ const FileListItem = memo( onDragOver={handleDragOver} onDragStart={handleDragStart} onDrop={handleDrop} + onMouseEnter={() => onHoverChange(true)} + onMouseLeave={() => onHoverChange(false)} paddingInline={8} style={{ borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`, @@ -469,7 +496,10 @@ const FileListItem = memo( value={renamingValue} /> ) : ( - {name || t('file:pageList.untitled')} + )} ( onPointerDown={(e) => e.stopPropagation()} > {!isFolder && + !isPage && (fileStoreState.isCreatingFileParseTask || isNull(chunkingStatus) || !chunkingStatus ? ( @@ -562,7 +593,8 @@ const FileListItem = memo( prevProps.url === nextProps.url && prevProps.columnWidths.name === nextProps.columnWidths.name && prevProps.columnWidths.date === nextProps.columnWidths.date && - prevProps.columnWidths.size === nextProps.columnWidths.size + prevProps.columnWidths.size === nextProps.columnWidths.size && + prevProps.isAnyRowHovered === nextProps.isAnyRowHovered ); }, ); diff --git a/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx b/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx index 4e5ace11c2..4da041bda5 100644 --- a/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx +++ b/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx @@ -29,6 +29,7 @@ const ListViewSkeleton = ({ key={index} paddingInline={8} style={{ + background: index % 2 === 0 ? cssVar.colorFillQuaternary : 'transparent', borderBlockEnd: `1px solid ${cssVar.colorBorderSecondary}`, opacity: getOpacity(index), }} @@ -39,21 +40,21 @@ const ListViewSkeleton = ({ - + - + diff --git a/src/features/ResourceManager/components/Explorer/ListView/index.tsx b/src/features/ResourceManager/components/Explorer/ListView/index.tsx index 1d9da00900..4ea7fbd119 100644 --- a/src/features/ResourceManager/components/Explorer/ListView/index.tsx +++ b/src/features/ResourceManager/components/Explorer/ListView/index.tsx @@ -15,6 +15,7 @@ import { } from '@/app/[variants]/(main)/resource/features/store'; import { sortFileList } from '@/app/[variants]/(main)/resource/features/store/selectors'; import { useFileStore } from '@/store/file'; +import { useFetchResources } from '@/store/file/slices/resource/hooks'; import { useGlobalStore } from '@/store/global'; import { INITIAL_STATUS } from '@/store/global/initialState'; import { type AsyncTaskStatus } from '@/types/asyncTask'; @@ -53,8 +54,11 @@ const styles = createStaticStyles(({ css }) => ({ `, })); -const ListView = memo(() => { +const ListView = memo(function ListView() { const [ + libraryId, + category, + searchQuery, selectFileIds, setSelectedFileIds, pendingRenameItemId, @@ -62,7 +66,11 @@ const ListView = memo(() => { loadMoreKnowledgeItems, sorter, sortType, + storeIsTransitioning, ] = useResourceManagerStore((s) => [ + s.libraryId, + s.category, + s.searchQuery, s.selectedFileIds, s.setSelectedFileIds, s.pendingRenameItemId, @@ -70,6 +78,7 @@ const ListView = memo(() => { s.loadMoreKnowledgeItems, s.sorter, s.sortType, + s.isTransitioning, ]); // Access column widths from Global store @@ -83,6 +92,7 @@ const ListView = memo(() => { const [isLoadingMore, setIsLoadingMore] = useState(false); const isDragActive = useDragActive(); const [isDropZoneActive, setIsDropZoneActive] = useState(false); + const [isAnyRowHovered, setIsAnyRowHovered] = useState(false); const scrollTimerRef = useRef | null>(null); const autoScrollIntervalRef = useRef | null>(null); const containerRef = useRef(null); @@ -94,6 +104,33 @@ const ListView = memo(() => { // Get current folder ID - either from breadcrumb or null for root const currentFolderId = folderBreadcrumb?.at(-1)?.id || null; + const queryParams = useMemo( + () => ({ + category: libraryId ? undefined : category, + libraryId, + parentId: currentFolderSlug || null, + q: searchQuery ?? undefined, + showFilesInKnowledgeBase: false, + sortType, + sorter, + }), + [category, currentFolderSlug, libraryId, searchQuery, sorter, sortType], + ); + + const { isLoading, isValidating } = useFetchResources(queryParams); + const { queryParams: currentQueryParams } = useFileStore(); + + const isNavigating = useMemo(() => { + if (!currentQueryParams || !queryParams) return false; + + return ( + currentQueryParams.libraryId !== queryParams.libraryId || + currentQueryParams.parentId !== queryParams.parentId || + currentQueryParams.category !== queryParams.category || + currentQueryParams.q !== queryParams.q + ); + }, [currentQueryParams, queryParams]); + const resourceList = useFileStore((s) => s.resourceList); // Map ResourceItem[] to FileListItem[] for compatibility @@ -112,6 +149,17 @@ const ListView = memo(() => { // Sort data using current sort settings const data = sortFileList(rawData, sorter, sortType) || []; + const dataLength = data.length; + const effectiveIsLoading = isLoading ?? false; + const effectiveIsNavigating = isNavigating ?? false; + const effectiveIsTransitioning = storeIsTransitioning ?? false; + const effectiveIsValidating = isValidating ?? false; + + const showSkeleton = + (effectiveIsLoading && dataLength === 0) || + (effectiveIsNavigating && effectiveIsValidating) || + effectiveIsTransitioning; + const dataRef = useRef(data); useEffect(() => { @@ -286,6 +334,8 @@ const ListView = memo(() => { return ; }, [isLoadingMore, fileListHasMore, columnWidths]); + if (showSkeleton) return ; + return (
@@ -360,7 +410,11 @@ const ListView = memo(() => {
{ { +const MasonryView = memo(function MasonryView() { // Access all state from Resource Manager store const [ libraryId, + category, + searchQuery, selectedFileIds, setSelectedFileIds, loadMoreKnowledgeItems, fileListHasMore, - isMasonryReady, + storeIsMasonryReady, sorter, sortType, + storeIsTransitioning, ] = useResourceManagerStore((s) => [ s.libraryId, + s.category, + s.searchQuery, s.selectedFileIds, s.setSelectedFileIds, s.loadMoreKnowledgeItems, @@ -34,6 +41,7 @@ const MasonryView = memo(() => { s.isMasonryReady, s.sorter, s.sortType, + s.isTransitioning, ]); const { t } = useTranslation('file'); @@ -43,6 +51,33 @@ const MasonryView = memo(() => { // NEW: Read from resource store instead of fetching independently const resourceList = useFileStore((s) => s.resourceList); + const queryParams = useMemo( + () => ({ + category: libraryId ? undefined : category, + libraryId, + parentId: null, + q: searchQuery ?? undefined, + showFilesInKnowledgeBase: false, + sortType, + sorter, + }), + [category, libraryId, searchQuery, sorter, sortType], + ); + + const { isLoading, isValidating } = useFetchResources(queryParams); + const { queryParams: currentQueryParams } = useFileStore(); + + const isNavigating = useMemo(() => { + if (!currentQueryParams || !queryParams) return false; + + return ( + currentQueryParams.libraryId !== queryParams.libraryId || + currentQueryParams.parentId !== queryParams.parentId || + currentQueryParams.category !== queryParams.category || + currentQueryParams.q !== queryParams.q + ); + }, [currentQueryParams, queryParams]); + // Map ResourceItem[] to FileListItem[] for compatibility const rawData = resourceList?.map( (item): FileListItem => ({ @@ -69,7 +104,20 @@ const MasonryView = memo(() => { ); // Sort data using current sort settings - const data = sortFileList(rawData, sorter, sortType); + const data = sortFileList(rawData, sorter, sortType) || []; + + const dataLength = data.length; + const effectiveIsLoading = isLoading ?? false; + const effectiveIsNavigating = isNavigating ?? false; + const effectiveIsValidating = isValidating ?? false; + const effectiveIsTransitioning = storeIsTransitioning ?? false; + const effectiveIsMasonryReady = storeIsMasonryReady; + + const showSkeleton = + (effectiveIsLoading && dataLength === 0) || + (effectiveIsNavigating && effectiveIsValidating) || + effectiveIsTransitioning || + !effectiveIsMasonryReady; const masonryContext = useMemo( () => ({ @@ -108,13 +156,15 @@ const MasonryView = memo(() => { [handleLoadMore], ); - return ( + return showSkeleton ? ( + + ) : (
{ ItemContent={MasonryItemWrapper} columnCount={columnCount} context={masonryContext} - data={data || []} + data={data} style={{ gap: '16px', overflow: 'hidden', @@ -147,4 +197,6 @@ const MasonryView = memo(() => { ); }); +MasonryView.displayName = 'MasonryView'; + export default MasonryView; diff --git a/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx b/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx index a1231255f3..b4b8327673 100644 --- a/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx +++ b/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx @@ -5,7 +5,7 @@ import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import FolderTree, { type FolderTreeItem } from '@/features/ResourceManager/components/FolderTree'; -import { clearTreeFolderCache } from '@/features/ResourceManager/components/Tree'; +import { clearTreeFolderCache } from '@/features/ResourceManager/components/LibraryHierarchy'; import { fileService } from '@/services/file'; import { useFileStore } from '@/store/file'; @@ -28,10 +28,7 @@ const MoveToFolderModal = memo( const [loadedFolders, setLoadedFolders] = useState>(new Set()); const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [moveResource, createFolder] = useFileStore((s) => [ - s.moveResource, - s.createFolder, - ]); + const [moveResource, createFolder] = useFileStore((s) => [s.moveResource, s.createFolder]); // Sort items: folders only const sortItems = useCallback((items: FolderTreeItem[]): FolderTreeItem[] => { diff --git a/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx b/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx index 43e38e5e7b..dcad1dd855 100644 --- a/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx +++ b/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx @@ -25,13 +25,12 @@ export type MultiSelectActionType = | 'removeFromKnowledgeBase'; interface BatchActionsDropdownProps { - disabled?: boolean; onActionClick: (type: MultiSelectActionType) => Promise; selectCount: number; } const BatchActionsDropdown = memo( - ({ selectCount, onActionClick, disabled }) => { + ({ selectCount, onActionClick }) => { const { t } = useTranslation(['components', 'common', 'file', 'knowledgeBase']); const { modal, message } = App.useApp(); @@ -54,7 +53,7 @@ const BatchActionsDropdown = memo( danger: true, icon: , key: 'deleteLibrary', - label: t('delete', { ns: 'common' }), + label: t('header.actions.deleteLibrary', { ns: 'file' }), onClick: async () => { modal.confirm({ okButtonProps: { @@ -74,6 +73,7 @@ const BatchActionsDropdown = memo( const availableKnowledgeBases = (knowledgeBases || []).filter((kb) => kb.id !== libraryId); const addToKnowledgeBaseSubmenu: DropdownItem[] = availableKnowledgeBases.map((kb) => ({ + disabled: selectCount === 0, icon: , key: `add-to-kb-${kb.id}`, label: {kb.name}, @@ -95,6 +95,7 @@ const BatchActionsDropdown = memo( if (libraryId) { items.push({ + disabled: selectCount === 0, icon: , key: 'removeFromKnowledgeBase', label: t('FileManager.actions.removeFromKnowledgeBase'), @@ -117,6 +118,7 @@ const BatchActionsDropdown = memo( if (availableKnowledgeBases.length > 0) { items.push({ children: addToKnowledgeBaseSubmenu as any, + disabled: selectCount === 0, icon: , key: 'addToOtherKnowledgeBase', label: t('FileManager.actions.addToOtherKnowledgeBase'), @@ -125,6 +127,7 @@ const BatchActionsDropdown = memo( } else if (availableKnowledgeBases.length > 0) { items.push({ children: addToKnowledgeBaseSubmenu as any, + disabled: selectCount === 0, icon: , key: 'addToKnowledgeBase', label: t('FileManager.actions.addToKnowledgeBase'), @@ -133,6 +136,7 @@ const BatchActionsDropdown = memo( items.push( { + disabled: selectCount === 0, icon: , key: 'batchChunking', label: t('FileManager.actions.batchChunking'), @@ -145,6 +149,7 @@ const BatchActionsDropdown = memo( }, { danger: true, + disabled: selectCount === 0, icon: , key: 'delete', label: t('delete', { ns: 'common' }), @@ -177,9 +182,8 @@ const BatchActionsDropdown = memo( ]); return ( - + diff --git a/src/features/ResourceManager/components/Explorer/index.tsx b/src/features/ResourceManager/components/Explorer/index.tsx index f6b3a4320c..ccd33331c5 100644 --- a/src/features/ResourceManager/components/Explorer/index.tsx +++ b/src/features/ResourceManager/components/Explorer/index.tsx @@ -12,11 +12,8 @@ import { useFetchResources, useResourceStore } from '@/store/file/slices/resourc import EmptyPlaceholder from './EmptyPlaceholder'; import Header from './Header'; import ListView from './ListView'; -import ListViewSkeleton from './ListView/Skeleton'; import MasonryView from './MasonryView'; -import MasonryViewSkeleton from './MasonryView/Skeleton'; import { useCheckTaskStatus } from './useCheckTaskStatus'; -import { useMasonryColumnCount } from './useMasonryColumnCount'; import { useResourceExplorer } from './useResourceExplorer'; /** @@ -32,27 +29,16 @@ const ResourceExplorer = memo(() => { useResourceManagerUrlSync(); // Get state from Resource Manager store - const [ - libraryId, - category, - viewMode, - isTransitioning, - isMasonryReady, - searchQuery, - setSelectedFileIds, - sorter, - sortType, - ] = useResourceManagerStore((s) => [ - s.libraryId, - s.category, - s.viewMode, - s.isTransitioning, - s.isMasonryReady, - s.searchQuery, - s.setSelectedFileIds, - s.sorter, - s.sortType, - ]); + const [libraryId, category, viewMode, searchQuery, setSelectedFileIds, sorter, sortType] = + useResourceManagerStore((s) => [ + s.libraryId, + s.category, + s.viewMode, + s.searchQuery, + s.setSelectedFileIds, + s.sorter, + s.sortType, + ]); // Get folder path for empty state check const { currentFolderSlug } = useFolderPath(); @@ -77,19 +63,7 @@ const ResourceExplorer = memo(() => { const { isLoading, isValidating } = useFetchResources(queryParams); // Get resource data from store (updated by SWR hook) - const { resourceList, queryParams: currentQueryParams } = useResourceStore(); - - // Check if we're navigating to a different view (different query params) - const isNavigating = useMemo(() => { - if (!currentQueryParams || !queryParams) return false; - - return ( - currentQueryParams.libraryId !== queryParams.libraryId || - currentQueryParams.parentId !== queryParams.parentId || - currentQueryParams.category !== queryParams.category || - currentQueryParams.q !== queryParams.q - ); - }, [currentQueryParams, queryParams]); + const { resourceList } = useResourceStore(); // Map ResourceItem[] to FileListItem[] for compatibility // TODO: Eventually update all consumers to use ResourceItem directly @@ -119,19 +93,6 @@ const ResourceExplorer = memo(() => { setSelectedFileIds([]); }, [category, libraryId, searchQuery, setSelectedFileIds]); - // Computed values - const columnCount = useMasonryColumnCount(); - - // Show skeleton when: - // 1. Initial load with no data (isLoading && no data) - // 2. Navigating to different folder/category (isNavigating && isValidating) - // 3. View mode transitions - const showSkeleton = - (isLoading && (!data || data.length >= 5)) || - (isNavigating && isValidating) || - (viewMode === 'list' && isTransitioning) || - (viewMode === 'masonry' && (isTransitioning || !isMasonryReady)); - const showEmptyStatus = !isLoading && !isValidating && data?.length === 0 && !currentFolderSlug; return ( @@ -139,12 +100,6 @@ const ResourceExplorer = memo(() => {
{showEmptyStatus ? ( - ) : showSkeleton ? ( - viewMode === 'list' ? ( - - ) : ( - - ) ) : viewMode === 'list' ? ( ) : ( diff --git a/src/features/ResourceManager/components/Header/AddButton.tsx b/src/features/ResourceManager/components/Header/AddButton.tsx index 8b85628b2a..b7773c66e3 100644 --- a/src/features/ResourceManager/components/Header/AddButton.tsx +++ b/src/features/ResourceManager/components/Header/AddButton.tsx @@ -22,7 +22,6 @@ const AddButton = () => { const { t } = useTranslation('file'); const pushDockFileList = useFileStore((s) => s.pushDockFileList); const uploadFolderWithStructure = useFileStore((s) => s.uploadFolderWithStructure); - const createResource = useFileStore((s) => s.createResource); const createResourceAndSync = useFileStore((s) => s.createResourceAndSync); // TODO: Migrate Notion import to use createResource @@ -39,9 +38,9 @@ const AddButton = () => { ]); const handleOpenPageEditor = useCallback(async () => { - // Create a new page with optimistic update - instant UI feedback + // Create a new page and wait for server sync - ensures page editor can load the document const untitledTitle = t('pageList.untitled'); - const tempId = await createResource({ + const realId = await createResourceAndSync({ content: '', fileType: 'custom/document', knowledgeBaseId: libraryId, @@ -50,10 +49,10 @@ const AddButton = () => { title: untitledTitle, }); - // Switch to page view mode immediately (temp ID works) - setCurrentViewItemId(tempId); + // Switch to page view mode with real ID + setCurrentViewItemId(realId); setMode('page'); - }, [createResource, currentFolderId, libraryId, setCurrentViewItemId, setMode, t]); + }, [createResourceAndSync, currentFolderId, libraryId, setCurrentViewItemId, setMode, t]); const handleCreateFolder = useCallback(async () => { // Create folder and wait for sync to complete before triggering rename diff --git a/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx b/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx new file mode 100644 index 0000000000..d099a727e2 --- /dev/null +++ b/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx @@ -0,0 +1,382 @@ +'use client'; + +import { CaretDownFilled, LoadingOutlined } from '@ant-design/icons'; +import { ActionIcon, Block, Flexbox, Icon, showContextMenu } from '@lobehub/ui'; +import { App, Input } from 'antd'; +import { cx } from 'antd-style'; +import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react'; +import * as motion from 'motion/react-m'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { + getTransparentDragImage, + useDragActive, + useDragState, +} from '@/app/[variants]/(main)/resource/features/DndContextWrapper'; +import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath'; +import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store'; +import FileIcon from '@/components/FileIcon'; +import { useFileStore } from '@/store/file'; + +import { useFileItemDropdown } from '../Explorer/ItemDropdown/useFileItemDropdown'; +import { styles } from './styles'; +import { clearTreeFolderCache } from './treeState'; +import type { TreeItem } from './types'; + +interface HierarchyNodeProps { + expandedFolders: Set; + folderChildrenCache: Map; + item: TreeItem; + level?: number; + loadingFolders: Set; + onLoadFolder: (_: string) => Promise; + onToggleFolder: (_: string) => void; + selectedKey: string | null; + updateKey?: number; +} + +// Row component for folder / file tree (virtualized by flattening visible nodes) +export const HierarchyNode = memo( + ({ + item, + level = 0, + expandedFolders, + loadingFolders, + onToggleFolder, + onLoadFolder, + selectedKey, + folderChildrenCache, + }) => { + const navigate = useNavigate(); + const { currentFolderSlug } = useFolderPath(); + const { message } = App.useApp(); + + const [setMode, setCurrentViewItemId, libraryId] = useResourceManagerStore((s) => [ + s.setMode, + s.setCurrentViewItemId, + s.libraryId, + ]); + + const renameFolder = useFileStore((s) => s.renameFolder); + + const [isRenaming, setIsRenaming] = useState(false); + const [renamingValue, setRenamingValue] = useState(item.name); + const inputRef = useRef(null); + + // Memoize computed values that don't change frequently + const { itemKey } = useMemo( + () => ({ + itemKey: item.slug || item.id, + }), + [item.slug, item.id], + ); + + const handleRenameStart = useCallback(() => { + setIsRenaming(true); + setRenamingValue(item.name); + // Focus input after render + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + }, [item.name]); + + const handleRenameConfirm = useCallback(async () => { + if (!renamingValue.trim()) { + message.error('Folder name cannot be empty'); + return; + } + + if (renamingValue.trim() === item.name) { + setIsRenaming(false); + return; + } + + try { + await renameFolder(item.id, renamingValue.trim()); + if (libraryId) { + await clearTreeFolderCache(libraryId); + } + message.success('Renamed successfully'); + setIsRenaming(false); + } catch (error) { + console.error('Rename error:', error); + message.error('Rename failed'); + } + }, [item.id, item.name, libraryId, renamingValue, renameFolder, message]); + + const handleRenameCancel = useCallback(() => { + setIsRenaming(false); + setRenamingValue(item.name); + }, [item.name]); + + const { menuItems } = useFileItemDropdown({ + fileType: item.fileType, + filename: item.name, + id: item.id, + libraryId, + onRenameStart: item.isFolder ? handleRenameStart : undefined, + sourceType: item.sourceType, + url: item.url, + }); + + const isDragActive = useDragActive(); + const { setCurrentDrag } = useDragState(); + const [isDragging, setIsDragging] = useState(false); + const [isOver, setIsOver] = useState(false); + + // Memoize drag data to prevent recreation + const dragData = useMemo( + () => ({ + fileType: item.fileType, + isFolder: item.isFolder, + name: item.name, + sourceType: item.sourceType, + }), + [item.fileType, item.isFolder, item.name, item.sourceType], + ); + + // Native HTML5 drag event handlers + const handleDragStart = useCallback( + (e: React.DragEvent) => { + setIsDragging(true); + setCurrentDrag({ + data: dragData, + id: item.id, + type: item.isFolder ? 'folder' : 'file', + }); + + // Set drag image to be transparent (we use custom overlay) + const img = getTransparentDragImage(); + if (img && e.dataTransfer) { + e.dataTransfer.setDragImage(img, 0, 0); + } + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + } + }, + [dragData, item.id, item.isFolder, setCurrentDrag], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!item.isFolder || !isDragActive) return; + + e.preventDefault(); + e.stopPropagation(); + setIsOver(true); + }, + [item.isFolder, isDragActive], + ); + + const handleDragLeave = useCallback(() => { + setIsOver(false); + }, []); + + const handleDrop = useCallback(() => { + // Clear the highlight after drop + setIsOver(false); + }, []); + + const handleItemClick = useCallback(() => { + // Open file modal using slug-based routing + const currentPath = currentFolderSlug + ? `/resource/library/${libraryId}/${currentFolderSlug}` + : `/resource/library/${libraryId}`; + + setCurrentViewItemId(itemKey); + navigate(`${currentPath}?file=${itemKey}`); + + if (itemKey.startsWith('doc')) { + setMode('page'); + } else { + // Set mode to 'file' immediately to prevent flickering to list view + setMode('editor'); + } + }, [itemKey, currentFolderSlug, libraryId, navigate, setMode, setCurrentViewItemId]); + + const handleFolderClick = useCallback( + (folderId: string, folderSlug?: string | null) => { + const navKey = folderSlug || folderId; + navigate(`/resource/library/${libraryId}/${navKey}`); + + setMode('explorer'); + }, + [libraryId, navigate], + ); + + if (item.isFolder) { + const isExpanded = expandedFolders.has(itemKey); + const isActive = selectedKey === itemKey; + const isLoading = loadingFolders.has(itemKey); + + const handleToggle = async () => { + // Toggle folder expansion + onToggleFolder(itemKey); + + // Only load if not already cached + if (!isExpanded && !folderChildrenCache.has(itemKey)) { + await onLoadFolder(itemKey); + } + }; + + return ( + + handleFolderClick(item.id, item.slug)} + onContextMenu={(e) => { + e.preventDefault(); + showContextMenu(menuItems()); + }} + onDragEnd={handleDragEnd} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDragStart={handleDragStart} + onDrop={handleDrop} + paddingInline={4} + style={{ + paddingInlineStart: level * 12 + 4, + }} + variant={isActive ? 'filled' : 'borderless'} + > + {isLoading ? ( + + ) : ( + + { + e.stopPropagation(); + handleToggle(); + }} + size={'small'} + style={{ width: 20 }} + /> + + )} + + + {isRenaming ? ( + setRenamingValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRenameConfirm(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRenameCancel(); + } + }} + onPointerDown={(e) => e.stopPropagation()} + ref={inputRef} + size="small" + style={{ flex: 1 }} + value={renamingValue} + /> + ) : ( + + {item.name} + + )} + + + + ); + } + + // Render as file + const isActive = selectedKey === itemKey; + return ( + + { + e.preventDefault(); + showContextMenu(menuItems()); + }} + onDragEnd={handleDragEnd} + onDragStart={handleDragStart} + paddingInline={4} + style={{ + paddingInlineStart: level * 12 + 4, + }} + variant={isActive ? 'filled' : 'borderless'} + > +
+ + {item.sourceType === 'document' ? ( + + ) : ( + + )} + + {item.name} + + + + + ); + }, +); + +HierarchyNode.displayName = 'HierarchyNode'; diff --git a/src/features/ResourceManager/components/Tree/TreeSkeleton.tsx b/src/features/ResourceManager/components/LibraryHierarchy/TreeSkeleton.tsx similarity index 100% rename from src/features/ResourceManager/components/Tree/TreeSkeleton.tsx rename to src/features/ResourceManager/components/LibraryHierarchy/TreeSkeleton.tsx diff --git a/src/features/ResourceManager/components/LibraryHierarchy/index.tsx b/src/features/ResourceManager/components/LibraryHierarchy/index.tsx new file mode 100644 index 0000000000..95d4c27391 --- /dev/null +++ b/src/features/ResourceManager/components/LibraryHierarchy/index.tsx @@ -0,0 +1,396 @@ +'use client'; + +import { Flexbox } from '@lobehub/ui'; +import { memo, useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import { VList } from 'virtua'; + +import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath'; +import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store'; +import { fileService } from '@/services/file'; +import { useFileStore } from '@/store/file'; +import type { ResourceQueryParams } from '@/types/resource'; + +import { HierarchyNode } from './HierarchyNode'; +import TreeSkeleton from './TreeSkeleton'; +import { + TREE_REFRESH_EVENT, + getTreeState, + resourceItemToTreeItem, + sortTreeItems, +} from './treeState'; +import type { TreeItem } from './types'; + +// Export for external use +export { clearTreeFolderCache } from './treeState'; + +/** + * As a sidebar along with the Explorer + */ +const LibraryHierarchy = memo(() => { + const { currentFolderSlug } = useFolderPath(); + + const [useFetchKnowledgeItems, useFetchFolderBreadcrumb, useFetchKnowledgeItem] = useFileStore( + (s) => [s.useFetchKnowledgeItems, s.useFetchFolderBreadcrumb, s.useFetchKnowledgeItem], + ); + + const [resourceList, resourceQueryParams] = useFileStore((s) => [s.resourceList, s.queryParams]); + + const [libraryId, currentViewItemId] = useResourceManagerStore((s) => [ + s.libraryId, + s.currentViewItemId, + ]); + + // Force re-render when tree state changes + const [updateKey, forceUpdate] = useReducer((x) => x + 1, 0); + + // Get the persisted state for this knowledge base + const state = useMemo(() => getTreeState(libraryId || ''), [libraryId]); + const { expandedFolders, folderChildrenCache, loadingFolders } = state; + + // Fetch breadcrumb for current folder + const { data: folderBreadcrumb } = useFetchFolderBreadcrumb(currentFolderSlug); + + // Fetch current file when viewing a file + const { data: currentFile } = useFetchKnowledgeItem(currentViewItemId); + + // Track parent folder key for file selection - stored in a ref to avoid hook order issues + const parentFolderKeyRef = useRef(null); + + // Fetch root level data using SWR + const { data: rootData, isLoading } = useFetchKnowledgeItems({ + knowledgeBaseId: libraryId, + parentId: null, + showFilesInKnowledgeBase: false, + }); + + const isExplorerCacheActiveForTree = useMemo(() => { + if (!libraryId) return false; + if (!resourceQueryParams) return false; + + // We intentionally ignore search per requirement: tree always shows full hierarchy + if (resourceQueryParams.q) return false; + + return resourceQueryParams.libraryId === libraryId; + }, [libraryId, resourceQueryParams]); + + const explorerParentKey = useMemo(() => { + if (!isExplorerCacheActiveForTree) return null; + return (resourceQueryParams as ResourceQueryParams).parentId ?? null; + }, [isExplorerCacheActiveForTree, resourceQueryParams]); + + const explorerChildren = useMemo(() => { + if (!isExplorerCacheActiveForTree) return []; + return sortTreeItems(resourceList.map(resourceItemToTreeItem)); + }, [isExplorerCacheActiveForTree, resourceList]); + + const isSameTreeItems = useCallback((a: TreeItem[] | undefined, b: TreeItem[]) => { + if (!a) return false; + if (a.length !== b.length) return false; + // Compare minimal stable identity for change detection + let i = 0; + for (const item of a) { + if (item.id !== b[i]?.id) return false; + i += 1; + } + return true; + }, []); + + // Convert root data to tree items + const items: TreeItem[] = useMemo(() => { + // If Explorer has loaded root for this library, use its cache to ensure identical state + if (isExplorerCacheActiveForTree && explorerParentKey === null) return explorerChildren; + if (!rootData) return []; + + const mappedItems: TreeItem[] = rootData.map((item) => ({ + fileType: item.fileType, + id: item.id, + isFolder: item.fileType === 'custom/folder', + name: item.name, + slug: item.slug, + sourceType: item.sourceType, + url: item.url, + })); + + return sortTreeItems(mappedItems); + }, [explorerChildren, explorerParentKey, rootData, updateKey]); + + // Hydrate tree cache for the folder Explorer has loaded (non-root only). + // This ensures the tree and explorer render identical children for that folder. + useEffect(() => { + if (!isExplorerCacheActiveForTree) return; + if (!explorerParentKey) return; // root handled via `items` memo above + + const existing = state.folderChildrenCache.get(explorerParentKey); + if (isSameTreeItems(existing, explorerChildren)) return; + + state.folderChildrenCache.set(explorerParentKey, explorerChildren); + state.loadedFolders.add(explorerParentKey); + forceUpdate(); + // NOTE: folderChildrenCache / loadedFolders are mutated in-place + }, [ + explorerChildren, + explorerParentKey, + isExplorerCacheActiveForTree, + isSameTreeItems, + state, + forceUpdate, + ]); + + const visibleNodes = useMemo(() => { + interface VisibleNode { + item: TreeItem; + key: string; + level: number; + } + + const result: VisibleNode[] = []; + + const walk = (nodes: TreeItem[], level: number) => { + for (const node of nodes) { + const key = node.slug || node.id; + + result.push({ item: node, key, level }); + + if (!node.isFolder) continue; + if (!expandedFolders.has(key)) continue; + + const children = folderChildrenCache.get(key); + if (!children || children.length === 0) continue; + + walk(children, level + 1); + } + }; + + walk(items, 0); + + return result; + // NOTE: expandedFolders / folderChildrenCache are mutated in-place, so rely on updateKey for recompute + }, [items, expandedFolders, folderChildrenCache, updateKey]); + + const handleLoadFolder = useCallback( + async (folderId: string) => { + // Set loading state + state.loadingFolders.add(folderId); + forceUpdate(); + + try { + // Prefer Explorer's cache when it matches this folder (keeps tree + explorer identical) + if (isExplorerCacheActiveForTree && explorerParentKey === folderId) { + state.folderChildrenCache.set(folderId, explorerChildren); + state.loadedFolders.add(folderId); + return; + } + + // Use SWR mutate to trigger a fetch that will be cached and shared with FileExplorer + const { mutate: swrMutate } = await import('swr'); + const response = await swrMutate( + [ + 'useFetchKnowledgeItems', + { + knowledgeBaseId: libraryId, + parentId: folderId, + showFilesInKnowledgeBase: false, + }, + ], + () => + fileService.getKnowledgeItems({ + knowledgeBaseId: libraryId, + parentId: folderId, + showFilesInKnowledgeBase: false, + }), + { + revalidate: false, // Don't revalidate immediately after mutation + }, + ); + + if (!response || !response.items) { + console.error('Failed to load folder contents: no data returned'); + return; + } + + const childItems: TreeItem[] = response.items.map((item) => ({ + fileType: item.fileType, + id: item.id, + isFolder: item.fileType === 'custom/folder', + name: item.name, + slug: item.slug, + sourceType: item.sourceType, + url: item.url, + })); + + // Sort children: folders first, then files + const sortedChildren = sortTreeItems(childItems); + + // Store children in cache + state.folderChildrenCache.set(folderId, sortedChildren); + state.loadedFolders.add(folderId); + } catch (error) { + console.error('Failed to load folder contents:', error); + } finally { + // Clear loading state + state.loadingFolders.delete(folderId); + // Trigger re-render + forceUpdate(); + } + }, + [ + explorerChildren, + explorerParentKey, + forceUpdate, + isExplorerCacheActiveForTree, + libraryId, + state, + ], + ); + + const handleToggleFolder = useCallback( + (folderId: string) => { + if (state.expandedFolders.has(folderId)) { + state.expandedFolders.delete(folderId); + } else { + state.expandedFolders.add(folderId); + } + // Trigger re-render + forceUpdate(); + }, + [state, forceUpdate], + ); + + // Reset parent folder key when switching libraries + useEffect(() => { + parentFolderKeyRef.current = null; + }, [libraryId]); + + // Listen for external tree refresh events (triggered when cache is cleared) + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleTreeRefresh = (event: Event) => { + const detail = (event as CustomEvent<{ knowledgeBaseId?: string }>).detail; + if (detail?.knowledgeBaseId && libraryId && detail.knowledgeBaseId !== libraryId) return; + forceUpdate(); + }; + + window.addEventListener(TREE_REFRESH_EVENT, handleTreeRefresh); + return () => { + window.removeEventListener(TREE_REFRESH_EVENT, handleTreeRefresh); + }; + }, [libraryId, forceUpdate]); + + // Auto-expand folders when navigating to a folder in Explorer + useEffect(() => { + if (!folderBreadcrumb || folderBreadcrumb.length === 0) return; + + let hasChanges = false; + + // Expand all folders in the breadcrumb path + for (const crumb of folderBreadcrumb) { + const key = crumb.slug || crumb.id; + if (!state.expandedFolders.has(key)) { + state.expandedFolders.add(key); + hasChanges = true; + } + + // Load folder contents if not already loaded + if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) { + handleLoadFolder(key); + } + } + + if (hasChanges) { + forceUpdate(); + } + }, [folderBreadcrumb, state, forceUpdate, handleLoadFolder]); + + // Auto-expand parent folder when viewing a file + useEffect(() => { + if (!currentFile || !currentViewItemId) { + parentFolderKeyRef.current = null; + return; + } + + // If the file has a parent folder, expand the path to it + if (currentFile.parentId) { + // Fetch the parent folder's breadcrumb to get the full path + const fetchParentPath = async () => { + try { + const parentBreadcrumb = await fileService.getFolderBreadcrumb(currentFile.parentId!); + + if (!parentBreadcrumb || parentBreadcrumb.length === 0) return; + + let hasChanges = false; + + // The last item in breadcrumb is the immediate parent folder + const parentFolder = parentBreadcrumb.at(-1)!; + const parentKey = parentFolder.slug || parentFolder.id; + parentFolderKeyRef.current = parentKey; + + // Expand all folders in the parent's breadcrumb path + for (const crumb of parentBreadcrumb) { + const key = crumb.slug || crumb.id; + if (!state.expandedFolders.has(key)) { + state.expandedFolders.add(key); + hasChanges = true; + } + + // Load folder contents if not already loaded + if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) { + handleLoadFolder(key); + } + } + + if (hasChanges) { + forceUpdate(); + } + } catch (error) { + console.error('Failed to fetch parent folder breadcrumb:', error); + } + }; + + fetchParentPath(); + } else { + parentFolderKeyRef.current = null; + } + }, [currentFile, currentViewItemId, state, forceUpdate, handleLoadFolder]); + + if (isLoading) { + return ; + } + + // Determine which item should be highlighted + // If viewing a file, highlight its parent folder + // Otherwise, highlight the current folder + const selectedKey = + currentViewItemId && parentFolderKeyRef.current + ? parentFolderKeyRef.current + : currentFolderSlug; + + return ( + + + {visibleNodes.map(({ item, key, level }) => ( +
+ +
+ ))} +
+
+ ); +}); + +LibraryHierarchy.displayName = 'FileTree'; + +export default LibraryHierarchy; diff --git a/src/features/ResourceManager/components/LibraryHierarchy/styles.ts b/src/features/ResourceManager/components/LibraryHierarchy/styles.ts new file mode 100644 index 0000000000..0b1faa6065 --- /dev/null +++ b/src/features/ResourceManager/components/LibraryHierarchy/styles.ts @@ -0,0 +1,19 @@ +import { createStaticStyles } from 'antd-style'; + +export const styles = createStaticStyles(({ css, cssVar }) => ({ + dragging: css` + will-change: transform; + opacity: 0.5; + `, + fileItemDragOver: css` + color: ${cssVar.colorBgElevated} !important; + background-color: ${cssVar.colorText} !important; + + * { + color: ${cssVar.colorBgElevated} !important; + } + `, + treeItem: css` + cursor: pointer; + `, +})); diff --git a/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts b/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts new file mode 100644 index 0000000000..b883f17c6c --- /dev/null +++ b/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts @@ -0,0 +1,178 @@ +import { fileService } from '@/services/file'; +import { useFileStore } from '@/store/file'; +import type { ResourceItem } from '@/types/resource'; + +import type { TreeItem } from './types'; + +export const sortTreeItems = (items: T[]): T[] => { + return [...items].sort((a, b) => { + // Folders first + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + // Then alphabetically by name + return a.name.localeCompare(b.name); + }); +}; + +export const resourceItemToTreeItem = (item: ResourceItem): TreeItem => { + return { + fileType: item.fileType, + id: item.id, + isFolder: item.fileType === 'custom/folder', + name: item.name, + slug: item.slug, + sourceType: item.sourceType, + url: item.url || '', + }; +}; + +// Module-level state to persist expansion across re-renders +const treeState = new Map< + string, + { + expandedFolders: Set; + folderChildrenCache: Map; + loadedFolders: Set; + loadingFolders: Set; + } +>(); + +export const TREE_REFRESH_EVENT = 'resource-tree-refresh'; + +export const emitTreeRefresh = (knowledgeBaseId: string) => { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(TREE_REFRESH_EVENT, { + detail: { knowledgeBaseId }, + }), + ); +}; + +export const getTreeState = (knowledgeBaseId: string) => { + if (!treeState.has(knowledgeBaseId)) { + treeState.set(knowledgeBaseId, { + expandedFolders: new Set(), + folderChildrenCache: new Map(), + loadedFolders: new Set(), + loadingFolders: new Set(), + }); + } + return treeState.get(knowledgeBaseId)!; +}; + +/** + * Clear and reload all expanded folders + * This should be called along with file store's refreshFileList() + * Simpler approach: reload all expanded folders to avoid ID vs slug issues + */ +export const clearTreeFolderCache = async (knowledgeBaseId: string) => { + const state = treeState.get(knowledgeBaseId); + if (!state) return; + + const { resourceList } = useFileStore.getState(); + + const resolveParentId = (key: string | null | undefined) => { + if (!key) return null; + // Prefer id match + const byId = resourceList.find( + (item) => item.knowledgeBaseId === knowledgeBaseId && item.id === key, + ); + if (byId) return byId.id; + // Fallback to slug match + const bySlug = resourceList.find( + (item) => item.knowledgeBaseId === knowledgeBaseId && item.slug === key, + ); + return bySlug?.id ?? key; + }; + + const buildChildrenFromStore = (parentKey: string | null) => { + const parentId = resolveParentId(parentKey); + const items = resourceList + .filter( + (item) => + item.knowledgeBaseId === knowledgeBaseId && + (item.parentId ?? null) === (parentId ?? null), + ) + .map(resourceItemToTreeItem); + + return sortTreeItems(items); + }; + + // Get list of all currently expanded folders before clearing + const expandedFoldersList = Array.from(state.expandedFolders); + + // Clear all caches + state.folderChildrenCache.clear(); + state.loadedFolders.clear(); + + // Reload each expanded folder + for (const folderKey of expandedFoldersList) { + // Prefer local store (explorer data) to avoid stale remote state + const localChildren = buildChildrenFromStore(folderKey); + if (localChildren.length > 0) { + state.folderChildrenCache.set(folderKey, localChildren); + state.loadedFolders.add(folderKey); + continue; + } + + // Fallback to remote fetch if store has no data (e.g., initial load) + try { + const response = await fileService.getKnowledgeItems({ + knowledgeBaseId, + parentId: folderKey, + showFilesInKnowledgeBase: false, + }); + + if (response?.items) { + const childItems = response.items.map((item) => ({ + fileType: item.fileType, + id: item.id, + isFolder: item.fileType === 'custom/folder', + name: item.name, + slug: item.slug, + sourceType: item.sourceType, + url: item.url, + })); + + // Sort children: folders first, then files + const sortedChildren = childItems.sort((a, b) => { + if (a.isFolder && !b.isFolder) return -1; + if (!a.isFolder && b.isFolder) return 1; + return a.name.localeCompare(b.name); + }); + + state.folderChildrenCache.set(folderKey, sortedChildren); + state.loadedFolders.add(folderKey); + } + } catch (error) { + console.error(`Failed to reload folder ${folderKey}:`, error); + } + } + + // Revalidate SWR caches for root and expanded folders to keep list and tree in sync + try { + const { mutate } = await import('swr'); + const revalidateFolder = (parentId: string | null) => + mutate( + [ + 'useFetchKnowledgeItems', + { + knowledgeBaseId, + parentId, + showFilesInKnowledgeBase: false, + }, + ], + undefined, + { revalidate: true }, + ); + + await Promise.all([ + revalidateFolder(null), + ...expandedFoldersList.map((folderKey) => revalidateFolder(folderKey)), + ]); + } catch (error) { + console.error('Failed to revalidate tree SWR cache:', error); + } + + emitTreeRefresh(knowledgeBaseId); +}; diff --git a/src/features/ResourceManager/components/LibraryHierarchy/types.ts b/src/features/ResourceManager/components/LibraryHierarchy/types.ts new file mode 100644 index 0000000000..fedc7da99a --- /dev/null +++ b/src/features/ResourceManager/components/LibraryHierarchy/types.ts @@ -0,0 +1,10 @@ +export interface TreeItem { + children?: TreeItem[]; + fileType: string; + id: string; + isFolder: boolean; + name: string; + slug?: string | null; + sourceType?: string; + url: string; +} diff --git a/src/features/ResourceManager/components/Tree/index.tsx b/src/features/ResourceManager/components/Tree/index.tsx deleted file mode 100644 index 644a4ecf4a..0000000000 --- a/src/features/ResourceManager/components/Tree/index.tsx +++ /dev/null @@ -1,883 +0,0 @@ -'use client'; - -import { CaretDownFilled, LoadingOutlined } from '@ant-design/icons'; -import { ActionIcon, Block, Flexbox, Icon, showContextMenu } from '@lobehub/ui'; -import { App, Input } from 'antd'; -import { createStaticStyles, cx } from 'antd-style'; -import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react'; -import * as motion from 'motion/react-m'; -import React, { memo, useCallback, useMemo, useReducer, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { VList } from 'virtua'; - -import { - getTransparentDragImage, - useDragActive, - useDragState, -} from '@/app/[variants]/(main)/resource/features/DndContextWrapper'; -import { useFolderPath } from '@/app/[variants]/(main)/resource/features/hooks/useFolderPath'; -import { useResourceManagerStore } from '@/app/[variants]/(main)/resource/features/store'; -import FileIcon from '@/components/FileIcon'; -import { fileService } from '@/services/file'; -import { useFileStore } from '@/store/file'; -import type { ResourceItem } from '@/types/resource'; - -import { useFileItemDropdown } from '../Explorer/ItemDropdown/useFileItemDropdown'; -import TreeSkeleton from './TreeSkeleton'; - -// Module-level state to persist expansion across re-renders -const treeState = new Map< - string, - { - expandedFolders: Set; - folderChildrenCache: Map; - loadedFolders: Set; - loadingFolders: Set; - } ->(); - -const TREE_REFRESH_EVENT = 'resource-tree-refresh'; - -const emitTreeRefresh = (knowledgeBaseId: string) => { - if (typeof window === 'undefined') return; - window.dispatchEvent( - new CustomEvent(TREE_REFRESH_EVENT, { - detail: { knowledgeBaseId }, - }), - ); -}; - -const getTreeState = (knowledgeBaseId: string) => { - if (!treeState.has(knowledgeBaseId)) { - treeState.set(knowledgeBaseId, { - expandedFolders: new Set(), - folderChildrenCache: new Map(), - loadedFolders: new Set(), - loadingFolders: new Set(), - }); - } - return treeState.get(knowledgeBaseId)!; -}; - -/** - * Clear and reload all expanded folders - * This should be called along with file store's refreshFileList() - * Simpler approach: reload all expanded folders to avoid ID vs slug issues - */ -export const clearTreeFolderCache = async (knowledgeBaseId: string) => { - const state = treeState.get(knowledgeBaseId); - if (!state) return; - - const { resourceList } = useFileStore.getState(); - - const resolveParentId = (key: string | null | undefined) => { - if (!key) return null; - // Prefer id match - const byId = resourceList.find( - (item) => item.knowledgeBaseId === knowledgeBaseId && item.id === key, - ); - if (byId) return byId.id; - // Fallback to slug match - const bySlug = resourceList.find( - (item) => item.knowledgeBaseId === knowledgeBaseId && item.slug === key, - ); - return bySlug?.id ?? key; - }; - - const buildChildrenFromStore = (parentKey: string | null) => { - const parentId = resolveParentId(parentKey); - return resourceList - .filter( - (item) => - item.knowledgeBaseId === knowledgeBaseId && - (item.parentId ?? null) === (parentId ?? null), - ) - .map((item) => item) - .map((item) => ({ - fileType: item.fileType, - id: item.id, - isFolder: item.fileType === 'custom/folder', - name: item.name, - slug: item.slug, - sourceType: item.sourceType, - url: item.url || '', - })) - .sort((a, b) => { - if (a.isFolder && !b.isFolder) return -1; - if (!a.isFolder && b.isFolder) return 1; - return a.name.localeCompare(b.name); - }); - }; - - // Get list of all currently expanded folders before clearing - const expandedFoldersList = Array.from(state.expandedFolders); - - // Clear all caches - state.folderChildrenCache.clear(); - state.loadedFolders.clear(); - - // Reload each expanded folder - for (const folderKey of expandedFoldersList) { - // Prefer local store (explorer data) to avoid stale remote state - const localChildren = buildChildrenFromStore(folderKey); - if (localChildren.length > 0) { - state.folderChildrenCache.set(folderKey, localChildren); - state.loadedFolders.add(folderKey); - continue; - } - - // Fallback to remote fetch if store has no data (e.g., initial load) - try { - const response = await fileService.getKnowledgeItems({ - knowledgeBaseId, - parentId: folderKey, - showFilesInKnowledgeBase: false, - }); - - if (response?.items) { - const childItems = response.items.map((item) => ({ - fileType: item.fileType, - id: item.id, - isFolder: item.fileType === 'custom/folder', - name: item.name, - slug: item.slug, - sourceType: item.sourceType, - url: item.url, - })); - - // Sort children: folders first, then files - const sortedChildren = childItems.sort((a, b) => { - if (a.isFolder && !b.isFolder) return -1; - if (!a.isFolder && b.isFolder) return 1; - return a.name.localeCompare(b.name); - }); - - state.folderChildrenCache.set(folderKey, sortedChildren); - state.loadedFolders.add(folderKey); - } - } catch (error) { - console.error(`Failed to reload folder ${folderKey}:`, error); - } - } - - // Revalidate SWR caches for root and expanded folders to keep list and tree in sync - try { - const { mutate } = await import('swr'); - const revalidateFolder = (parentId: string | null) => - mutate( - [ - 'useFetchKnowledgeItems', - { - knowledgeBaseId, - parentId, - showFilesInKnowledgeBase: false, - }, - ], - undefined, - { revalidate: true }, - ); - - await Promise.all([ - revalidateFolder(null), - ...expandedFoldersList.map((folderKey) => revalidateFolder(folderKey)), - ]); - } catch (error) { - console.error('Failed to revalidate tree SWR cache:', error); - } - - emitTreeRefresh(knowledgeBaseId); -}; - -const styles = createStaticStyles(({ css, cssVar }) => ({ - dragging: css` - will-change: transform; - opacity: 0.5; - `, - fileItemDragOver: css` - color: ${cssVar.colorBgElevated} !important; - background-color: ${cssVar.colorText} !important; - - * { - color: ${cssVar.colorBgElevated} !important; - } - `, - treeItem: css` - cursor: pointer; - `, -})); - -interface TreeItem { - children?: TreeItem[]; - fileType: string; - id: string; - isFolder: boolean; - name: string; - slug?: string | null; - sourceType?: string; - url: string; -} - -// Row component for folder / file tree (virtualized by flattening visible nodes) -const FileTreeRow = memo<{ - expandedFolders: Set; - folderChildrenCache: Map; - item: TreeItem; - level?: number; - loadingFolders: Set; - onLoadFolder: (_: string) => Promise; - onToggleFolder: (_: string) => void; - selectedKey: string | null; - updateKey?: number; -}>( - ({ - item, - level = 0, - expandedFolders, - loadingFolders, - onToggleFolder, - onLoadFolder, - selectedKey, - - folderChildrenCache, - }) => { - const navigate = useNavigate(); - const { currentFolderSlug } = useFolderPath(); - const { message } = App.useApp(); - - const [setMode, setCurrentViewItemId, libraryId] = useResourceManagerStore((s) => [ - s.setMode, - s.setCurrentViewItemId, - s.libraryId, - ]); - - const renameFolder = useFileStore((s) => s.renameFolder); - - const [isRenaming, setIsRenaming] = useState(false); - const [renamingValue, setRenamingValue] = useState(item.name); - const inputRef = useRef(null); - - // Memoize computed values that don't change frequently - const { itemKey } = useMemo( - () => ({ - itemKey: item.slug || item.id, - }), - [item.slug, item.id], - ); - - const handleRenameStart = useCallback(() => { - setIsRenaming(true); - setRenamingValue(item.name); - // Focus input after render - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - }, [item.name]); - - const handleRenameConfirm = useCallback(async () => { - if (!renamingValue.trim()) { - message.error('Folder name cannot be empty'); - return; - } - - if (renamingValue.trim() === item.name) { - setIsRenaming(false); - return; - } - - try { - await renameFolder(item.id, renamingValue.trim()); - if (libraryId) { - await clearTreeFolderCache(libraryId); - } - message.success('Renamed successfully'); - setIsRenaming(false); - } catch (error) { - console.error('Rename error:', error); - message.error('Rename failed'); - } - }, [item.id, item.name, libraryId, renamingValue, renameFolder, message]); - - const handleRenameCancel = useCallback(() => { - setIsRenaming(false); - setRenamingValue(item.name); - }, [item.name]); - - const { menuItems } = useFileItemDropdown({ - fileType: item.fileType, - filename: item.name, - id: item.id, - libraryId, - onRenameStart: item.isFolder ? handleRenameStart : undefined, - sourceType: item.sourceType, - url: item.url, - }); - - const isDragActive = useDragActive(); - const { setCurrentDrag } = useDragState(); - const [isDragging, setIsDragging] = useState(false); - const [isOver, setIsOver] = useState(false); - - // Memoize drag data to prevent recreation - const dragData = useMemo( - () => ({ - fileType: item.fileType, - isFolder: item.isFolder, - name: item.name, - sourceType: item.sourceType, - }), - [item.fileType, item.isFolder, item.name, item.sourceType], - ); - - // Native HTML5 drag event handlers - const handleDragStart = useCallback( - (e: React.DragEvent) => { - setIsDragging(true); - setCurrentDrag({ - data: dragData, - id: item.id, - type: item.isFolder ? 'folder' : 'file', - }); - - // Set drag image to be transparent (we use custom overlay) - const img = getTransparentDragImage(); - if (img) { - e.dataTransfer.setDragImage(img, 0, 0); - } - e.dataTransfer.effectAllowed = 'move'; - }, - [dragData, item.id, item.isFolder, setCurrentDrag], - ); - - const handleDragEnd = useCallback(() => { - setIsDragging(false); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - if (!item.isFolder || !isDragActive) return; - - e.preventDefault(); - e.stopPropagation(); - setIsOver(true); - }, - [item.isFolder, isDragActive], - ); - - const handleDragLeave = useCallback(() => { - setIsOver(false); - }, []); - - const handleDrop = useCallback(() => { - // Clear the highlight after drop - setIsOver(false); - }, []); - - const handleItemClick = useCallback(() => { - // Open file modal using slug-based routing - const currentPath = currentFolderSlug - ? `/resource/library/${libraryId}/${currentFolderSlug}` - : `/resource/library/${libraryId}`; - - setCurrentViewItemId(itemKey); - navigate(`${currentPath}?file=${itemKey}`); - - if (itemKey.startsWith('doc')) { - setMode('page'); - } else { - // Set mode to 'file' immediately to prevent flickering to list view - setMode('editor'); - } - }, [itemKey, currentFolderSlug, libraryId, navigate, setMode, setCurrentViewItemId]); - - const handleFolderClick = useCallback( - (folderId: string, folderSlug?: string | null) => { - const navKey = folderSlug || folderId; - navigate(`/resource/library/${libraryId}/${navKey}`); - - setMode('explorer'); - }, - [libraryId, navigate], - ); - - if (item.isFolder) { - const isExpanded = expandedFolders.has(itemKey); - const isActive = selectedKey === itemKey; - const isLoading = loadingFolders.has(itemKey); - - const handleToggle = async () => { - // Toggle folder expansion - onToggleFolder(itemKey); - - // Only load if not already cached - if (!isExpanded && !folderChildrenCache.has(itemKey)) { - await onLoadFolder(itemKey); - } - }; - - return ( - - handleFolderClick(item.id, item.slug)} - onContextMenu={(e) => { - e.preventDefault(); - showContextMenu(menuItems()); - }} - onDragEnd={handleDragEnd} - onDragLeave={handleDragLeave} - onDragOver={handleDragOver} - onDragStart={handleDragStart} - onDrop={handleDrop} - paddingInline={4} - style={{ - paddingInlineStart: level * 12 + 4, - }} - variant={isActive ? 'filled' : 'borderless'} - > - {isLoading ? ( - - ) : ( - - { - e.stopPropagation(); - handleToggle(); - }} - size={'small'} - style={{ width: 20 }} - /> - - )} - - - {isRenaming ? ( - setRenamingValue(e.target.value)} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleRenameConfirm(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - onPointerDown={(e) => e.stopPropagation()} - ref={inputRef} - size="small" - style={{ flex: 1 }} - value={renamingValue} - /> - ) : ( - - {item.name} - - )} - - - - ); - } - - // Render as file - const isActive = selectedKey === itemKey; - return ( - - { - e.preventDefault(); - showContextMenu(menuItems()); - }} - onDragEnd={handleDragEnd} - onDragStart={handleDragStart} - paddingInline={4} - style={{ - paddingInlineStart: level * 12 + 4, - }} - variant={isActive ? 'filled' : 'borderless'} - > -
- - {item.sourceType === 'document' ? ( - - ) : ( - - )} - - {item.name} - - - - - ); - }, -); - -FileTreeRow.displayName = 'FileTreeRow'; - -/** - * As a sidebar along with the Explorer to work - */ -const FileTree = memo(() => { - const { currentFolderSlug } = useFolderPath(); - - const [useFetchKnowledgeItems, useFetchFolderBreadcrumb, useFetchKnowledgeItem] = useFileStore( - (s) => [s.useFetchKnowledgeItems, s.useFetchFolderBreadcrumb, s.useFetchKnowledgeItem], - ); - - const [libraryId, currentViewItemId] = useResourceManagerStore((s) => [ - s.libraryId, - s.currentViewItemId, - ]); - - // Force re-render when tree state changes - const [updateKey, forceUpdate] = useReducer((x) => x + 1, 0); - - // Get the persisted state for this knowledge base - const state = React.useMemo(() => getTreeState(libraryId || ''), [libraryId]); - const { expandedFolders, folderChildrenCache, loadingFolders } = state; - - // Fetch breadcrumb for current folder - const { data: folderBreadcrumb } = useFetchFolderBreadcrumb(currentFolderSlug); - - // Fetch current file when viewing a file - const { data: currentFile } = useFetchKnowledgeItem(currentViewItemId); - - // Track parent folder key for file selection - stored in a ref to avoid hook order issues - const parentFolderKeyRef = React.useRef(null); - - // Fetch root level data using SWR - const { data: rootData, isLoading } = useFetchKnowledgeItems({ - knowledgeBaseId: libraryId, - parentId: null, - showFilesInKnowledgeBase: false, - }); - - // Sort items: folders first, then files - const sortItems = useCallback((items: T[]): T[] => { - return [...items].sort((a, b) => { - // Folders first - if (a.isFolder && !b.isFolder) return -1; - if (!a.isFolder && b.isFolder) return 1; - // Then alphabetically by name - return a.name.localeCompare(b.name); - }); - }, []); - - // Convert root data to tree items - const items: TreeItem[] = React.useMemo(() => { - if (!rootData) return []; - - const mappedItems: TreeItem[] = rootData.map((item) => ({ - fileType: item.fileType, - id: item.id, - isFolder: item.fileType === 'custom/folder', - name: item.name, - slug: item.slug, - sourceType: item.sourceType, - url: item.url, - })); - - return sortItems(mappedItems); - }, [rootData, sortItems, updateKey]); - - const visibleNodes = React.useMemo(() => { - interface VisibleNode { - item: TreeItem; - key: string; - level: number; - } - - const result: VisibleNode[] = []; - - const walk = (nodes: TreeItem[], level: number) => { - for (const node of nodes) { - const key = node.slug || node.id; - - result.push({ item: node, key, level }); - - if (!node.isFolder) continue; - if (!expandedFolders.has(key)) continue; - - const children = folderChildrenCache.get(key); - if (!children || children.length === 0) continue; - - walk(children, level + 1); - } - }; - - walk(items, 0); - - return result; - // NOTE: expandedFolders / folderChildrenCache are mutated in-place, so rely on updateKey for recompute - }, [items, expandedFolders, folderChildrenCache, updateKey]); - - const handleLoadFolder = useCallback( - async (folderId: string) => { - // Set loading state - state.loadingFolders.add(folderId); - forceUpdate(); - - try { - // Use SWR mutate to trigger a fetch that will be cached and shared with FileExplorer - const { mutate: swrMutate } = await import('swr'); - const response = await swrMutate( - [ - 'useFetchKnowledgeItems', - { - knowledgeBaseId: libraryId, - parentId: folderId, - showFilesInKnowledgeBase: false, - }, - ], - () => - fileService.getKnowledgeItems({ - knowledgeBaseId: libraryId, - parentId: folderId, - showFilesInKnowledgeBase: false, - }), - { - revalidate: false, // Don't revalidate immediately after mutation - }, - ); - - if (!response || !response.items) { - console.error('Failed to load folder contents: no data returned'); - return; - } - - const childItems: TreeItem[] = response.items.map((item) => ({ - fileType: item.fileType, - id: item.id, - isFolder: item.fileType === 'custom/folder', - name: item.name, - slug: item.slug, - sourceType: item.sourceType, - url: item.url, - })); - - // Sort children: folders first, then files - const sortedChildren = sortItems(childItems); - - // Store children in cache - state.folderChildrenCache.set(folderId, sortedChildren); - state.loadedFolders.add(folderId); - } catch (error) { - console.error('Failed to load folder contents:', error); - } finally { - // Clear loading state - state.loadingFolders.delete(folderId); - // Trigger re-render - forceUpdate(); - } - }, - [libraryId, sortItems, state, forceUpdate], - ); - - const handleToggleFolder = useCallback( - (folderId: string) => { - if (state.expandedFolders.has(folderId)) { - state.expandedFolders.delete(folderId); - } else { - state.expandedFolders.add(folderId); - } - // Trigger re-render - forceUpdate(); - }, - [state, forceUpdate], - ); - - // Reset parent folder key when switching libraries - React.useEffect(() => { - parentFolderKeyRef.current = null; - }, [libraryId]); - - // Listen for external tree refresh events (triggered when cache is cleared) - React.useEffect(() => { - if (typeof window === 'undefined') return; - - const handleTreeRefresh = (event: Event) => { - const detail = (event as CustomEvent<{ knowledgeBaseId?: string }>).detail; - if (detail?.knowledgeBaseId && libraryId && detail.knowledgeBaseId !== libraryId) return; - forceUpdate(); - }; - - window.addEventListener(TREE_REFRESH_EVENT, handleTreeRefresh); - return () => { - window.removeEventListener(TREE_REFRESH_EVENT, handleTreeRefresh); - }; - }, [libraryId, forceUpdate]); - - // Auto-expand folders when navigating to a folder in Explorer - React.useEffect(() => { - if (!folderBreadcrumb || folderBreadcrumb.length === 0) return; - - let hasChanges = false; - - // Expand all folders in the breadcrumb path - for (const crumb of folderBreadcrumb) { - const key = crumb.slug || crumb.id; - if (!state.expandedFolders.has(key)) { - state.expandedFolders.add(key); - hasChanges = true; - } - - // Load folder contents if not already loaded - if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) { - handleLoadFolder(key); - } - } - - if (hasChanges) { - forceUpdate(); - } - }, [folderBreadcrumb, state, forceUpdate, handleLoadFolder]); - - // Auto-expand parent folder when viewing a file - React.useEffect(() => { - if (!currentFile || !currentViewItemId) { - parentFolderKeyRef.current = null; - return; - } - - // If the file has a parent folder, expand the path to it - if (currentFile.parentId) { - // Fetch the parent folder's breadcrumb to get the full path - const fetchParentPath = async () => { - try { - const parentBreadcrumb = await fileService.getFolderBreadcrumb(currentFile.parentId!); - - if (!parentBreadcrumb || parentBreadcrumb.length === 0) return; - - let hasChanges = false; - - // The last item in breadcrumb is the immediate parent folder - const parentFolder = parentBreadcrumb.at(-1)!; - const parentKey = parentFolder.slug || parentFolder.id; - parentFolderKeyRef.current = parentKey; - - // Expand all folders in the parent's breadcrumb path - for (const crumb of parentBreadcrumb) { - const key = crumb.slug || crumb.id; - if (!state.expandedFolders.has(key)) { - state.expandedFolders.add(key); - hasChanges = true; - } - - // Load folder contents if not already loaded - if (!state.loadedFolders.has(key) && !state.loadingFolders.has(key)) { - handleLoadFolder(key); - } - } - - if (hasChanges) { - forceUpdate(); - } - } catch (error) { - console.error('Failed to fetch parent folder breadcrumb:', error); - } - }; - - fetchParentPath(); - } else { - parentFolderKeyRef.current = null; - } - }, [currentFile, currentViewItemId, state, forceUpdate, handleLoadFolder]); - - if (isLoading) { - return ; - } - - // Determine which item should be highlighted - // If viewing a file, highlight its parent folder - // Otherwise, highlight the current folder - const selectedKey = - currentViewItemId && parentFolderKeyRef.current - ? parentFolderKeyRef.current - : currentFolderSlug; - - return ( - - - {visibleNodes.map(({ item, key, level }) => ( -
- -
- ))} -
-
- ); -}); - -FileTree.displayName = 'FileTree'; - -export default FileTree; diff --git a/src/features/ResourceManager/index.tsx b/src/features/ResourceManager/index.tsx index bef8634060..81c2b80772 100644 --- a/src/features/ResourceManager/index.tsx +++ b/src/features/ResourceManager/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { BRANDING_NAME } from '@lobechat/business-const'; import { Flexbox } from '@lobehub/ui'; import { createStyles, cssVar } from 'antd-style'; import dynamic from 'next/dynamic'; @@ -91,6 +92,8 @@ const ResourceManager = memo(() => { prev.delete('file'); return prev; }); + // Reset document title to default + document.title = BRANDING_NAME; }; return ( diff --git a/src/locales/default/auth.ts b/src/locales/default/auth.ts index e0b72a63af..4943fe0fb3 100644 --- a/src/locales/default/auth.ts +++ b/src/locales/default/auth.ts @@ -194,7 +194,7 @@ export default { 'profile.usernameRule': 'Username can only contain letters, numbers, or underscores', 'profile.usernameUpdateFailed': 'Failed to update username, please try again later', 'signin.subtitle': 'Sign up or log in to your {{appName}} account', - 'signin.title': 'For Agents collaboration', + 'signin.title': 'Where Agents Collaborate', 'signout': 'Log Out', 'signup': 'Sign Up', 'stats.aiheatmaps': 'Activity Index', diff --git a/src/locales/default/file.ts b/src/locales/default/file.ts index 79a963c8ff..665358c813 100644 --- a/src/locales/default/file.ts +++ b/src/locales/default/file.ts @@ -20,6 +20,7 @@ export default { 'header.actions.connect': 'Connect...', 'header.actions.createFolderError': 'Failed to create folder', 'header.actions.creatingFolder': 'Creating folder...', + 'header.actions.deleteLibrary': 'Delete Library', 'header.actions.gitignore.apply': 'Apply Rules', 'header.actions.gitignore.cancel': 'Ignore Rules', 'header.actions.gitignore.content': @@ -118,6 +119,7 @@ export default { 'preview.downloadFile': 'Download File', 'preview.unsupportedFileAndContact': 'This file format is not currently supported for online preview. If you have a request for previewing, feel free to <1>contact us.', + 'resource': 'Resource', 'searchFilePlaceholder': 'Search Files', 'searchPagePlaceholder': 'Search Pages', 'tab.all': 'All', diff --git a/src/locales/default/metadata.ts b/src/locales/default/metadata.ts index d6948d58a5..1c39098373 100644 --- a/src/locales/default/metadata.ts +++ b/src/locales/default/metadata.ts @@ -3,7 +3,7 @@ export default { 'changelog.title': 'Changelog', 'chat.description': '{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.', - 'chat.title': '{{appName}} · For Collaborative Agents', + 'chat.title': '{{appName}} · Where Agents Collaborate', 'discover.assistants.description': 'Content, Q&A, images, video, voice, workflows—browse and add Agents from the Community.', 'discover.assistants.title': 'Agent Community', @@ -30,5 +30,5 @@ export default { 'plugins.title': 'Skill Community', 'welcome.description': '{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.', - 'welcome.title': 'Welcome to {{appName}} · For Collaborative Agents', + 'welcome.title': 'Welcome to {{appName}} · Where Agents Collaborate', }; diff --git a/src/server/services/document/index.ts b/src/server/services/document/index.ts index 701ffc9182..e33774cc8b 100644 --- a/src/server/services/document/index.ts +++ b/src/server/services/document/index.ts @@ -86,6 +86,7 @@ export class DocumentService { fileId, fileType, filename: title, + knowledgeBaseId, // Set knowledge_base_id column for all document types metadata: finalMetadata, pages: undefined, parentId, diff --git a/src/store/file/slices/fileManager/action.ts b/src/store/file/slices/fileManager/action.ts index 1b7482bf96..a95ff1b3a5 100644 --- a/src/store/file/slices/fileManager/action.ts +++ b/src/store/file/slices/fileManager/action.ts @@ -307,6 +307,11 @@ export const createFileManageSlice: StateCreator< revalidate: true, }, ); + + // Also revalidate the ResourceManager resource list cache (SWR_RESOURCES) + // so uploaded files appear immediately in the Explorer without a full refresh. + const { revalidateResources } = await import('../resource/hooks'); + await revalidateResources(); }, removeAllFiles: async () => { await fileService.removeAllFiles(); @@ -543,10 +548,13 @@ export const createFileManageSlice: StateCreator< }), useFetchKnowledgeItem: (id) => - useClientDataSWR(!id ? null : ['useFetchKnowledgeItem', id], async () => { - const response = await serverFileService.getKnowledgeItem(id!); - return response ?? undefined; - }), + useClientDataSWR( + !id ? null : ['useFetchKnowledgeItem', id], + async () => { + const response = await serverFileService.getKnowledgeItem(id!); + return response ?? undefined; + }, + ), useFetchKnowledgeItems: (params) => useClientDataSWR([FETCH_ALL_KNOWLEDGE_KEY, params], async () => {