diff --git a/.env.example b/.env.example index 40d6433822ef..ec6de216f5b2 100644 --- a/.env.example +++ b/.env.example @@ -139,23 +139,34 @@ OPENAI_API_KEY=sk-xxxxxxxxx # the format is `plugin-identifier:key1=value1;key2=value2`, multiple settings fields are separated by semicolons `;`, multiple plugin settings are separated by commas `,`. # PLUGIN_SETTINGS=search-engine:SERPAPI_API_KEY=xxxxx +######################################## +####### Doc / Changelog Service ######## +######################################## + +# Use in Changelog / Document service cdn url prefix +# DOC_S3_PUBLIC_DOMAIN=https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Use in dev cdn workflow +# DOC_S3_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# DOC_S3_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ######################################## ##### S3 Object Storage Service ######## ######################################## # S3 keys -#S3_ACCESS_KEY_ID=9998d6757e276cf9f1edbd325b7083a6 -#S3_SECRET_ACCESS_KEY=55af75d8eb6b99f189f6a35f855336ea62cd9c4751a5cf4337c53c1d3f497ac2 +# S3_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# S3_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Bucket name -#S3_BUCKET=lobechat +# S3_BUCKET=lobechat # Bucket request endpoint -#S3_ENDPOINT=https://0b33a03b5c993fd2f453379dc36558e5.r2.cloudflarestorage.com +# S3_ENDPOINT=https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxx.r2.cloudflarestorage.com # Public access domain for the bucket -#S3_PUBLIC_DOMAIN=https://s3-for-lobechat.your-domain.com +# S3_PUBLIC_DOMAIN=https://s3-for-lobechat.your-domain.com # Bucket region, such as us-west-1, generally not needed to add # but some service providers may require configuration @@ -190,11 +201,11 @@ OPENAI_API_KEY=sk-xxxxxxxxx ######################################## # Specify the service mode as server if you want to use the server database -#NEXT_PUBLIC_SERVICE_MODE=server +# NEXT_PUBLIC_SERVICE_MODE=server # Postgres database URL -#DATABASE_URL=postgres://username:password@host:port/database +# DATABASE_URL=postgres://username:password@host:port/database # use `openssl rand -base64 32` to generate a key for the encryption of the database # we use this key to encrypt the user api key -#KEY_VAULTS_SECRET=xxxxx/xxxxxxxxxxxxxx= +# KEY_VAULTS_SECRET=xxxxx/xxxxxxxxxxxxxx= diff --git a/.eslintignore b/.eslintignore index a0afbcb77f1b..3c5530b607d1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -28,4 +28,4 @@ logs # misc # add other ignore file below -.next \ No newline at end of file +.next diff --git a/docs/.cdn.cache.json b/docs/.cdn.cache.json new file mode 100644 index 000000000000..2708ec042a3d --- /dev/null +++ b/docs/.cdn.cache.json @@ -0,0 +1,25 @@ +{ + "https://github.com/lobehub/lobe-chat/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2": "/blog/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2.mp4", + "https://github.com/user-attachments/assets/03433283-08a5-481a-8f6c-069b2fc6bace": "/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp", + "https://github.com/user-attachments/assets/0e3a7174-6b66-4432-a319-dff60b033c24": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp", + "https://github.com/user-attachments/assets/2048b4c2-4a56-4029-acf9-71e35ff08652": "/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp", + "https://github.com/user-attachments/assets/2787824c-a13c-466c-ba6f-820bddfe099f": "/blog/assets/8d6c17a6ea5e784edf4449fb18ca3f76.webp", + "https://github.com/user-attachments/assets/29508dda-2382-430f-bc81-fb23f02149f8": "/blog/assets/29b13dc042e3b839ad8865354afe2fac.webp", + "https://github.com/user-attachments/assets/2a4116a7-15ad-43e5-b801-cc62d8da2012": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp", + "https://github.com/user-attachments/assets/385eaca6-daea-484a-9bea-ba7270b4753d": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp", + "https://github.com/user-attachments/assets/484f28f4-017c-4ed7-948b-4a8d51f0b63a": "/blog/assets/5bbb4b421d6df63780b3c7a05f5a102d.webp", + "https://github.com/user-attachments/assets/533f7a5e-8a93-4a57-a62f-8233897d72b5": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp", + "https://github.com/user-attachments/assets/6069332b-8e15-4d3c-8a77-479e8bc09c23": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp", + "https://github.com/user-attachments/assets/635f1c74-6327-48a8-a8d9-68d7376c7749": "/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp", + "https://github.com/user-attachments/assets/639ed70b-abc5-476f-9eb0-10c739e5a115": "/blog/assets/b2845057b23bccfec3bfea90e43ac381.webp", + "https://github.com/user-attachments/assets/6935e155-4a1d-4ab7-a61a-2b813d65bb7b": "/blog/assets/6ee2609d79281b6b915e317461013f31.webp", + "https://github.com/user-attachments/assets/82bfc467-e0c6-4d99-9b1f-18e4aea24285": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp", + "https://github.com/user-attachments/assets/aee846d5-b5ee-46cb-9dd0-d952ea708b67": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp", + "https://github.com/user-attachments/assets/bd6d0c82-8f14-4167-ad09-2a841f1e34e4": "/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp", + "https://github.com/user-attachments/assets/c68e88e4-cf2e-4122-82bc-89ba193b1eb4": "/blog/assets/1f6c4f1c5e6211735ca4924c7807aca1.webp", + "https://github.com/user-attachments/assets/dde2c9c5-cdda-4a65-8f32-b6f4da907df2": "/blog/assets/d47654360d626f80144cdedb979a3526.webp", + "https://github.com/user-attachments/assets/e70c2db6-05c9-43ea-b111-6f6f99e0ae88": "/blog/assets/944c671604833cd2457445b211ebba33.webp", + "https://github.com/user-attachments/assets/eaed3762-136f-4297-b161-ca92a27c4982": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp", + "https://github.com/user-attachments/assets/eb3f3d8a-79ce-40aa-a206-2c846206c0c0": "/blog/assets/f10a4b98782e36797c38071eed785c6f.webp", + "https://github.com/user-attachments/assets/fa8fab19-ace2-4f85-8428-a3a0e28845bb": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp" +} diff --git a/docs/changelog/2023-09-09-plugin-system.mdx b/docs/changelog/2023-09-09-plugin-system.mdx index 82bd2f8fa600..b1ad1e858f9e 100644 --- a/docs/changelog/2023-09-09-plugin-system.mdx +++ b/docs/changelog/2023-09-09-plugin-system.mdx @@ -10,7 +10,7 @@ description: >- The LobeChat plugin ecosystem is a significant extension of its core functionalities, greatly enhancing the utility and flexibility of the LobeChat assistant. - + By leveraging plugins, the LobeChat assistants are capable of accessing and processing real-time information, such as searching online for data and providing users with timely and relevant insights. diff --git a/docs/changelog/2023-09-09-plugin-system.zh-CN.mdx b/docs/changelog/2023-09-09-plugin-system.zh-CN.mdx index b23bcc9f35e5..a9cb9ce79f20 100644 --- a/docs/changelog/2023-09-09-plugin-system.zh-CN.mdx +++ b/docs/changelog/2023-09-09-plugin-system.zh-CN.mdx @@ -7,7 +7,7 @@ description: 了解 LobeChat 插件生态系统如何增强 LobeChat 助手的 LobeChat 的插件生态系统是其核心功能的重要扩展,它极大地增强了 LobeChat 助手的实用性和灵活性。 - + 通过利用插件,LobeChat 的助手们能够实现实时信息的获取和处理,例如搜索网络信息,为用户提供即时且相关的资讯。 diff --git a/docs/changelog/2024-09-20-artifacts.mdx b/docs/changelog/2024-09-20-artifacts.mdx index 08ceaf34fe3b..5b8d601cc9ac 100644 --- a/docs/changelog/2024-09-20-artifacts.mdx +++ b/docs/changelog/2024-09-20-artifacts.mdx @@ -20,7 +20,7 @@ In this version, we have nearly fully replicated the core features of Claude Art It is worth mentioning that the Python code execution feature has also been developed and will be available in future versions. At that time, users will be able to utilize both Claude Artifacts and OpenAI Code Interpreter, significantly enhancing the practicality of the AI assistant. -![Artifacts Feature Showcase](https://hub-apac-1.lobeobjects.space/blog/assets/b2845057b23bccfec3bfea90e43ac381.webp) +![Artifacts Feature Showcase](https://github.com/user-attachments/assets/639ed70b-abc5-476f-9eb0-10c739e5a115) ## 🔍 New Discovery Page: Explore More Possibilities diff --git a/docs/changelog/2024-09-20-artifacts.zh-CN.mdx b/docs/changelog/2024-09-20-artifacts.zh-CN.mdx index e28278b71f2b..6b70dd4e020f 100644 --- a/docs/changelog/2024-09-20-artifacts.zh-CN.mdx +++ b/docs/changelog/2024-09-20-artifacts.zh-CN.mdx @@ -19,7 +19,7 @@ description: >- 值得一提的是,Python 代码执行功能也已完成开发,将在后续版本中与大家见面。届时,用户将能够同时运用 Claude Artifacts 和 OpenAI Code Interpreter 这两大强大工具,极大提升 AI 助手的实用性。 -![Artifacts 功能展示](https://hub-apac-1.lobeobjects.space/blog/assets/8d6c17a6ea5e784edf4449fb18ca3f76.webp) +![Artifacts 功能展示](https://github.com/user-attachments/assets/2787824c-a13c-466c-ba6f-820bddfe099f) ## 🔍 全新发现页面:探索更多可能 diff --git a/docs/changelog/2024-10-27-pin-assistant.mdx b/docs/changelog/2024-10-27-pin-assistant.mdx index f21f435545a5..9b13bba51427 100644 --- a/docs/changelog/2024-10-27-pin-assistant.mdx +++ b/docs/changelog/2024-10-27-pin-assistant.mdx @@ -16,9 +16,9 @@ In version v1.26.0, we are excited to introduce a long-awaited new feature — t - **Space Optimization**: Activating the sidebar automatically hides the conversation list, providing you with a larger conversation area. - **Intelligent Display**: Automatically syncs pinned assistants to the sidebar, ensuring that important assistants are always within view. -![Sidebar Display Effect](https://hub-apac-1.lobeobjects.space/blog/assets/6ee2609d79281b6b915e317461013f31.webp) +![Sidebar Display Effect](https://github.com/user-attachments/assets/6935e155-4a1d-4ab7-a61a-2b813d65bb7b) -![Conversation Interface Effect](https://hub-apac-1.lobeobjects.space/blog/assets/1f6c4f1c5e6211735ca4924c7807aca1.webp) +![Conversation Interface Effect](https://github.com/user-attachments/assets/c68e88e4-cf2e-4122-82bc-89ba193b1eb4) ## How to Use diff --git a/docs/changelog/2024-10-27-pin-assistant.zh-CN.mdx b/docs/changelog/2024-10-27-pin-assistant.zh-CN.mdx index 1cf298c05592..1b7f84ddd1b4 100644 --- a/docs/changelog/2024-10-27-pin-assistant.zh-CN.mdx +++ b/docs/changelog/2024-10-27-pin-assistant.zh-CN.mdx @@ -13,9 +13,9 @@ description: LobeChat v1.26.0 推出助手常驻侧边栏功能,支持快捷 - **空间优化**:激活侧边栏时会自动隐藏会话列表,为您腾出更大的对话空间 - **智能显示**:将置顶助手自动同步到侧边栏,让重要助手始终在视线范围内 -![侧边栏展示效果](https://hub-apac-1.lobeobjects.space/blog/assets/6ee2609d79281b6b915e317461013f31.webp) +![侧边栏展示效果](https://github.com/user-attachments/assets/6935e155-4a1d-4ab7-a61a-2b813d65bb7b) -![对话界面效果](https://hub-apac-1.lobeobjects.space/blog/assets/1f6c4f1c5e6211735ca4924c7807aca1.webp) +![对话界面效果](https://github.com/user-attachments/assets/c68e88e4-cf2e-4122-82bc-89ba193b1eb4) ## 如何使用 diff --git a/docs/changelog/2024-11-06-share-text-json.mdx b/docs/changelog/2024-11-06-share-text-json.mdx index 458421215dad..8fed3949d87b 100644 --- a/docs/changelog/2024-11-06-share-text-json.mdx +++ b/docs/changelog/2024-11-06-share-text-json.mdx @@ -13,11 +13,11 @@ In the latest version v1.28.0, we have launched the text format export feature f The Markdown export feature meets users' needs for directly using conversation content in note-taking and document writing. You can easily save valuable conversation content and manage it across various note-taking applications for reuse. -![Exporting Conversations as Markdown Text](https://hub-apac-1.lobeobjects.space/blog/assets/29b13dc042e3b839ad8865354afe2fac.webp) +![Exporting Conversations as Markdown Text](https://github.com/user-attachments/assets/29508dda-2382-430f-bc81-fb23f02149f8) Additionally, we support exporting conversations in JSON format that complies with OpenAI messages specifications. This format can be used directly for API debugging and serves as high-quality training data for models. -![Exporting Conversations as JSON in OpenAI API Specification](https://hub-apac-1.lobeobjects.space/blog/assets/5bbb4b421d6df63780b3c7a05f5a102d.webp) +![Exporting Conversations as JSON in OpenAI API Specification](https://github.com/user-attachments/assets/484f28f4-017c-4ed7-948b-4a8d51f0b63a) It is particularly noteworthy that we retain the original data of Tools Calling within the conversation, which is crucial for enhancing the model's tool invocation capabilities. diff --git a/docs/changelog/2024-11-06-share-text-json.zh-CN.mdx b/docs/changelog/2024-11-06-share-text-json.zh-CN.mdx index 0c8add008c8a..dfc222da5d0f 100644 --- a/docs/changelog/2024-11-06-share-text-json.zh-CN.mdx +++ b/docs/changelog/2024-11-06-share-text-json.zh-CN.mdx @@ -11,11 +11,11 @@ description: >- Markdown 格式导出功能满足了用户将对话内容直接用于笔记和文档撰写的需求。您可以轻松地将有价值的对话内容保存下来,并在各类笔记软件中进行管理和复用。 -![将对话导出为 Markdown 格式文本](https://hub-apac-1.lobeobjects.space/blog/assets/29b13dc042e3b839ad8865354afe2fac.webp) +![将对话导出为 Markdown 格式文本](https://github.com/user-attachments/assets/29508dda-2382-430f-bc81-fb23f02149f8) 同时,我们还支持将对话导出为符合 OpenAI messages 规范的 JSON 格式。这种格式不仅可以直接用于 API 调试,还能作为高质量的模型训练语料。 -![将对话导出为 OpenAI 接口规范的 JSON](https://hub-apac-1.lobeobjects.space/blog/assets/5bbb4b421d6df63780b3c7a05f5a102d.webp) +![将对话导出为 OpenAI 接口规范的 JSON](https://github.com/user-attachments/assets/484f28f4-017c-4ed7-948b-4a8d51f0b63a) 特别值得一提的是,我们会完整保留对话中的 Tools Calling 原始数据,这对提升模型的工具调用能力具有重要价值。 diff --git a/docs/changelog/index.json b/docs/changelog/index.json index dfddfc07021e..ab2662c7fa6a 100644 --- a/docs/changelog/index.json +++ b/docs/changelog/index.json @@ -3,97 +3,97 @@ "cloud": [], "community": [ { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp", + "image": "https://github.com/user-attachments/assets/2048b4c2-4a56-4029-acf9-71e35ff08652", "id": "2024-11-27-forkable-chat", "date": "2024-11-27", "versionRange": ["1.34.0", "1.33.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp", + "image": "https://github.com/user-attachments/assets/fa8fab19-ace2-4f85-8428-a3a0e28845bb", "id": "2024-11-25-november-providers", "date": "2024-11-25", "versionRange": ["1.33.0", "1.30.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/f10a4b98782e36797c38071eed785c6f.webp", + "image": "https://github.com/user-attachments/assets/eb3f3d8a-79ce-40aa-a206-2c846206c0c0", "id": "2024-11-06-share-text-json", "date": "2024-11-06", "versionRange": ["1.28.0", "1.26.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/944c671604833cd2457445b211ebba33.webp", + "image": "https://github.com/user-attachments/assets/e70c2db6-05c9-43ea-b111-6f6f99e0ae88", "id": "2024-10-27-pin-assistant", "date": "2024-10-27", "versionRange": ["1.26.0", "1.19.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp", + "image": "https://github.com/user-attachments/assets/635f1c74-6327-48a8-a8d9-68d7376c7749", "id": "2024-09-20-artifacts", "date": "2024-09-20", "versionRange": ["1.19.0", "1.17.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp", + "image": "https://github.com/user-attachments/assets/bd6d0c82-8f14-4167-ad09-2a841f1e34e4", "id": "2024-09-13-openai-o1-models", "date": "2024-09-13", "versionRange": ["1.17.0", "1.12.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp", + "image": "https://github.com/user-attachments/assets/385eaca6-daea-484a-9bea-ba7270b4753d", "id": "2024-08-21-file-upload-and-knowledge-base", "date": "2024-08-21", "versionRange": ["1.12.0", "1.8.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp", + "image": "https://github.com/user-attachments/assets/2a4116a7-15ad-43e5-b801-cc62d8da2012", "id": "2024-08-02-lobe-chat-database-docker", "date": "2024-08-02", "versionRange": ["1.8.0", "1.6.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp", + "image": "https://github.com/user-attachments/assets/0e3a7174-6b66-4432-a319-dff60b033c24", "id": "2024-07-19-gpt-4o-mini", "date": "2024-07-19", "versionRange": ["1.6.0", "1.0.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp", + "image": "https://github.com/user-attachments/assets/82bfc467-e0c6-4d99-9b1f-18e4aea24285", "id": "2024-06-19-lobe-chat-v1", "date": "2024-06-19", "versionRange": ["1.0.0", "0.147.0"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp", + "image": "https://github.com/user-attachments/assets/aee846d5-b5ee-46cb-9dd0-d952ea708b67", "id": "2024-02-14-ollama", "date": "2024-02-14", "versionRange": ["0.127.0", "0.125.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/9498087e85f27e692716a63cb3b58d79.webp", + "image": "https://github.com/user-attachments/assets/533f7a5e-8a93-4a57-a62f-8233897d72b5", "id": "2024-02-08-sso-oauth", "date": "2024-02-08", "versionRange": ["0.125.0", "0.118.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp", + "image": "https://github.com/user-attachments/assets/6069332b-8e15-4d3c-8a77-479e8bc09c23", "id": "2023-12-22-dalle-3", "date": "2023-12-22", "versionRange": ["0.118.0", "0.102.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp", + "image": "https://github.com/user-attachments/assets/03433283-08a5-481a-8f6c-069b2fc6bace", "id": "2023-11-19-tts-stt", "date": "2023-11-19", "versionRange": ["0.102.0", "0.101.1"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/d47654360d626f80144cdedb979a3526.webp", + "image": "https://github.com/user-attachments/assets/dde2c9c5-cdda-4a65-8f32-b6f4da907df2", "id": "2023-11-14-gpt4-vision", "date": "2023-11-14", "versionRange": ["0.101.0", "0.90.0"] }, { - "image": "https://hub-apac-1.lobeobjects.space/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp", + "image": "https://github.com/user-attachments/assets/eaed3762-136f-4297-b161-ca92a27c4982", "id": "2023-09-09-plugin-system", "date": "2023-09-09", "versionRange": ["0.72.0", "0.67.0"] diff --git a/locales/ar/changelog.json b/locales/ar/changelog.json new file mode 100644 index 000000000000..ff12b6c86033 --- /dev/null +++ b/locales/ar/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "تابعنا على X", + "subscribeToUpdates": "اشترك في التحديثات", + "versions": "تفاصيل الإصدار" + }, + "addedWhileAway": "لقد أضفنا ميزات جديدة أثناء غيابك.", + "allChangelog": "عرض جميع سجلات التحديثات", + "description": "تابع الميزات الجديدة والتحسينات في {{appName}}", + "pagination": { + "older": "عرض التغييرات السابقة", + "prev": "الصفحة السابقة" + }, + "readDetails": "اقرأ التفاصيل", + "title": "سجل التحديثات", + "versionDetails": "تفاصيل الإصدار", + "welcomeBack": "مرحبًا بعودتك!" +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 9a5c6a2fac9b..8d3506f269fe 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -218,6 +218,7 @@ "pinOff": "إلغاء التثبيت", "privacy": "سياسة الخصوصية", "regenerate": "إعادة توليد", + "releaseNotes": "تفاصيل الإصدار", "rename": "إعادة تسمية", "reset": "إعادة تعيين", "retry": "إعادة المحاولة", diff --git a/locales/ar/metadata.json b/locales/ar/metadata.json index 034b748717a8..3d1a0c82ec83 100644 --- a/locales/ar/metadata.json +++ b/locales/ar/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "تابع الميزات الجديدة والتحسينات في {{appName}} باستمرار", + "title": "سجل التحديثات" + }, "chat": { "description": "{{appName}} يقدم لك أفضل تجربة لاستخدام ChatGPT وClaude وGemini وOLLaMA WebUI", "title": "{{appName}}: أداة الذكاء الاصطناعي الشخصية، امنح نفسك دماغًا أكثر ذكاءً" diff --git a/locales/bg-BG/changelog.json b/locales/bg-BG/changelog.json new file mode 100644 index 000000000000..e7fcce524eb5 --- /dev/null +++ b/locales/bg-BG/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Последвайте ни в X", + "subscribeToUpdates": "Абонирайте се за актуализации", + "versions": "Детайли за версиите" + }, + "addedWhileAway": "Докато ви нямаше, добавихме нови функции.", + "allChangelog": "Вижте всички актуализации", + "description": "Следете новите функции и подобрения на {{appName}}", + "pagination": { + "older": "Преглед на историческите промени", + "prev": "Предишна страница" + }, + "readDetails": "Прочетете подробности", + "title": "Актуализации", + "versionDetails": "Детайли за версиите", + "welcomeBack": "Добре дошли обратно!" +} diff --git a/locales/bg-BG/common.json b/locales/bg-BG/common.json index 24ebdf8e8c7e..49e4c8b2618b 100644 --- a/locales/bg-BG/common.json +++ b/locales/bg-BG/common.json @@ -218,6 +218,7 @@ "pinOff": "Откачи", "privacy": "Политика за поверителност", "regenerate": "Прегенерирай", + "releaseNotes": "Информация за версията", "rename": "Преименувай", "reset": "Нулирай", "retry": "Опитай отново", diff --git a/locales/bg-BG/metadata.json b/locales/bg-BG/metadata.json index 8c12d30619a8..f8f312d02fd1 100644 --- a/locales/bg-BG/metadata.json +++ b/locales/bg-BG/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Непрекъснато следене на новите функции и подобрения в {{appName}}", + "title": "Журнал на промените" + }, "chat": { "description": "{{appName}} ви предлага най-доброто изживяване с ChatGPT, Claude, Gemini и OLLaMA WebUI", "title": "{{appName}}: Личен AI инструмент за ефективност, дайте си по-умен мозък" diff --git a/locales/de-DE/changelog.json b/locales/de-DE/changelog.json new file mode 100644 index 000000000000..e397bb9fdb41 --- /dev/null +++ b/locales/de-DE/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Folgen Sie uns auf X", + "subscribeToUpdates": "Abonnieren Sie Updates", + "versions": "Versionsdetails" + }, + "addedWhileAway": "Wir haben neue Funktionen hinzugefügt, während Sie weg waren.", + "allChangelog": "Alle Änderungsprotokolle anzeigen", + "description": "Verfolgen Sie die neuen Funktionen und Verbesserungen von {{appName}} kontinuierlich", + "pagination": { + "older": "Ältere Änderungen anzeigen", + "prev": "Vorherige Seite" + }, + "readDetails": "Details lesen", + "title": "Änderungsprotokoll", + "versionDetails": "Versionsdetails", + "welcomeBack": "Willkommen zurück!" +} diff --git a/locales/de-DE/common.json b/locales/de-DE/common.json index d42e3877a01f..6fb1994d268f 100644 --- a/locales/de-DE/common.json +++ b/locales/de-DE/common.json @@ -218,6 +218,7 @@ "pinOff": "Anheften aufheben", "privacy": "Datenschutzrichtlinie", "regenerate": "Neu generieren", + "releaseNotes": "Versionsdetails", "rename": "Umbenennen", "reset": "Zurücksetzen", "retry": "Erneut versuchen", diff --git a/locales/de-DE/metadata.json b/locales/de-DE/metadata.json index d5e01753ce0f..06c08bee67cb 100644 --- a/locales/de-DE/metadata.json +++ b/locales/de-DE/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Verfolgen Sie kontinuierlich die neuen Funktionen und Verbesserungen von {{appName}}", + "title": "Änderungsprotokoll" + }, "chat": { "description": "{{appName}} bietet dir das beste Erlebnis mit ChatGPT, Claude, Gemini und OLLaMA WebUI", "title": "{{appName}}: Persönliches KI-Effizienzwerkzeug, gib dir selbst ein schlaueres Gehirn" diff --git a/locales/en-US/changelog.json b/locales/en-US/changelog.json new file mode 100644 index 000000000000..bba44698e436 --- /dev/null +++ b/locales/en-US/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Follow us on X", + "subscribeToUpdates": "Subscribe for updates", + "versions": "Version details" + }, + "addedWhileAway": "We've introduced new features while you were away.", + "allChangelog": "View all changelogs", + "description": "Stay updated on the new features and improvements of {{appName}}", + "pagination": { + "older": "View Historical Changes", + "prev": "Previous Page" + }, + "readDetails": "Read details", + "title": "Changelog", + "versionDetails": "Version details", + "welcomeBack": "Welcome back!" +} diff --git a/locales/en-US/common.json b/locales/en-US/common.json index 3fd1d357cb0d..02d67d476604 100644 --- a/locales/en-US/common.json +++ b/locales/en-US/common.json @@ -218,6 +218,7 @@ "pinOff": "Unpin", "privacy": "Privacy Policy", "regenerate": "Regenerate", + "releaseNotes": "Version Details", "rename": "Rename", "reset": "Reset", "retry": "Retry", diff --git a/locales/en-US/metadata.json b/locales/en-US/metadata.json index ce088925446b..30df2d8311b0 100644 --- a/locales/en-US/metadata.json +++ b/locales/en-US/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Stay updated on the new features and improvements of {{appName}}", + "title": "Changelog" + }, "chat": { "description": "{{appName}} brings you the best UI experience for ChatGPT, Claude, Gemini, and OLLaMA.", "title": "{{appName}}: Your personal AI productivity tool for a smarter brain." diff --git a/locales/es-ES/changelog.json b/locales/es-ES/changelog.json new file mode 100644 index 000000000000..2b35adebf5bb --- /dev/null +++ b/locales/es-ES/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Síguenos en X", + "subscribeToUpdates": "Suscríbete a las actualizaciones", + "versions": "Detalles de la versión" + }, + "addedWhileAway": "Hemos traído nuevas características mientras estabas ausente.", + "allChangelog": "Ver todos los registros de cambios", + "description": "Sigue las nuevas funciones y mejoras de {{appName}}", + "pagination": { + "older": "Ver cambios anteriores", + "prev": "Página anterior" + }, + "readDetails": "Leer detalles", + "title": "Registro de cambios", + "versionDetails": "Detalles de la versión", + "welcomeBack": "¡Bienvenido de nuevo!" +} diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 46c26bbb2358..8ce9f128abe3 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -218,6 +218,7 @@ "pinOff": "Quitar fijación", "privacy": "Política de privacidad", "regenerate": "Regenerar", + "releaseNotes": "Detalles de la versión", "rename": "Renombrar", "reset": "Restablecer", "retry": "Reintentar", diff --git a/locales/es-ES/metadata.json b/locales/es-ES/metadata.json index f7fb5943a90c..f39fbc96a164 100644 --- a/locales/es-ES/metadata.json +++ b/locales/es-ES/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Sigue las nuevas funciones y mejoras de {{appName}}", + "title": "Registro de cambios" + }, "chat": { "description": "{{appName}} te ofrece la mejor experiencia de uso de ChatGPT, Claude, Gemini y OLLaMA WebUI", "title": "{{appName}}: Herramienta de productividad personal de IA, dale a tu cerebro un impulso más inteligente" diff --git a/locales/fa-IR/changelog.json b/locales/fa-IR/changelog.json new file mode 100644 index 000000000000..bad13c844875 --- /dev/null +++ b/locales/fa-IR/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "ما را در X دنبال کنید", + "subscribeToUpdates": "برای دریافت بهروزرسانیها مشترک شوید", + "versions": "جزئیات نسخه" + }, + "addedWhileAway": "در زمان غیبت شما، ویژگیهای جدیدی اضافه کردیم.", + "allChangelog": "تمام تغییرات را مشاهده کنید", + "description": "بهروزرسانیهای جدید و بهبودهای {{appName}} را دنبال کنید", + "pagination": { + "older": "مشاهده تغییرات قبلی", + "prev": "صفحه قبلی" + }, + "readDetails": "جزئیات را بخوانید", + "title": "تغییرات", + "versionDetails": "جزئیات نسخه", + "welcomeBack": "خوش آمدید!" +} diff --git a/locales/fa-IR/common.json b/locales/fa-IR/common.json index b9cafaf6f7f8..c31a94b510ca 100644 --- a/locales/fa-IR/common.json +++ b/locales/fa-IR/common.json @@ -218,6 +218,7 @@ "pinOff": "لغو سنجاق کردن", "privacy": "سیاست حفظ حریم خصوصی", "regenerate": "بازتولید", + "releaseNotes": "جزئیات نسخه", "rename": "تغییر نام", "reset": "بازنشانی", "retry": "تلاش مجدد", diff --git a/locales/fa-IR/metadata.json b/locales/fa-IR/metadata.json index 11e9c7e2a1c1..2a617ee17365 100644 --- a/locales/fa-IR/metadata.json +++ b/locales/fa-IR/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "پیگیری مداوم ویژگیها و بهبودهای جدید {{appName}}", + "title": "گزارش تغییرات" + }, "chat": { "description": "{{appName}} بهترین تجربه استفاده از ChatGPT، Claude، Gemini، OLLaMA WebUI را برای شما به ارمغان میآورد", "title": "{{appName}}: ابزار بهرهوری شخصی AI، به خودتان یک مغز هوشمندتر بدهید" diff --git a/locales/fr-FR/changelog.json b/locales/fr-FR/changelog.json new file mode 100644 index 000000000000..2f9f68c59c73 --- /dev/null +++ b/locales/fr-FR/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Suivez-nous sur X", + "subscribeToUpdates": "Abonnez-vous aux mises à jour", + "versions": "Détails de la version" + }, + "addedWhileAway": "Nous avons ajouté de nouvelles fonctionnalités pendant votre absence.", + "allChangelog": "Voir tous les journaux de mise à jour", + "description": "Suivez en continu les nouvelles fonctionnalités et améliorations de {{appName}}", + "pagination": { + "older": "Voir les modifications antérieures", + "prev": "Page précédente" + }, + "readDetails": "Lire les détails", + "title": "Journal des mises à jour", + "versionDetails": "Détails de la version", + "welcomeBack": "Bienvenue de nouveau!" +} diff --git a/locales/fr-FR/common.json b/locales/fr-FR/common.json index 9dc8231696bb..ca37d5b2920e 100644 --- a/locales/fr-FR/common.json +++ b/locales/fr-FR/common.json @@ -218,6 +218,7 @@ "pinOff": "Désactiver l'épinglage", "privacy": "Politique de confidentialité", "regenerate": "Régénérer", + "releaseNotes": "Détails de la version", "rename": "Renommer", "reset": "Réinitialiser", "retry": "Réessayer", diff --git a/locales/fr-FR/metadata.json b/locales/fr-FR/metadata.json index ca1d97690462..b53a646432ef 100644 --- a/locales/fr-FR/metadata.json +++ b/locales/fr-FR/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Suivez les nouvelles fonctionnalités et améliorations de {{appName}}", + "title": "Journal des mises à jour" + }, "chat": { "description": "{{appName}} vous offre la meilleure expérience d'utilisation de ChatGPT, Claude, Gemini et OLLaMA WebUI", "title": "{{appName}} : un outil d'efficacité personnelle en IA pour vous donner un cerveau plus intelligent" diff --git a/locales/it-IT/changelog.json b/locales/it-IT/changelog.json new file mode 100644 index 000000000000..7f7549af6c6f --- /dev/null +++ b/locales/it-IT/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Seguici su X", + "subscribeToUpdates": "Iscriviti agli aggiornamenti", + "versions": "Dettagli versione" + }, + "addedWhileAway": "Abbiamo introdotto nuove funzionalità mentre eri via.", + "allChangelog": "Visualizza tutti i registri delle modifiche", + "description": "Tieni traccia delle nuove funzionalità e miglioramenti di {{appName}}", + "pagination": { + "older": "Visualizza le modifiche precedenti", + "prev": "Pagina precedente" + }, + "readDetails": "Leggi i dettagli", + "title": "Registro delle modifiche", + "versionDetails": "Dettagli versione", + "welcomeBack": "Bentornato!" +} diff --git a/locales/it-IT/common.json b/locales/it-IT/common.json index 2556bcbc23a7..5e40a98e1f22 100644 --- a/locales/it-IT/common.json +++ b/locales/it-IT/common.json @@ -218,6 +218,7 @@ "pinOff": "Annulla fissaggio", "privacy": "Informativa sulla privacy", "regenerate": "Rigenera", + "releaseNotes": "Dettagli della versione", "rename": "Rinomina", "reset": "Ripristina", "retry": "Riprova", diff --git a/locales/it-IT/metadata.json b/locales/it-IT/metadata.json index 31549cc2110d..327cf6c2d17c 100644 --- a/locales/it-IT/metadata.json +++ b/locales/it-IT/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Segui le nuove funzionalità e i miglioramenti di {{appName}}", + "title": "Registro delle modifiche" + }, "chat": { "description": "{{appName}} ti offre la migliore esperienza con ChatGPT, Claude, Gemini e OLLaMA WebUI", "title": "{{appName}}: strumento di efficienza personale AI, per darti un cervello più intelligente" diff --git a/locales/ja-JP/changelog.json b/locales/ja-JP/changelog.json new file mode 100644 index 000000000000..386c567b042d --- /dev/null +++ b/locales/ja-JP/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Xで私たちをフォロー", + "subscribeToUpdates": "更新を購読", + "versions": "バージョンの詳細" + }, + "addedWhileAway": "あなたが離れている間に、新しい機能を追加しました。", + "allChangelog": "すべての更新ログを見る", + "description": "{{appName}}の新機能と改善を継続的に追跡", + "pagination": { + "older": "履歴の変更を表示", + "prev": "前のページ" + }, + "readDetails": "詳細を読む", + "title": "更新ログ", + "versionDetails": "バージョンの詳細", + "welcomeBack": "お帰りなさい!" +} diff --git a/locales/ja-JP/common.json b/locales/ja-JP/common.json index 724535307b31..f324aa5140b5 100644 --- a/locales/ja-JP/common.json +++ b/locales/ja-JP/common.json @@ -218,6 +218,7 @@ "pinOff": "ピン留め解除", "privacy": "プライバシーポリシー", "regenerate": "再生成", + "releaseNotes": "リリースノート", "rename": "名前を変更", "reset": "リセット", "retry": "再試行", diff --git a/locales/ja-JP/metadata.json b/locales/ja-JP/metadata.json index fbc4bcf97a8e..45ae6d803f0e 100644 --- a/locales/ja-JP/metadata.json +++ b/locales/ja-JP/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "{{appName}} の新機能と改善を継続的に追跡する", + "title": "更新履歴" + }, "chat": { "description": "{{appName}}が提供する最高のChatGPT、Claude、Gemini、OLLaMA WebUIの体験", "title": "{{appName}}:個人AI効率ツール、より賢い脳を手に入れよう" diff --git a/locales/ko-KR/changelog.json b/locales/ko-KR/changelog.json new file mode 100644 index 000000000000..cc70e7a8b689 --- /dev/null +++ b/locales/ko-KR/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "X에서 저희를 팔로우하세요", + "subscribeToUpdates": "업데이트 구독하기", + "versions": "버전 세부정보" + }, + "addedWhileAway": "귀하가 떠나 있는 동안 새로운 기능이 추가되었습니다.", + "allChangelog": "모든 업데이트 로그 보기", + "description": "{{appName}}의 새로운 기능과 개선 사항을 지속적으로 추적하세요", + "pagination": { + "older": "이전 변경 사항 보기", + "prev": "이전 페이지" + }, + "readDetails": "자세히 읽기", + "title": "업데이트 로그", + "versionDetails": "버전 세부정보", + "welcomeBack": "다시 오신 것을 환영합니다!" +} diff --git a/locales/ko-KR/common.json b/locales/ko-KR/common.json index 9af1244eb611..8d98cb52963a 100644 --- a/locales/ko-KR/common.json +++ b/locales/ko-KR/common.json @@ -218,6 +218,7 @@ "pinOff": "고정 해제", "privacy": "개인정보 보호 정책", "regenerate": "재생성", + "releaseNotes": "버전 세부정보", "rename": "이름 바꾸기", "reset": "재설정", "retry": "재시도", diff --git a/locales/ko-KR/metadata.json b/locales/ko-KR/metadata.json index 6cd12c3655b3..eb8174efc962 100644 --- a/locales/ko-KR/metadata.json +++ b/locales/ko-KR/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "{{appName}}의 새로운 기능과 개선 사항을 지속적으로 추적합니다.", + "title": "변경 로그" + }, "chat": { "description": "{{appName}}가 제공하는 최고의 ChatGPT, Claude, Gemini, OLLaMA WebUI 사용 경험", "title": "{{appName}}: 개인 AI 효율 도구, 더 똑똑한 두뇌를 위한 선택" diff --git a/locales/nl-NL/changelog.json b/locales/nl-NL/changelog.json new file mode 100644 index 000000000000..0076f83087cc --- /dev/null +++ b/locales/nl-NL/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Volg ons op X", + "subscribeToUpdates": "Abonneer op updates", + "versions": "Versie details" + }, + "addedWhileAway": "We hebben nieuwe functies toegevoegd terwijl je weg was.", + "allChangelog": "Bekijk alle changelogs", + "description": "Blijf op de hoogte van nieuwe functies en verbeteringen van {{appName}}", + "pagination": { + "older": "Bekijk eerdere wijzigingen", + "prev": "Vorige pagina" + }, + "readDetails": "Lees meer", + "title": "Changelog", + "versionDetails": "Versie details", + "welcomeBack": "Welkom terug!" +} diff --git a/locales/nl-NL/common.json b/locales/nl-NL/common.json index 7f30b97ca542..ec486adb3ba8 100644 --- a/locales/nl-NL/common.json +++ b/locales/nl-NL/common.json @@ -218,6 +218,7 @@ "pinOff": "Vastzetten uitschakelen", "privacy": "Privacybeleid", "regenerate": "Opnieuw genereren", + "releaseNotes": "Versie details", "rename": "Naam wijzigen", "reset": "Resetten", "retry": "Opnieuw proberen", diff --git a/locales/nl-NL/metadata.json b/locales/nl-NL/metadata.json index 2bf20a555ce6..1571cfb3d167 100644 --- a/locales/nl-NL/metadata.json +++ b/locales/nl-NL/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Blijf op de hoogte van nieuwe functies en verbeteringen van {{appName}}", + "title": "Wijzigingslog" + }, "chat": { "description": "{{appName}} biedt je de beste ervaring met ChatGPT, Claude, Gemini, en OLLaMA WebUI", "title": "{{appName}}: Persoonlijke AI-efficiëntietool, geef jezelf een slimmer brein" diff --git a/locales/pl-PL/changelog.json b/locales/pl-PL/changelog.json new file mode 100644 index 000000000000..b6a277dc3707 --- /dev/null +++ b/locales/pl-PL/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Obserwuj nas na X", + "subscribeToUpdates": "Subskrybuj aktualizacje", + "versions": "Szczegóły wersji" + }, + "addedWhileAway": "W czasie Twojej nieobecności wprowadziliśmy nowe funkcje.", + "allChangelog": "Zobacz wszystkie dzienniki zmian", + "description": "Na bieżąco śledź nowe funkcje i ulepszenia {{appName}}", + "pagination": { + "older": "Zobacz wcześniejsze zmiany", + "prev": "Poprzednia strona" + }, + "readDetails": "Przeczytaj szczegóły", + "title": "Dziennik zmian", + "versionDetails": "Szczegóły wersji", + "welcomeBack": "Witaj z powrotem!" +} diff --git a/locales/pl-PL/common.json b/locales/pl-PL/common.json index 7f7a3736e8f8..6a52fc52bbbd 100644 --- a/locales/pl-PL/common.json +++ b/locales/pl-PL/common.json @@ -218,6 +218,7 @@ "pinOff": "Odepnij", "privacy": "Polityka prywatności", "regenerate": "Regeneruj", + "releaseNotes": "Szczegóły wersji", "rename": "Zmień nazwę", "reset": "Resetuj", "retry": "Ponów", diff --git a/locales/pl-PL/metadata.json b/locales/pl-PL/metadata.json index abfb8db716b6..c5533eb57685 100644 --- a/locales/pl-PL/metadata.json +++ b/locales/pl-PL/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Bieżące śledzenie nowych funkcji i ulepszeń {{appName}}", + "title": "Dziennik zmian" + }, "chat": { "description": "{{appName}} oferuje najlepsze doświadczenia z ChatGPT, Claude, Gemini, OLLaMA WebUI", "title": "{{appName}}: osobiste narzędzie AI, które daje ci mądrzejszy umysł" diff --git a/locales/pt-BR/changelog.json b/locales/pt-BR/changelog.json new file mode 100644 index 000000000000..50b34c1dd02c --- /dev/null +++ b/locales/pt-BR/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Siga-nos no X", + "subscribeToUpdates": "Inscreva-se para atualizações", + "versions": "Detalhes da versão" + }, + "addedWhileAway": "Trouxemos novos recursos enquanto você estava ausente.", + "allChangelog": "Veja todos os registros de alterações", + "description": "Acompanhe as novas funcionalidades e melhorias do {{appName}}", + "pagination": { + "older": "Ver alterações anteriores", + "prev": "Página anterior" + }, + "readDetails": "Leia os detalhes", + "title": "Registro de Atualizações", + "versionDetails": "Detalhes da versão", + "welcomeBack": "Bem-vindo de volta!" +} diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index a89ea565c8c0..d704d8af05aa 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -218,6 +218,7 @@ "pinOff": "Desafixar", "privacy": "Política de Privacidade", "regenerate": "Regenerar", + "releaseNotes": "Notas da versão", "rename": "Renomear", "reset": "Redefinir", "retry": "Tentar novamente", diff --git a/locales/pt-BR/metadata.json b/locales/pt-BR/metadata.json index ff207209901a..adceb37d8ea8 100644 --- a/locales/pt-BR/metadata.json +++ b/locales/pt-BR/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Acompanhe as novas funcionalidades e melhorias do {{appName}}", + "title": "Registro de Atualizações" + }, "chat": { "description": "{{appName}} traz a você a melhor experiência de uso do ChatGPT, Claude, Gemini e OLLaMA WebUI", "title": "{{appName}}: Ferramenta de eficiência pessoal em IA, dê a si mesmo um cérebro mais inteligente" diff --git a/locales/ru-RU/changelog.json b/locales/ru-RU/changelog.json new file mode 100644 index 000000000000..42a31753681a --- /dev/null +++ b/locales/ru-RU/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Подписывайтесь на нас в X", + "subscribeToUpdates": "Подписаться на обновления", + "versions": "Детали версий" + }, + "addedWhileAway": "Мы добавили новые функции, пока вы отсутствовали.", + "allChangelog": "Просмотреть все журналы изменений", + "description": "Постоянно следите за новыми функциями и улучшениями {{appName}}", + "pagination": { + "older": "Посмотреть историю изменений", + "prev": "Предыдущая страница" + }, + "readDetails": "Читать детали", + "title": "Журнал изменений", + "versionDetails": "Детали версий", + "welcomeBack": "С возвращением!" +} diff --git a/locales/ru-RU/common.json b/locales/ru-RU/common.json index 3e3eacc704d1..67d128277da2 100644 --- a/locales/ru-RU/common.json +++ b/locales/ru-RU/common.json @@ -218,6 +218,7 @@ "pinOff": "Открепить", "privacy": "Политика конфиденциальности", "regenerate": "Перегенерировать", + "releaseNotes": "Подробности о версии", "rename": "Переименовать", "reset": "Сброс", "retry": "Повторить", diff --git a/locales/ru-RU/metadata.json b/locales/ru-RU/metadata.json index d5ac78f4701e..6c72fb5f74af 100644 --- a/locales/ru-RU/metadata.json +++ b/locales/ru-RU/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Постоянно следите за новыми функциями и улучшениями {{appName}}", + "title": "Журнал изменений" + }, "chat": { "description": "{{appName}} предлагает вам лучший опыт использования ChatGPT, Claude, Gemini и OLLaMA WebUI", "title": "{{appName}}: личный инструмент AI для повышения эффективности, дайте себе более умный мозг" diff --git a/locales/tr-TR/changelog.json b/locales/tr-TR/changelog.json new file mode 100644 index 000000000000..8ca7fee5b03f --- /dev/null +++ b/locales/tr-TR/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Bizi X'te takip edin", + "subscribeToUpdates": "Güncellemeleri abone olun", + "versions": "Sürüm detayları" + }, + "addedWhileAway": "Siz yokken yeni özellikler ekledik.", + "allChangelog": "Tüm güncelleme günlüklerini görüntüle", + "description": "{{appName}}'in yeni özelliklerini ve iyileştirmelerini sürekli takip edin", + "pagination": { + "older": "Geçmiş değişiklikleri görüntüle", + "prev": "Önceki sayfa" + }, + "readDetails": "Detayları okuyun", + "title": "Güncelleme Günlüğü", + "versionDetails": "Sürüm detayları", + "welcomeBack": "Hoş geldiniz!" +} diff --git a/locales/tr-TR/common.json b/locales/tr-TR/common.json index a3e3cbee41db..4e25f113b807 100644 --- a/locales/tr-TR/common.json +++ b/locales/tr-TR/common.json @@ -218,6 +218,7 @@ "pinOff": "Unpin", "privacy": "Gizlilik Politikası", "regenerate": "Tekrarla", + "releaseNotes": "Sürüm Detayları", "rename": "Yeniden İsimlendir", "reset": "Reset", "retry": "Yeniden Dene", diff --git a/locales/tr-TR/metadata.json b/locales/tr-TR/metadata.json index 66892ae76502..1f71c962df9d 100644 --- a/locales/tr-TR/metadata.json +++ b/locales/tr-TR/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "{{appName}}'in yeni özelliklerini ve iyileştirmelerini sürekli takip edin", + "title": "Güncelleme Geçmişi" + }, "chat": { "description": "{{appName}} size en iyi ChatGPT, Claude, Gemini, OLLaMA WebUI deneyimini sunar", "title": "{{appName}}: Kişisel AI verimlilik aracı, kendinize daha akıllı bir zihin verin" diff --git a/locales/vi-VN/changelog.json b/locales/vi-VN/changelog.json new file mode 100644 index 000000000000..dea0fbeb1097 --- /dev/null +++ b/locales/vi-VN/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "Theo dõi chúng tôi trên X", + "subscribeToUpdates": "Đăng ký nhận cập nhật", + "versions": "Chi tiết phiên bản" + }, + "addedWhileAway": "Chúng tôi đã mang đến những tính năng mới trong thời gian bạn vắng mặt.", + "allChangelog": "Xem tất cả nhật ký cập nhật", + "description": "Theo dõi các tính năng và cải tiến mới của {{appName}}", + "pagination": { + "older": "Xem thay đổi lịch sử", + "prev": "Trang trước" + }, + "readDetails": "Đọc chi tiết", + "title": "Nhật ký cập nhật", + "versionDetails": "Chi tiết phiên bản", + "welcomeBack": "Chào mừng bạn trở lại!" +} diff --git a/locales/vi-VN/common.json b/locales/vi-VN/common.json index 2c0c55e0764c..42fab0ebb57c 100644 --- a/locales/vi-VN/common.json +++ b/locales/vi-VN/common.json @@ -218,6 +218,7 @@ "pinOff": "Bỏ ghim", "privacy": "Chính sách bảo mật", "regenerate": "Tạo lại", + "releaseNotes": "Chi tiết phiên bản", "rename": "Đổi tên", "reset": "Đặt lại", "retry": "Thử lại", diff --git a/locales/vi-VN/metadata.json b/locales/vi-VN/metadata.json index 365a60c61115..8a21cdc5bc2c 100644 --- a/locales/vi-VN/metadata.json +++ b/locales/vi-VN/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "Theo dõi các tính năng và cải tiến mới của {{appName}}", + "title": "Nhật ký cập nhật" + }, "chat": { "description": "{{appName}} mang đến cho bạn trải nghiệm tốt nhất với ChatGPT, Claude, Gemini, OLLaMA WebUI", "title": "{{appName}}: Công cụ AI cá nhân, giúp bạn có một bộ não thông minh hơn" diff --git a/locales/zh-CN/changelog.json b/locales/zh-CN/changelog.json new file mode 100644 index 000000000000..c139ed36954b --- /dev/null +++ b/locales/zh-CN/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "在 X 上关注我们", + "subscribeToUpdates": "订阅更新", + "versions": "版本详情" + }, + "addedWhileAway": "在您离开期间,我们带来了新的特性。", + "allChangelog": "查看所有更新日志", + "description": "持续追踪 {{appName}} 的新功能和改进", + "pagination": { + "older": "查看历史变更", + "prev": "上一页" + }, + "readDetails": "阅读详情", + "title": "更新日志", + "versionDetails": "版本详情", + "welcomeBack": "欢迎回来!" +} diff --git a/locales/zh-CN/common.json b/locales/zh-CN/common.json index 78456d650f88..5c276d43ae88 100644 --- a/locales/zh-CN/common.json +++ b/locales/zh-CN/common.json @@ -218,6 +218,7 @@ "pinOff": "取消置顶", "privacy": "隐私政策", "regenerate": "重新生成", + "releaseNotes": "版本详情", "rename": "重命名", "reset": "重置", "retry": "重试", diff --git a/locales/zh-CN/metadata.json b/locales/zh-CN/metadata.json index a6fa05bd8a92..c47cf8685fd5 100644 --- a/locales/zh-CN/metadata.json +++ b/locales/zh-CN/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "持续追踪 {{appName}} 的新功能和改进", + "title": "更新日志" + }, "chat": { "description": "{{appName}} 带给你最好的 ChatGPT, Claude , Gemini, OLLaMA WebUI 使用体验", "title": "{{appName}}:个人 AI 效能工具,给自己一个更聪明的大脑" diff --git a/locales/zh-TW/changelog.json b/locales/zh-TW/changelog.json new file mode 100644 index 000000000000..e7d265ca8fe8 --- /dev/null +++ b/locales/zh-TW/changelog.json @@ -0,0 +1,18 @@ +{ + "actions": { + "followOnX": "在 X 上關注我們", + "subscribeToUpdates": "訂閱更新", + "versions": "版本詳情" + }, + "addedWhileAway": "在您離開期間,我們帶來了新的特性。", + "allChangelog": "查看所有更新日誌", + "description": "持續追蹤 {{appName}} 的新功能和改進", + "pagination": { + "older": "查看歷史變更", + "prev": "上一頁" + }, + "readDetails": "閱讀詳情", + "title": "更新日誌", + "versionDetails": "版本詳情", + "welcomeBack": "歡迎回來!" +} diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f95782393bfd..7ccfe9923631 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -218,6 +218,7 @@ "pinOff": "取消置頂", "privacy": "隱私政策", "regenerate": "重新生成", + "releaseNotes": "版本詳細", "rename": "重新命名", "reset": "重置", "retry": "重試", diff --git a/locales/zh-TW/metadata.json b/locales/zh-TW/metadata.json index c59b51e853c6..d856fbc52c18 100644 --- a/locales/zh-TW/metadata.json +++ b/locales/zh-TW/metadata.json @@ -1,4 +1,8 @@ { + "changelog": { + "description": "持續追蹤 {{appName}} 的新功能和改進", + "title": "更新日誌" + }, "chat": { "description": "{{appName}} 帶給你最好的 ChatGPT, Claude, Gemini, OLLaMA WebUI 使用體驗", "title": "{{appName}}:個人 AI 效能工具,給自己一個更聰明的大腦" diff --git a/package.json b/package.json index 9590226aa752..f4db550d93f7 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "test:update": "vitest -u", "type-check": "tsc --noEmit", "webhook:ngrok": "ngrok http http://localhost:3011", + "workflow:cdn": "tsx ./scripts/cdnWorkflow/index.ts", "workflow:changelog": "tsx ./scripts/changelogWorkflow/index.ts", "workflow:countCharters": "tsx scripts/countEnWord.ts", "workflow:docs": "tsx ./scripts/docsWorkflow/index.ts", @@ -190,6 +191,7 @@ "pdfjs-dist": "4.8.69", "pg": "^8.13.0", "pino": "^9.5.0", + "plaiceholder": "^3.0.0", "polished": "^4.3.1", "posthog-js": "^1.174.2", "pwa-install-handler": "^2.6.1", @@ -251,6 +253,7 @@ "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.1", "@types/chroma-js": "^2.4.4", + "@types/crypto-js": "^4.2.2", "@types/debug": "^4.1.12", "@types/diff": "^6.0.0", "@types/fs-extra": "^11.0.4", @@ -274,6 +277,7 @@ "ajv-keywords": "^5.1.0", "commitlint": "^19.5.0", "consola": "^3.2.3", + "crypto-js": "^4.2.0", "dotenv": "^16.4.5", "dpdm-fast": "^1.0.4", "drizzle-kit": "^0.30.0", @@ -291,6 +295,7 @@ "lodash": "^4.17.21", "markdown-table": "^3.0.3", "markdown-to-txt": "^2.0.1", + "mime": "^4.0.4", "node-fetch": "^3.3.2", "node-gyp": "^10.2.0", "openapi-typescript": "^6.7.6", diff --git a/scripts/cdnWorkflow/index.ts b/scripts/cdnWorkflow/index.ts new file mode 100644 index 000000000000..fa907d08a0ae --- /dev/null +++ b/scripts/cdnWorkflow/index.ts @@ -0,0 +1,217 @@ +import { consola } from 'consola'; +import { writeJSONSync } from 'fs-extra'; +import matter from 'gray-matter'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import pMap from 'p-map'; + +import { uploader } from './uploader'; +import { + changelogIndex, + changelogIndexPath, + extractHttpsLinks, + fetchImageAsFile, + mergeAndDeduplicateArrays, + posts, + root, +} from './utils'; + +// 定义常量 +const GITHUB_CDN = 'https://github.com/lobehub/lobe-chat/assets/'; +const CHECK_CDN = [ + 'https://cdn.nlark.com/yuque/0/', + 'https://s.imtccdn.com/', + 'https://oss.home.imtc.top/', + 'https://www.anthropic.com/_next/image', + 'https://miro.medium.com/v2/', + 'https://images.unsplash.com/', + 'https://github.com/user-attachments/assets', +]; + +const CACHE_FILE = resolve(root, 'docs', '.cdn.cache.json'); + +class ImageCDNUploader { + private cache: { [link: string]: string } = {}; + + constructor() { + this.loadCache(); + } + + // 从文件加载缓存数据 + private loadCache() { + try { + this.cache = JSON.parse(readFileSync(CACHE_FILE, 'utf8')); + } catch (error) { + consola.error('Failed to load cache', error); + } + } + + // 将缓存数据写入文件 + private writeCache() { + try { + writeFileSync(CACHE_FILE, JSON.stringify(this.cache, null, 2)); + } catch (error) { + consola.error('Failed to write cache', error); + } + } + + // 收集所有的图片链接 + private collectImageLinks(): string[] { + const links: string[][] = posts.map((post) => { + const mdx = readFileSync(post, 'utf8'); + const { content, data } = matter(mdx); + let inlineLinks: string[] = extractHttpsLinks(content); + + // 添加特定字段中的图片链接 + if (data?.image) inlineLinks.push(data.image); + if (data?.seo?.image) inlineLinks.push(data.seo.image); + + // 过滤出有效的 CDN 链接 + return inlineLinks.filter( + (link) => + (link.startsWith(GITHUB_CDN) || CHECK_CDN.some((cdn) => link.startsWith(cdn))) && + !this.cache[link], + ); + }); + + const communityLinks = changelogIndex.community + .map((post) => post.image) + .filter( + (link) => + link && + (link.startsWith(GITHUB_CDN) || CHECK_CDN.some((cdn) => link.startsWith(cdn))) && + !this.cache[link], + ) as string[]; + + const cloudLinks = changelogIndex.cloud + .map((post) => post.image) + .filter( + (link) => + link && + (link.startsWith(GITHUB_CDN) || CHECK_CDN.some((cdn) => link.startsWith(cdn))) && + !this.cache[link], + ) as string[]; + + // 合并和去重链接数组 + return mergeAndDeduplicateArrays(links.flat().concat(communityLinks, cloudLinks)); + } + + // 上传图片到 CDN + private async uploadImagesToCDN(links: string[]) { + const cdnLinks: { [link: string]: string } = {}; + + await pMap(links, async (link) => { + consola.start('Uploading image to CDN', link); + const file = await fetchImageAsFile(link, 1600); + + if (!file) { + consola.error('Failed to fetch image as file', link); + return; + } + + const cdnUrl = await this.uploadFileToCDN(file, link); + if (cdnUrl) { + consola.success(link, '>>>', cdnUrl); + cdnLinks[link] = cdnUrl.replaceAll(process.env.DOC_S3_PUBLIC_DOMAIN || '', ''); + } + }); + + // 更新缓存 + this.cache = { ...this.cache, ...cdnLinks }; + this.writeCache(); + } + + // 根据不同的 CDN 来处理文件上传 + private async uploadFileToCDN(file: File, link: string): Promise { + if (link.startsWith(GITHUB_CDN)) { + const filename = link.replaceAll(GITHUB_CDN, ''); + return uploader(file, filename); + } else if (CHECK_CDN.some((cdn) => link.startsWith(cdn))) { + const buffer = await file.arrayBuffer(); + const hash = createHash('md5').update(Buffer.from(buffer)).digest('hex'); + return uploader(file, hash); + } + + return; + } + + // 替换文章中的图片链接 + private replaceLinksInPosts() { + let count = 0; + + for (const post of posts) { + const mdx = readFileSync(post, 'utf8'); + let { content, data } = matter(mdx); + const inlineLinks = extractHttpsLinks(content); + + for (const link of inlineLinks) { + if (this.cache[link]) { + content = content.replaceAll(link, this.cache[link]); + count++; + } + } + + // 更新特定字段的图片链接 + + if (data['image'] && this.cache[data['image']]) { + data['image'] = this.cache[data['image']]; + count++; + } + + if (data['seo']?.['image'] && this.cache[data['seo']?.['image']]) { + data['seo']['image'] = this.cache[data['seo']['image']]; + count++; + } + + writeFileSync(post, matter.stringify(content, data)); + } + + consola.success(`${count} images have been uploaded to CDN and links have been replaced`); + } + + private replaceLinksInChangelogIndex() { + let count = 0; + changelogIndex.community = changelogIndex.community.map((post) => { + if (!post.image) return post; + count++; + return { + ...post, + image: this.cache[post.image] || post.image, + }; + }); + + changelogIndex.cloud = changelogIndex.cloud.map((post) => { + if (!post.image) return post; + count++; + return { + ...post, + image: this.cache[post.image] || post.image, + }; + }); + + writeJSONSync(changelogIndexPath, changelogIndex, { spaces: 2 }); + + consola.success( + `${count} changelog index images have been uploaded to CDN and links have been replaced`, + ); + } + + // 运行上传过程 + async run() { + const links = this.collectImageLinks(); + + if (links.length > 0) { + consola.info("Found images that haven't been uploaded to CDN:"); + consola.info(links); + await this.uploadImagesToCDN(links); + } else { + consola.info('No new images to upload.'); + } + } +} + +// 实例化并运行 +const instance = new ImageCDNUploader(); + +instance.run(); diff --git a/scripts/cdnWorkflow/optimized.ts b/scripts/cdnWorkflow/optimized.ts new file mode 100644 index 000000000000..6cfcd3a2ca02 --- /dev/null +++ b/scripts/cdnWorkflow/optimized.ts @@ -0,0 +1,21 @@ +import sharp from 'sharp'; + +const WIDTH = 1600; + +export const opimized = async ( + inputBuffer: ArrayBuffer, + width: number = WIDTH, +): Promise => { + return await sharp(inputBuffer) + .resize({ width: width, withoutEnlargement: true }) + .webp() + .toBuffer(); +}; + +export const opimizedGif = async (inputBuffer: ArrayBuffer): Promise => { + try { + return await sharp(inputBuffer, { animated: true }).webp().toBuffer(); + } catch { + return await sharp(inputBuffer, { animated: true, limitInputPixels: false }).webp().toBuffer(); + } +}; diff --git a/scripts/cdnWorkflow/s3/index.ts b/scripts/cdnWorkflow/s3/index.ts new file mode 100644 index 000000000000..2b3436a580ea --- /dev/null +++ b/scripts/cdnWorkflow/s3/index.ts @@ -0,0 +1,120 @@ +import { + GetObjectCommand, + PutObjectCommand, + PutObjectCommandOutput, + S3Client, + S3ClientConfig, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +import type { ImgInfo, S3UserConfig, UploadResult } from './types'; +import { extractInfo } from './utils'; + +async function getFileURL( + opts: createUploadTaskOpts, + eTag: string, + versionId: string, +): Promise { + try { + const signedUrl = await getSignedUrl( + opts.client, + new GetObjectCommand({ + Bucket: opts.bucketName, + IfMatch: eTag, + Key: opts.path, + VersionId: versionId, + }), + { expiresIn: 3600 }, + ); + const urlObject = new URL(signedUrl); + urlObject.search = ''; + return urlObject.href; + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error); + } +} + +function createS3Client(opts: S3UserConfig): S3Client { + const clientOptions: S3ClientConfig = { + credentials: { + accessKeyId: opts.accessKeyId, + secretAccessKey: opts.secretAccessKey, + }, + + endpoint: opts.endpoint || undefined, + forcePathStyle: opts.pathStyleAccess, + region: opts.region || 'auto', + }; + + const client = new S3Client(clientOptions); + return client; +} + +interface createUploadTaskOpts { + acl: string; + bucketName: string; + client: S3Client; + item: ImgInfo; + path: string; + urlPrefix?: string; +} + +async function createUploadTask(opts: createUploadTaskOpts): Promise { + if (!opts.item.buffer) { + throw new Error('undefined image'); + } + + let body: Buffer; + let contentType: string; + let contentEncoding: string; + + try { + ({ body, contentType, contentEncoding } = (await extractInfo(opts.item)) as any); + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error); + } + + const command = new PutObjectCommand({ + ACL: opts.acl as any, + Body: body, + Bucket: opts.bucketName, + ContentEncoding: contentEncoding, + ContentType: contentType, + Key: opts.path, + }); + + let output: PutObjectCommandOutput; + try { + output = await opts.client.send(command); + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error); + } + + let url: string; + if (opts.urlPrefix) { + url = `${opts.urlPrefix}/${opts.path}`; + } else { + try { + url = await getFileURL(opts, output.ETag as string, output.VersionId as string); + } catch (error) { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject + return Promise.reject(error); + } + } + + return { + eTag: output.ETag, + imgURL: url, + key: opts.path, + url: url, + versionId: output.VersionId, + }; +} + +export default { + createS3Client, + createUploadTask, +}; diff --git a/scripts/cdnWorkflow/s3/types.ts b/scripts/cdnWorkflow/s3/types.ts new file mode 100644 index 000000000000..61d0c41f7cc5 --- /dev/null +++ b/scripts/cdnWorkflow/s3/types.ts @@ -0,0 +1,25 @@ +export interface ImgInfo { + [propName: string]: any; + buffer: Buffer; + extname: string; + fileName: string; +} + +export interface S3UserConfig { + accessKeyId: string; + bucketName: string; + endpoint: string; + pathPrefix: string; + pathStyleAccess?: boolean; + region: string; + secretAccessKey: string; + uploadPath?: string; +} + +export interface UploadResult { + eTag?: string; + imgURL: string; + key: string; + url: string; + versionId?: string; +} diff --git a/scripts/cdnWorkflow/s3/utils.ts b/scripts/cdnWorkflow/s3/utils.ts new file mode 100644 index 000000000000..69c62776b88b --- /dev/null +++ b/scripts/cdnWorkflow/s3/utils.ts @@ -0,0 +1,106 @@ +import CryptoJS from 'crypto-js'; +import mime from 'mime'; + +import { ImgInfo } from './types'; + +class FileNameGenerator { + date: Date; + info: ImgInfo; + + static fields = [ + 'year', + 'month', + 'day', + 'fullName', + 'fileName', + 'extName', + 'timestamp', + 'timestampMS', + 'md5', + ]; + + constructor(info: ImgInfo) { + this.date = new Date(); + this.info = info; + } + + public year(): string { + return `${this.date.getFullYear()}`; + } + + public month(): string { + return this.date.getMonth() < 9 + ? `0${this.date.getMonth() + 1}` + : `${this.date.getMonth() + 1}`; + } + + public day(): string { + return this.date.getDate() < 9 ? `0${this.date.getDate()}` : `${this.date.getDate()}`; + } + + public fullName(): string { + return this.info.fileName; + } + + public fileName(): string { + return this.info.fileName.replace(this.info.extname, ''); + } + + public extName(): string { + return this.info.extname.replace('.', ''); + } + + public timestamp(): string { + return Math.floor(Date.now() / 1000).toString(); + } + + public timestampMS(): string { + return Date.now().toString(); + } + + public md5(): string { + const wordArray = CryptoJS.lib.WordArray.create(this.imgBuffer()); + const md5Hash = CryptoJS.MD5(wordArray); + return md5Hash.toString(CryptoJS.enc.Hex); + } + private imgBuffer(): Buffer { + return this.info.buffer; + } +} + +export function formatPath(info: ImgInfo, format?: string): string { + if (!format) { + return info.fileName; + } + + const fileNameGenerator = new FileNameGenerator(info); + + let formatPath: string = format; + + for (const key of FileNameGenerator.fields) { + const re = new RegExp(`{${key}}`, 'g'); + // @ts-ignore + formatPath = formatPath.replace(re, fileNameGenerator[key]()); + } + + return formatPath; +} + +export async function extractInfo(info: ImgInfo): Promise<{ + body?: Buffer; + contentEncoding?: string; + contentType?: string; +}> { + const result: { + body?: Buffer; + contentEncoding?: string; + contentType?: string; + } = {}; + + if (info.extname) { + result.contentType = mime.getType(info.extname) as string; + } + result.body = info.buffer; + + return result; +} diff --git a/scripts/cdnWorkflow/uploader.ts b/scripts/cdnWorkflow/uploader.ts new file mode 100644 index 000000000000..db85ec17768e --- /dev/null +++ b/scripts/cdnWorkflow/uploader.ts @@ -0,0 +1,73 @@ +import { consola } from 'consola'; +import dotenv from 'dotenv'; + +import s3 from './s3'; +import type { ImgInfo, S3UserConfig, UploadResult } from './s3/types'; +import { formatPath } from './s3/utils'; + +dotenv.config(); + +if (!process.env.DOC_S3_ACCESS_KEY_ID) { + consola.error('请配置 Doc S3 存储的环境变量: DOC_S3_ACCESS_KEY_ID'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} + +if (!process.env.DOC_S3_SECRET_ACCESS_KEY) { + consola.error('请配置 Doc S3 存储的环境变量: DOC_S3_SECRET_ACCESS_KEY'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} + +if (!process.env.DOC_S3_PUBLIC_DOMAIN) { + consola.error('请配置 Doc S3 存储的环境变量: DOC_S3_PUBLIC_DOMAIN'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} + +export const BASE_PATH = 'blog/assets'; + +export const uploader = async ( + file: File, + filename: string, + basePath: string = BASE_PATH, + uploadPath?: string, +) => { + const item: ImgInfo = { + buffer: Buffer.from(await file.arrayBuffer()), + extname: file.name.split('.').pop() as string, + fileName: file.name, + mimeType: file.type, + }; + + const userConfig: S3UserConfig = { + accessKeyId: process.env.DOC_S3_ACCESS_KEY_ID || '', + bucketName: 'hub-apac-1', + endpoint: 'https://d35842305b91be4b48e06ff9a9ad83f5.r2.cloudflarestorage.com', + pathPrefix: process.env.DOC_S3_PUBLIC_DOMAIN || '', + pathStyleAccess: true, + region: 'auto', + secretAccessKey: process.env.DOC_S3_SECRET_ACCESS_KEY || '', + uploadPath: uploadPath || `${basePath}${filename}.{extName}`, + }; + + const client = s3.createS3Client(userConfig); + + let results: UploadResult; + + try { + results = await s3.createUploadTask({ + acl: 'public-read', + bucketName: userConfig.bucketName, + client, + item: item, + path: formatPath(item, userConfig.uploadPath), + urlPrefix: userConfig.pathPrefix, + }); + + return results.url; + } catch (error) { + consola.error('上传到 S3 存储发生错误,请检查网络连接和配置是否正确'); + consola.error(error); + } +}; diff --git a/scripts/cdnWorkflow/utils.ts b/scripts/cdnWorkflow/utils.ts new file mode 100644 index 000000000000..db3a42654ad5 --- /dev/null +++ b/scripts/cdnWorkflow/utils.ts @@ -0,0 +1,93 @@ +import { readJSONSync } from 'fs-extra'; +import { globSync } from 'glob'; +import { resolve } from 'node:path'; + +import { opimized, opimizedGif } from './optimized'; + +export const fixWinPath = (path: string) => path.replaceAll('\\', '/'); + +export const root = resolve(__dirname, '../..'); + +export const posts = globSync(fixWinPath(resolve(root, 'docs/changelog/*.mdx'))); + +interface ChangelogItem { + date: string; + id: string; + image?: string; + versionRange: string[]; +} + +export const changelogIndexPath = resolve(root, 'docs/changelog/index.json'); + +export const changelogIndex: { + cloud: ChangelogItem[]; + community: ChangelogItem[]; +} = readJSONSync(changelogIndexPath); + +export const extractHttpsLinks = (text: string) => { + const regex = /https:\/\/[^\s"')>]+/g; + const links = text.match(regex); + return links || []; +}; + +export const mergeAndDeduplicateArrays = (...arrays: string[][]) => { + const combinedArray = arrays.flat(); + const uniqueSet = new Set(combinedArray); + return Array.from(uniqueSet); +}; + +const mimeToExtensions = { + 'image/gif': '.gif', + // 图片类型 + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/webp': '.webp', + // 视频类型 + 'video/mp4': '.mp4', + 'video/mpeg': '.mpeg', + 'video/ogg': '.ogv', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-flv': '.flv', + 'video/x-matroska': '.mkv', + 'video/x-ms-wmv': '.wmv', + 'video/x-msvideo': '.avi', +}; + +// @ts-ignore +const getExtension = (type: string) => mimeToExtensions?.[type] || '.png'; + +export const fetchImageAsFile = async (url: string, width: number) => { + try { + // Step 1: Fetch the image + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Step 2: Create a blob from the response data + const blob = await response.blob(); + let buffer: ArrayBuffer | Buffer = await blob.arrayBuffer(); + let type = getExtension(blob.type); + if (type === '.gif') { + buffer = await opimizedGif(buffer); + type = '.webp'; + } else if (type === '.png' || type === '.jpg') { + buffer = await opimized(buffer, width); + type = '.webp'; + } + + const filename = Date.now().toString() + type; + + // Step 3: Create a file from the blob + const file: File = new File([buffer], filename, { + lastModified: Date.now(), + type: type === '.webp' ? 'image/webp' : blob.type, + }); + + return file; + } catch (error) { + console.error('Error fetching image as file:', error); + } +}; diff --git a/src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx b/src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx index f59ac8e5b550..addb02536f0d 100644 --- a/src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +++ b/src/app/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx @@ -1,10 +1,15 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import { ServerConfigStoreProvider } from '@/store/serverConfig'; import { useUserStore } from '@/store/user'; import { useCategory } from '../features/useCategory'; +const wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + // Mock dependencies vi.mock('next/navigation', () => ({ useRouter: vi.fn(() => ({ @@ -24,7 +29,7 @@ vi.mock('../../settings/features/useCategory', () => ({ // 定义一个变量来存储 enableAuth 的值 let enableAuth = true; -let enableClerk = false; +let enableClerk = true; // 模拟 @/const/auth 模块 vi.mock('@/const/auth', () => ({ get enableAuth() { @@ -37,7 +42,7 @@ vi.mock('@/const/auth', () => ({ afterEach(() => { enableAuth = true; - enableClerk = false; + enableClerk = true; }); // 目前对 enableAuth 的判定是在 useUserStore 中,所以需要 mock useUserStore @@ -47,8 +52,10 @@ describe('useCategory', () => { act(() => { useUserStore.setState({ isSignedIn: true, enableAuth: () => true }); }); + enableAuth = true; + enableClerk = false; - const { result } = renderHook(() => useCategory()); + const { result } = renderHook(() => useCategory(), { wrapper }); act(() => { const items = result.current; @@ -57,7 +64,7 @@ describe('useCategory', () => { expect(items.some((item) => item.key === 'data')).toBe(true); expect(items.some((item) => item.key === 'docs')).toBe(true); expect(items.some((item) => item.key === 'feedback')).toBe(true); - expect(items.some((item) => item.key === 'discord')).toBe(true); + expect(items.some((item) => item.key === 'changelog')).toBe(true); }); }); @@ -65,9 +72,10 @@ describe('useCategory', () => { act(() => { useUserStore.setState({ isSignedIn: true }); }); + enableAuth = true; enableClerk = true; - const { result } = renderHook(() => useCategory()); + const { result } = renderHook(() => useCategory(), { wrapper }); act(() => { const items = result.current; @@ -76,16 +84,21 @@ describe('useCategory', () => { expect(items.some((item) => item.key === 'data')).toBe(true); expect(items.some((item) => item.key === 'docs')).toBe(true); expect(items.some((item) => item.key === 'feedback')).toBe(true); - expect(items.some((item) => item.key === 'discord')).toBe(true); + expect(items.some((item) => item.key === 'changelog')).toBe(true); }); }); it('should return correct items when the user is logged in with NextAuth', () => { act(() => { - useUserStore.setState({ isSignedIn: true, enableAuth: () => true, enabledNextAuth: true }); + useUserStore.setState({ + isSignedIn: true, + enableAuth: () => true, + enabledNextAuth: true, + }); }); + enableClerk = false; - const { result } = renderHook(() => useCategory()); + const { result } = renderHook(() => useCategory(), { wrapper }); act(() => { const items = result.current; @@ -95,7 +108,7 @@ describe('useCategory', () => { expect(items.some((item) => item.key === 'data')).toBe(true); expect(items.some((item) => item.key === 'docs')).toBe(true); expect(items.some((item) => item.key === 'feedback')).toBe(true); - expect(items.some((item) => item.key === 'discord')).toBe(true); + expect(items.some((item) => item.key === 'changelog')).toBe(true); expect(items.some((item) => item.key === 'nextauthSignout')).toBe(true); }); }); @@ -105,7 +118,7 @@ describe('useCategory', () => { useUserStore.setState({ isSignedIn: false, enableAuth: () => true }); }); - const { result } = renderHook(() => useCategory()); + const { result } = renderHook(() => useCategory(), { wrapper }); act(() => { const items = result.current; @@ -114,7 +127,7 @@ describe('useCategory', () => { expect(items.some((item) => item.key === 'data')).toBe(false); expect(items.some((item) => item.key === 'docs')).toBe(true); expect(items.some((item) => item.key === 'feedback')).toBe(true); - expect(items.some((item) => item.key === 'discord')).toBe(true); + expect(items.some((item) => item.key === 'changelog')).toBe(true); expect(items.some((item) => item.key === 'nextauthSignout')).toBe(false); }); }); @@ -125,7 +138,7 @@ describe('useCategory', () => { }); enableClerk = false; - const { result } = renderHook(() => useCategory()); + const { result } = renderHook(() => useCategory(), { wrapper }); act(() => { const items = result.current; diff --git a/src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx b/src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx index b83b549097b5..6f8b8e713232 100644 --- a/src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx +++ b/src/app/(main)/(mobile)/me/(home)/features/useCategory.tsx @@ -1,10 +1,11 @@ -import { DiscordIcon } from '@lobehub/ui'; import { Book, CircleUserRound, + Cloudy, Database, Download, Feather, + FileClockIcon, LogOut, Settings2, } from 'lucide-react'; @@ -12,11 +13,13 @@ import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { CellProps } from '@/components/Cell'; -import { DISCORD, DOCUMENTS, FEEDBACK } from '@/const/url'; +import { LOBE_CHAT_CLOUD } from '@/const/branding'; +import { DOCUMENTS, FEEDBACK, OFFICIAL_URL, UTM_SOURCE } from '@/const/url'; import { isServerMode } from '@/const/version'; import { usePWAInstall } from '@/hooks/usePWAInstall'; +import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; import { useUserStore } from '@/store/user'; -import { authSelectors } from '@/store/user/slices/auth/selectors'; +import { authSelectors } from '@/store/user/selectors'; import { useCategory as useSettingsCategory } from '../../settings/features/useCategory'; @@ -24,6 +27,7 @@ export const useCategory = () => { const router = useRouter(); const { canInstall, install } = usePWAInstall(); const { t } = useTranslation(['common', 'setting', 'auth']); + const { showCloudPromotion, hideDocs } = useServerConfigStore(featureFlagsSelectors); const [isLogin, isLoginWithAuth, isLoginWithClerk, enableAuth, signOut, isLoginWithNextAuth] = useUserStore((s) => [ authSelectors.isLogin(s), @@ -91,6 +95,12 @@ export const useCategory = () => { ]; const helps: CellProps[] = [ + showCloudPromotion && { + icon: Cloudy, + key: 'cloud', + label: t('userPanel.cloud', { name: LOBE_CHAT_CLOUD }), + onClick: () => window.open(`${OFFICIAL_URL}?utm_source=${UTM_SOURCE}`, '__blank'), + }, { icon: Book, key: 'docs', @@ -104,12 +114,12 @@ export const useCategory = () => { onClick: () => window.open(FEEDBACK, '__blank'), }, { - icon: DiscordIcon, - key: 'discord', - label: 'Discord', - onClick: () => window.open(DISCORD, '__blank'), + icon: FileClockIcon, + key: 'changelog', + label: t('changelog'), + onClick: () => router.push('/changelog'), }, - ]; + ].filter(Boolean) as CellProps[]; const nextAuthSignOut: CellProps[] = [ { @@ -131,7 +141,7 @@ export const useCategory = () => { /* ↑ cloud slot ↑ */ ...(canInstall ? pwa : []), ...(isLogin && !isServerMode ? data : []), - ...helps, + ...(!hideDocs ? helps : []), ...(enableAuth && isLoginWithNextAuth ? nextAuthSignOut : []), ].filter(Boolean) as CellProps[]; diff --git a/src/app/(main)/_layout/Desktop.tsx b/src/app/(main)/_layout/Desktop.tsx index b6927fdc4bc0..d265868b8302 100644 --- a/src/app/(main)/_layout/Desktop.tsx +++ b/src/app/(main)/_layout/Desktop.tsx @@ -1,15 +1,18 @@ 'use client'; import { useTheme } from 'antd-style'; +import dynamic from 'next/dynamic'; import { memo } from 'react'; import { Flexbox } from 'react-layout-kit'; -import CloudBanner, { BANNER_HEIGHT } from '@/features/AlertBanner/CloudBanner'; +import { BANNER_HEIGHT } from '@/features/AlertBanner/CloudBanner'; import { usePlatform } from '@/hooks/usePlatform'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; import { LayoutProps } from './type'; +const CloudBanner = dynamic(() => import('@/features/AlertBanner/CloudBanner')); + const Layout = memo(({ children, nav }) => { const { isPWA } = usePlatform(); const theme = useTheme(); diff --git a/src/app/(main)/_layout/Mobile.tsx b/src/app/(main)/_layout/Mobile.tsx index 68da70e3a69d..0d430151f2b6 100644 --- a/src/app/(main)/_layout/Mobile.tsx +++ b/src/app/(main)/_layout/Mobile.tsx @@ -1,15 +1,16 @@ 'use client'; +import dynamic from 'next/dynamic'; import { usePathname } from 'next/navigation'; import qs from 'query-string'; import { memo } from 'react'; -import CloudBanner from '@/features/AlertBanner/CloudBanner'; import { useQuery } from '@/hooks/useQuery'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; import { LayoutProps } from './type'; +const CloudBanner = dynamic(() => import('@/features/AlertBanner/CloudBanner')); const MOBILE_NAV_ROUTES = new Set([ '/chat', '/discover', diff --git a/src/app/(main)/changelog/_layout/Desktop.tsx b/src/app/(main)/changelog/_layout/Desktop.tsx new file mode 100644 index 000000000000..fa025dd51d4d --- /dev/null +++ b/src/app/(main)/changelog/_layout/Desktop.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import Hero from '../features/Hero'; + +type Props = { children: ReactNode }; + +const Layout = ({ children }: Props) => { + return ( + + + + {children} + + + ); +}; + +Layout.displayName = 'DesktopChangelogLayout'; + +export default Layout; diff --git a/src/app/(main)/changelog/_layout/Mobile/Header.tsx b/src/app/(main)/changelog/_layout/Mobile/Header.tsx new file mode 100644 index 000000000000..4a4b0fb39a99 --- /dev/null +++ b/src/app/(main)/changelog/_layout/Mobile/Header.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { MobileNavBar, MobileNavBarTitle } from '@lobehub/ui'; +import { useRouter } from 'next/navigation'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flexbox } from 'react-layout-kit'; + +import { mobileHeaderSticky } from '@/styles/mobileHeader'; + +const Header = memo(() => { + const { t } = useTranslation('changelog'); + + const router = useRouter(); + return ( + + {t('title')} + + } + /> + } + onBackClick={() => router.back()} + showBackButton + style={mobileHeaderSticky} + /> + ); +}); + +export default Header; diff --git a/src/app/(main)/changelog/_layout/Mobile/index.tsx b/src/app/(main)/changelog/_layout/Mobile/index.tsx new file mode 100644 index 000000000000..e140bda944ea --- /dev/null +++ b/src/app/(main)/changelog/_layout/Mobile/index.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react'; + +import MobileContentLayout from '@/components/server/MobileNavLayout'; + +import Hero from '../../features/Hero'; +import Header from './Header'; + +type Props = { children: ReactNode }; + +const Layout = ({ children }: Props) => { + return ( + } padding={16}> + + {children} + + ); +}; + +Layout.displayName = 'MobileChangelogLayout'; + +export default Layout; diff --git a/src/app/(main)/changelog/error.tsx b/src/app/(main)/changelog/error.tsx new file mode 100644 index 000000000000..071491038c70 --- /dev/null +++ b/src/app/(main)/changelog/error.tsx @@ -0,0 +1,5 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +export default dynamic(() => import('@/components/Error')); diff --git a/src/app/(main)/changelog/features/GridLayout.tsx b/src/app/(main)/changelog/features/GridLayout.tsx new file mode 100644 index 000000000000..31c2bd6c1a0c --- /dev/null +++ b/src/app/(main)/changelog/features/GridLayout.tsx @@ -0,0 +1,22 @@ +import { FC, PropsWithChildren, ReactNode } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +const GridLayout: FC> = ({ + mobile, + children, + date, +}) => { + return ( + + + {date} + + + {children} + + {!mobile && } + + ); +}; + +export default GridLayout; diff --git a/src/app/(main)/changelog/features/Hero.tsx b/src/app/(main)/changelog/features/Hero.tsx new file mode 100644 index 000000000000..abaa85f3544e --- /dev/null +++ b/src/app/(main)/changelog/features/Hero.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useResponsive, useTheme } from 'antd-style'; +import Link from 'next/link'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flexbox } from 'react-layout-kit'; +import urlJoin from 'url-join'; + +import { BRANDING_NAME } from '@/const/branding'; +import { OFFICIAL_SITE, X } from '@/const/url'; + +import GridLayout from './GridLayout'; + +const Hero = memo(() => { + const { t } = useTranslation('changelog'); + const theme = useTheme(); + const { mobile } = useResponsive(); + return ( + + + {t('title')} + + {t('description', { appName: BRANDING_NAME })} + + + + {t('actions.versions')} + + · + + {t('actions.followOnX')} + + + + + ); +}); + +export default Hero; diff --git a/src/app/(main)/changelog/features/Post.tsx b/src/app/(main)/changelog/features/Post.tsx new file mode 100644 index 000000000000..0857cf4e56a1 --- /dev/null +++ b/src/app/(main)/changelog/features/Post.tsx @@ -0,0 +1,56 @@ +import { Typography } from '@lobehub/ui'; +import { Divider } from 'antd'; +import Link from 'next/link'; +import urlJoin from 'url-join'; + +import { CustomMDX } from '@/components/mdx'; +import Image from '@/components/mdx/Image'; +import { OFFICIAL_SITE } from '@/const/url'; +import { Locales } from '@/locales/resources'; +import { ChangelogService } from '@/server/services/changelog'; +import { ChangelogIndexItem } from '@/types/changelog'; + +import GridLayout from './GridLayout'; +import PublishedTime from './PublishedTime'; +import VersionTag from './VersionTag'; + +const Post = async ({ + id, + mobile, + versionRange, + locale, +}: ChangelogIndexItem & { branch?: string; locale: Locales; mobile?: boolean }) => { + const changelogService = new ChangelogService(); + const data = await changelogService.getPostById(id, { locale }); + + if (!data || !data.title) return null; + + return ( + <> + + + } + mobile={mobile} + > + + + {data.rawTitle || data.title} + + + + + + + + + > + ); +}; + +export default Post; diff --git a/src/app/(main)/changelog/features/PublishedTime.tsx b/src/app/(main)/changelog/features/PublishedTime.tsx new file mode 100644 index 000000000000..76a33664ecc5 --- /dev/null +++ b/src/app/(main)/changelog/features/PublishedTime.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { createStyles } from 'antd-style'; +import dayjs from 'dayjs'; +import 'dayjs/locale/zh.js'; +import { CSSProperties, FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +const useStyles = createStyles(({ css, token }) => { + return { + time: css` + margin-block: calc(var(--lobe-markdown-margin-multiple) * 1em); + + font-size: 14px; + line-height: var(--lobe-markdown-line-height); + color: ${token.colorTextSecondary}; + letter-spacing: 0.02em; + `, + }; +}); + +interface PrivacyUpdatedProps { + className?: string; + date: string; + style?: CSSProperties; + template?: string; +} +const PublishedTime: FC = ({ + date = new Date().toISOString(), + style, + className, + template = 'dddd, MMMM D YYYY', +}) => { + const { i18n } = useTranslation(); + const { styles, cx } = useStyles(); + const time = dayjs(date).locale(i18n.language).format(template); + + return ( + + {time} + + ); +}; + +export default PublishedTime; diff --git a/src/app/(main)/changelog/features/VersionTag.tsx b/src/app/(main)/changelog/features/VersionTag.tsx new file mode 100644 index 000000000000..1327e3620e60 --- /dev/null +++ b/src/app/(main)/changelog/features/VersionTag.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Tag } from '@lobehub/ui'; +import { createStyles } from 'antd-style'; +import { memo } from 'react'; + +const useStyles = createStyles(({ token, css }) => { + return { + tag: css` + margin: 0; + padding-block: 4px; + padding-inline: 12px; + + color: ${token.colorTextSecondary}; + + border-radius: 16px; + `, + }; +}); + +const VersionTag = memo<{ range: string[] }>(({ range }) => { + const { styles } = useStyles(); + + return {range.map((v) => 'v' + v).join(' ~ ')}; +}); + +export default VersionTag; diff --git a/src/app/(main)/changelog/layout.tsx b/src/app/(main)/changelog/layout.tsx new file mode 100644 index 000000000000..2de9fd0ecb3c --- /dev/null +++ b/src/app/(main)/changelog/layout.tsx @@ -0,0 +1,10 @@ +import ServerLayout from '@/components/server/ServerLayout'; + +import Desktop from './_layout/Desktop'; +import Mobile from './_layout/Mobile'; + +const MainLayout = ServerLayout({ Desktop, Mobile }); + +MainLayout.displayName = 'ChangelogLayout'; + +export default MainLayout; diff --git a/src/app/(main)/changelog/loading.tsx b/src/app/(main)/changelog/loading.tsx new file mode 100644 index 000000000000..14b2f7b8c18e --- /dev/null +++ b/src/app/(main)/changelog/loading.tsx @@ -0,0 +1,3 @@ +import { Skeleton } from 'antd'; + +export default () => ; diff --git a/src/app/(main)/changelog/modal/page.tsx b/src/app/(main)/changelog/modal/page.tsx new file mode 100644 index 000000000000..0d8d43e16ca5 --- /dev/null +++ b/src/app/(main)/changelog/modal/page.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useLayoutEffect } from 'react'; + +import { useQueryRoute } from '@/hooks/useQueryRoute'; + +/** + * @description: Changelog Modal (intercepting routes fallback when hard refresh) + * @example: /changelog/modal => /changelog + * @refs: https://github.com/lobehub/lobe-chat/discussions/2295#discussioncomment-9290942 + */ + +const ChangelogModalFallback = () => { + const router = useQueryRoute(); + + useLayoutEffect(() => { + router.replace('/changelog'); + }, []); + + return null; +}; + +export default ChangelogModalFallback; diff --git a/src/app/(main)/changelog/not-found.tsx b/src/app/(main)/changelog/not-found.tsx new file mode 100644 index 000000000000..02503bc7fa46 --- /dev/null +++ b/src/app/(main)/changelog/not-found.tsx @@ -0,0 +1,3 @@ +import dynamic from 'next/dynamic'; + +export default dynamic(() => import('@/components/404')); diff --git a/src/app/(main)/changelog/page.tsx b/src/app/(main)/changelog/page.tsx new file mode 100644 index 000000000000..861735fe65c6 --- /dev/null +++ b/src/app/(main)/changelog/page.tsx @@ -0,0 +1,73 @@ +import { Divider, Skeleton } from 'antd'; +import { notFound } from 'next/navigation'; +import { Fragment, Suspense } from 'react'; +import { Flexbox } from 'react-layout-kit'; +import urlJoin from 'url-join'; + +import Pagination from '@/app/@modal/(.)changelog/modal/features/Pagination'; +import StructuredData from '@/components/StructuredData'; +import { serverFeatureFlags } from '@/config/featureFlags'; +import { BRANDING_NAME } from '@/const/branding'; +import { OFFICIAL_SITE } from '@/const/url'; +import { ldModule } from '@/server/ld'; +import { metadataModule } from '@/server/metadata'; +import { ChangelogService } from '@/server/services/changelog'; +import { translation } from '@/server/translation'; +import { isMobileDevice } from '@/utils/server/responsive'; + +import GridLayout from './features/GridLayout'; +import Post from './features/Post'; + +export const generateMetadata = async () => { + const { t } = await translation('metadata'); + return metadataModule.generate({ + canonical: urlJoin(OFFICIAL_SITE, 'changelog'), + description: t('changelog.description', { appName: BRANDING_NAME }), + title: t('changelog.title'), + url: '/changelog', + }); +}; + +const Page = async () => { + const hideDocs = serverFeatureFlags().hideDocs; + + if (hideDocs) return notFound(); + + const mobile = await isMobileDevice(); + const { t, locale } = await translation('metadata'); + const changelogService = new ChangelogService(); + const data = await changelogService.getChangelogIndex(); + + const ld = ldModule.generate({ + description: t('changelog.description', { appName: BRANDING_NAME }), + title: t('changelog.title', { appName: BRANDING_NAME }), + url: '/changelog', + }); + + return ( + <> + + + {data.map((item) => ( + + + + + + } + > + + + + ))} + + + + + > + ); +}; + +export default Page; diff --git a/src/app/(main)/chat/(workspace)/page.tsx b/src/app/(main)/chat/(workspace)/page.tsx index e06168ad40db..e175858a55e5 100644 --- a/src/app/(main)/chat/(workspace)/page.tsx +++ b/src/app/(main)/chat/(workspace)/page.tsx @@ -1,7 +1,10 @@ import StructuredData from '@/components/StructuredData'; +import { serverFeatureFlags } from '@/config/featureFlags'; import { BRANDING_NAME } from '@/const/branding'; +import ChangelogModal from '@/features/ChangelogModal'; import { ldModule } from '@/server/ld'; import { metadataModule } from '@/server/metadata'; +import { ChangelogService } from '@/server/services/changelog'; import { translation } from '@/server/translation'; import { isMobileDevice } from '@/utils/server/responsive'; @@ -11,17 +14,18 @@ import TelemetryNotification from './features/TelemetryNotification'; export const generateMetadata = async () => { const { t } = await translation('metadata'); return metadataModule.generate({ - description: t('chat.title', { appName: BRANDING_NAME }), + description: t('chat.description', { appName: BRANDING_NAME }), title: t('chat.title', { appName: BRANDING_NAME }), url: '/chat', }); }; const Page = async () => { + const hideDocs = serverFeatureFlags().hideDocs; const mobile = await isMobileDevice(); const { t } = await translation('metadata'); const ld = ldModule.generate({ - description: t('chat.title', { appName: BRANDING_NAME }), + description: t('chat.description', { appName: BRANDING_NAME }), title: t('chat.title', { appName: BRANDING_NAME }), url: '/chat', }); @@ -31,6 +35,9 @@ const Page = async () => { + {!hideDocs && !mobile && ( + + )} > ); }; diff --git a/src/app/(main)/settings/about/features/Version.tsx b/src/app/(main)/settings/about/features/Version.tsx index 9a8589967662..ffe220fa5de2 100644 --- a/src/app/(main)/settings/about/features/Version.tsx +++ b/src/app/(main)/settings/about/features/Version.tsx @@ -7,7 +7,7 @@ import { Center, Flexbox } from 'react-layout-kit'; import { ProductLogo } from '@/components/Branding'; import { BRANDING_NAME } from '@/const/branding'; -import { MANUAL_UPGRADE_URL, OFFICIAL_SITE, RELEASES_URL } from '@/const/url'; +import { CHANGELOG_URL, MANUAL_UPGRADE_URL, OFFICIAL_SITE } from '@/const/url'; import { CURRENT_VERSION } from '@/const/version'; import { useNewVersion } from '@/features/User/UserPanel/useNewVersion'; import { useGlobalStore } from '@/store/global'; @@ -62,7 +62,7 @@ const Version = memo<{ mobile?: boolean }>(({ mobile }) => { - + {t('changelog')} {hasNewVersion && ( diff --git a/src/app/@modal/(.)changelog/modal/features/Cover.tsx b/src/app/@modal/(.)changelog/modal/features/Cover.tsx new file mode 100644 index 000000000000..8cb6f3b27824 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/Cover.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { createStyles } from 'antd-style'; +import { PropsWithChildren, memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +const useStyles = createStyles( + ({ css, token }) => css` + position: relative; + overflow: hidden; + background: ${token.colorFillSecondary}; + + &::before { + content: ''; + + position: absolute; + z-index: 1; + inset-block-start: 0; + inset-inline-start: 0; + + width: 100%; + height: 1px; + + background: ${token.colorFillTertiary}; + } + + &::after { + content: ''; + + position: absolute; + z-index: 1; + inset-block-end: 0; + inset-inline-start: 0; + + width: 100%; + height: 1px; + + background: ${token.colorFillTertiary}; + } + `, +); + +const Cover = memo(({ children }) => { + const { styles } = useStyles(); + return {children}; +}); + +export default Cover; diff --git a/src/app/@modal/(.)changelog/modal/features/Hero.tsx b/src/app/@modal/(.)changelog/modal/features/Hero.tsx new file mode 100644 index 000000000000..7d20fdf0bfb4 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/Hero.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { FluentEmoji } from '@lobehub/ui'; +import { createStyles } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flexbox } from 'react-layout-kit'; + +const useStyles = createStyles( + ({ css, token }) => css` + background: linear-gradient(to bottom, ${token.colorFillTertiary}, transparent); + `, +); + +const Hero = memo(() => { + const { theme, styles } = useStyles(); + const { t } = useTranslation('changelog'); + return ( + + + {t('welcomeBack')} + + + {t('addedWhileAway')} + + ); +}); + +export default Hero; diff --git a/src/app/@modal/(.)changelog/modal/features/Pagination.tsx b/src/app/@modal/(.)changelog/modal/features/Pagination.tsx new file mode 100644 index 000000000000..344c8d2a7f17 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/Pagination.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { Icon } from '@lobehub/ui'; +import { createStyles } from 'antd-style'; +import { ChevronRightIcon } from 'lucide-react'; +import Link from 'next/link'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flexbox } from 'react-layout-kit'; +import urlJoin from 'url-join'; + +import { OFFICIAL_SITE } from '@/const/url'; + +const useStyles = createStyles(({ css, token }) => ({ + button: css` + border: 1px solid ${token.colorBorderSecondary}; + border-radius: ${token.borderRadiusLG}px; + + &:hover { + background: ${token.colorFillTertiary}; + } + `, + desc: css` + color: ${token.colorTextSecondary}; + `, + title: css` + font-size: 16px; + font-weight: 500; + `, +})); + +const Pagination = memo(() => { + const { t } = useTranslation('changelog'); + const { styles } = useStyles(); + return ( + + + + + {t('pagination.prev')} + + + {t('pagination.older')} + + + + ); +}); + +export default Pagination; diff --git a/src/app/@modal/(.)changelog/modal/features/Post.tsx b/src/app/@modal/(.)changelog/modal/features/Post.tsx new file mode 100644 index 000000000000..1c15cd3d25c7 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/Post.tsx @@ -0,0 +1,57 @@ +import { Typography } from '@lobehub/ui'; +import Link from 'next/link'; +import { Flexbox } from 'react-layout-kit'; +import urlJoin from 'url-join'; + +import { CustomMDX } from '@/components/mdx'; +import Image from '@/components/mdx/Image'; +import { OFFICIAL_SITE } from '@/const/url'; +import { Locales } from '@/locales/resources'; +import { ChangelogService } from '@/server/services/changelog'; +import { ChangelogIndexItem } from '@/types/changelog'; + +import Cover from './Cover'; +import PublishedTime from './PublishedTime'; +import ReadDetail from './ReadDetail'; +import VersionTag from './VersionTag'; + +const Post = async ({ + id, + versionRange, + locale, +}: ChangelogIndexItem & { branch?: string; locale: Locales; mobile?: boolean }) => { + const changelogService = new ChangelogService(); + const data = await changelogService.getPostById(id, { locale }); + const url = urlJoin(OFFICIAL_SITE, 'changelog', id); + + if (!data) return null; + + return ( + + + + + + + + + + {data.rawTitle || data.title} + + + + + + + + + + + ); +}; + +export default Post; diff --git a/src/app/@modal/(.)changelog/modal/features/PublishedTime.tsx b/src/app/@modal/(.)changelog/modal/features/PublishedTime.tsx new file mode 100644 index 000000000000..76a33664ecc5 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/PublishedTime.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { createStyles } from 'antd-style'; +import dayjs from 'dayjs'; +import 'dayjs/locale/zh.js'; +import { CSSProperties, FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +const useStyles = createStyles(({ css, token }) => { + return { + time: css` + margin-block: calc(var(--lobe-markdown-margin-multiple) * 1em); + + font-size: 14px; + line-height: var(--lobe-markdown-line-height); + color: ${token.colorTextSecondary}; + letter-spacing: 0.02em; + `, + }; +}); + +interface PrivacyUpdatedProps { + className?: string; + date: string; + style?: CSSProperties; + template?: string; +} +const PublishedTime: FC = ({ + date = new Date().toISOString(), + style, + className, + template = 'dddd, MMMM D YYYY', +}) => { + const { i18n } = useTranslation(); + const { styles, cx } = useStyles(); + const time = dayjs(date).locale(i18n.language).format(template); + + return ( + + {time} + + ); +}; + +export default PublishedTime; diff --git a/src/app/@modal/(.)changelog/modal/features/ReadDetail.tsx b/src/app/@modal/(.)changelog/modal/features/ReadDetail.tsx new file mode 100644 index 000000000000..f8c00b89ecc6 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/ReadDetail.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { ActionIcon, Icon } from '@lobehub/ui'; +import { Divider } from 'antd'; +import { createStyles } from 'antd-style'; +import { ChevronRightIcon } from 'lucide-react'; +import Link from 'next/link'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Flexbox } from 'react-layout-kit'; +import urlJoin from 'url-join'; + +import { OFFICIAL_SITE } from '@/const/url'; +import { useShare } from '@/hooks/useShare'; + +const useStyles = createStyles( + ({ css, token }) => css` + position: relative; + + margin-block: 16px 32px; + padding: 16px; + + background: ${token.colorFillTertiary}; + border-radius: ${token.borderRadiusLG}px; + `, +); + +const ReadDetail = memo<{ desc: string; postId: string; title: string }>( + ({ postId, title, desc }) => { + const { t } = useTranslation('changelog'); + const { styles, theme } = useStyles(); + const url = urlJoin(OFFICIAL_SITE, `/changelog/${postId}`); + const { x, telegram, reddit, mastodon, whatsapp } = useShare({ desc, title, url }); + + return ( + + + + + + + + + + + + + + + + + + + + {t('readDetails')} + + + + + ); + }, +); + +export default ReadDetail; diff --git a/src/app/@modal/(.)changelog/modal/features/UpdateChangelogStatus.tsx b/src/app/@modal/(.)changelog/modal/features/UpdateChangelogStatus.tsx new file mode 100644 index 000000000000..8a370d88a8da --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/UpdateChangelogStatus.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { memo, useEffect } from 'react'; + +import { useGlobalStore } from '@/store/global'; + +const UpdateChangelogStatus = memo<{ currentId?: string }>(({ currentId }) => { + const [latestChangelogId, updateSystemStatus] = useGlobalStore((s) => [ + s.status.latestChangelogId, + s.updateSystemStatus, + ]); + + useEffect(() => { + if (!currentId || currentId === latestChangelogId) return; + updateSystemStatus({ latestChangelogId: currentId }); + }, [latestChangelogId, currentId]); + + return null; +}); + +export default UpdateChangelogStatus; diff --git a/src/app/@modal/(.)changelog/modal/features/VersionTag.tsx b/src/app/@modal/(.)changelog/modal/features/VersionTag.tsx new file mode 100644 index 000000000000..1327e3620e60 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/features/VersionTag.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Tag } from '@lobehub/ui'; +import { createStyles } from 'antd-style'; +import { memo } from 'react'; + +const useStyles = createStyles(({ token, css }) => { + return { + tag: css` + margin: 0; + padding-block: 4px; + padding-inline: 12px; + + color: ${token.colorTextSecondary}; + + border-radius: 16px; + `, + }; +}); + +const VersionTag = memo<{ range: string[] }>(({ range }) => { + const { styles } = useStyles(); + + return {range.map((v) => 'v' + v).join(' ~ ')}; +}); + +export default VersionTag; diff --git a/src/app/@modal/(.)changelog/modal/layout.tsx b/src/app/@modal/(.)changelog/modal/layout.tsx new file mode 100644 index 000000000000..92d0c2f47777 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/layout.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { createStyles } from 'antd-style'; +import { PropsWithChildren, memo } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import ModalLayout from '../../_layout/ModalLayout'; +import Hero from './features/Hero'; +import Pagination from './features/Pagination'; + +const useStyles = createStyles( + ({ css, prefixCls, token }) => css` + .${prefixCls}-modal-close { + background: ${token.colorBgElevated} !important; + border: 1px solid ${token.colorBorderSecondary}; + } + `, +); + +const Layout = memo(({ children }) => { + const { styles } = useStyles(); + + return ( + + + + {children} + + + + + + ); +}); + +export default Layout; diff --git a/src/app/@modal/(.)changelog/modal/loading.tsx b/src/app/@modal/(.)changelog/modal/loading.tsx new file mode 100644 index 000000000000..f5a6dcb53b46 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/loading.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from 'antd'; +import { Flexbox } from 'react-layout-kit'; + +export default () => { + return ( + + + + ); +}; diff --git a/src/app/@modal/(.)changelog/modal/page.tsx b/src/app/@modal/(.)changelog/modal/page.tsx new file mode 100644 index 000000000000..57cc070898d4 --- /dev/null +++ b/src/app/@modal/(.)changelog/modal/page.tsx @@ -0,0 +1,37 @@ +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import { serverFeatureFlags } from '@/config/featureFlags'; +import { ChangelogService } from '@/server/services/changelog'; +import { getLocale } from '@/server/translation'; +import { isMobileDevice } from '@/utils/server/responsive'; + +import Post from './features/Post'; +import UpdateChangelogStatus from './features/UpdateChangelogStatus'; +import Loading from './loading'; + +const Page = async () => { + const hideDocs = serverFeatureFlags().hideDocs; + + if (hideDocs) return notFound(); + + const locale = await getLocale(); + const mobile = await isMobileDevice(); + const changelogService = new ChangelogService(); + const data = await changelogService.getChangelogIndex(); + + return ( + <> + {data.map((item) => ( + } key={item.id}> + + + ))} + + > + ); +}; + +Page.displayName = 'ChangelogModal'; + +export default Page; diff --git a/src/app/@modal/(.)settings/modal/layout.tsx b/src/app/@modal/(.)settings/modal/layout.tsx index 1a322595da1e..e79ed5c985e1 100644 --- a/src/app/@modal/(.)settings/modal/layout.tsx +++ b/src/app/@modal/(.)settings/modal/layout.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { useActiveSettingsKey } from '@/hooks/useActiveSettingsKey'; import { SettingsTabs } from '@/store/global/initialState'; +import ModalLayout from '../../_layout/ModalLayout'; import SettingModalLayout from '../../_layout/SettingModalLayout'; const CategoryContent = dynamic( @@ -22,22 +23,24 @@ const Layout = memo(({ children }) => { const { t } = useTranslation('setting'); const activeKey = useActiveSettingsKey(); return ( - - {t(`tab.${activeKey}`)} - {activeKey === SettingsTabs.Sync && {t('tab.experiment')}} - > - } - category={ - <> - - - > - } - > - {children} - + + + {t(`tab.${activeKey}`)} + {activeKey === SettingsTabs.Sync && {t('tab.experiment')}} + > + } + category={ + <> + + + > + } + > + {children} + + ); }); diff --git a/src/app/@modal/_layout/ModalLayout.tsx b/src/app/@modal/_layout/ModalLayout.tsx new file mode 100644 index 000000000000..a66dc9aca939 --- /dev/null +++ b/src/app/@modal/_layout/ModalLayout.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { Modal, type ModalProps } from '@lobehub/ui'; +import { useTheme } from 'antd-style'; +import { useRouter } from 'next/navigation'; +import { memo, useState } from 'react'; + +const ModalLayout = memo( + ({ + children, + height = 'min(80vh,750px)', + width = 'min(80vw, 1024px)', + onCancel, + afterClose, + styles, + ...rest + }) => { + const [open, setOpen] = useState(true); + const router = useRouter(); + const theme = useTheme(); + + return ( + { + afterClose?.(); + router.back(); + }} + enableResponsive={false} + footer={null} + height={height} + onCancel={(e) => { + onCancel?.(e); + setOpen(false); + router.back(); + }} + open={open} + styles={{ + ...styles, + body: { + display: 'flex', + height: height, + overflow: 'hidden', + padding: 0, + position: 'relative', + ...styles?.body, + }, + content: { + border: 'none', + boxShadow: `0 0 0 1px ${theme.colorBorderSecondary}`, + ...styles?.content, + }, + }} + title={false} + width={width} + {...rest} + > + {children} + + ); + }, +); + +export default ModalLayout; diff --git a/src/app/@modal/chat/(.)settings/modal/layout.tsx b/src/app/@modal/chat/(.)settings/modal/layout.tsx index 9f21fa0169d2..a6d577d343aa 100644 --- a/src/app/@modal/chat/(.)settings/modal/layout.tsx +++ b/src/app/@modal/chat/(.)settings/modal/layout.tsx @@ -6,6 +6,7 @@ import dynamic from 'next/dynamic'; import { PropsWithChildren, memo } from 'react'; import { useTranslation } from 'react-i18next'; +import ModalLayout from '@/app/@modal/_layout/ModalLayout'; import StoreUpdater from '@/features/AgentSetting/StoreUpdater'; import { Provider, createStore } from '@/features/AgentSetting/store'; import { useQuery } from '@/hooks/useQuery'; @@ -35,23 +36,25 @@ const Layout = memo(({ children }) => { ]); return ( - } - desc={t('header.sessionDesc')} - title={t('header.session')} - > - - - {children} - - + + } + desc={t('header.sessionDesc')} + title={t('header.session')} + > + + + {children} + + + ); }); diff --git a/src/app/@modal/features/InterceptingContext.tsx b/src/app/@modal/features/InterceptingContext.tsx deleted file mode 100644 index f3abfb32b5ee..000000000000 --- a/src/app/@modal/features/InterceptingContext.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { PropsWithChildren, createContext, memo } from 'react'; - -export const InterceptContext = createContext(false); - -const InterceptingLayout = memo(({ children }) => { - return {children}; -}); - -export default InterceptingLayout; diff --git a/src/app/@modal/layout.tsx b/src/app/@modal/layout.tsx index 9310f26bc09e..a8df955abecc 100644 --- a/src/app/@modal/layout.tsx +++ b/src/app/@modal/layout.tsx @@ -1,71 +1,7 @@ -'use client'; +import { FC, PropsWithChildren } from 'react'; -import { Modal } from '@lobehub/ui'; -import { useTheme } from 'antd-style'; -import { usePathname, useRouter } from 'next/navigation'; -import { PropsWithChildren, memo, useEffect, useState } from 'react'; +const Layout: FC = ({ children }) => { + return {children}; +}; -import InterceptingLayout from './features/InterceptingContext'; - -const InterceptingModal = memo(({ children }) => { - const [open, setOpen] = useState(true); - const router = useRouter(); - const pathname = usePathname(); - const isDiscover = pathname.startsWith('/discover'); - const theme = useTheme(); - - const inModal = - pathname.startsWith('/settings') || - pathname.startsWith('/chat/settings') || - pathname.startsWith('/discover/assistant/') || - pathname.startsWith('/discover/model/') || - pathname.startsWith('/discover/plugin/') || - pathname.startsWith('/discover/provider/'); - - useEffect(() => { - if (!inModal) { - setOpen(false); - } else { - setOpen(true); - } - }, [inModal, router]); - - if (!inModal) return null; - - return ( - - { - if (inModal) { - router.back(); - } else { - router.refresh(); - } - }} - enableResponsive={false} - footer={null} - onCancel={() => { - setOpen(false); - router.back(); - }} - open={open} - styles={{ - body: { - display: 'flex', - maxHeight: isDiscover ? '80vh' : undefined, - minHeight: 750, - overflow: 'hidden', - padding: 0, - }, - content: { border: 'none', boxShadow: `0 0 0 1px ${theme.colorBorderSecondary}` }, - }} - title={false} - width={isDiscover ? 'min(80vw, 1280px)' : 'min(80vw, 1024px)'} - > - {children} - - - ); -}); - -export default InterceptingModal; +export default Layout; diff --git a/src/components/mdx/Image.tsx b/src/components/mdx/Image.tsx new file mode 100644 index 000000000000..0eeffd854811 --- /dev/null +++ b/src/components/mdx/Image.tsx @@ -0,0 +1,50 @@ +'use server'; + +import { Image } from '@lobehub/ui/mdx'; +import Img from 'next/image'; +import { getPlaiceholder } from 'plaiceholder'; +import { FC } from 'react'; + +const DEFAULT_WIDTH = 800; + +const fetchImage = async (url: string) => { + const buffer = await fetch(url, { cache: 'force-cache' }).then(async (res) => + Buffer.from(await res.arrayBuffer()), + ); + const { + base64, + metadata: { height, width }, + } = await getPlaiceholder(buffer, { format: ['webp'] }); + return { + base64, + height: (DEFAULT_WIDTH / width) * height, + }; +}; + +const ImageWrapper: FC<{ alt: string; src: string }> = async ({ alt, src, ...rest }) => { + try { + const { base64, height } = await fetchImage(src); + return ( + + } + src={src} + width={DEFAULT_WIDTH} + /> + ); + } catch { + return ; + } +}; + +export default ImageWrapper; diff --git a/src/components/mdx/index.tsx b/src/components/mdx/index.tsx index 7b1655f6dfac..1e83333315e1 100644 --- a/src/components/mdx/index.tsx +++ b/src/components/mdx/index.tsx @@ -5,6 +5,7 @@ import { FC } from 'react'; import remarkGfm from 'remark-gfm'; import CodeBlock from './CodeBlock'; +import Image from './Image'; import Link from './Link'; export const Typography = ({ @@ -31,6 +32,7 @@ export const CustomMDX: FC = ({ mobile, . const list: any = {}; Object.entries({ ...mdxComponents, + Image: Image, a: Link, pre: CodeBlock, ...rest.components, diff --git a/src/const/url.ts b/src/const/url.ts index ef4f73f01cc2..d508d05df2b1 100644 --- a/src/const/url.ts +++ b/src/const/url.ts @@ -73,3 +73,4 @@ export const mailTo = (email: string) => `mailto:${email}`; export const AES_GCM_URL = 'https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01'; export const BASE_PROVIDER_DOC_URL = 'https://lobehub.com/docs/usage/providers'; export const SITEMAP_BASE_URL = isDev ? '/sitemap.xml/' : 'sitemap'; +export const CHANGELOG_URL = urlJoin(OFFICIAL_SITE, 'changelog/versions'); diff --git a/src/features/ChangelogModal/index.tsx b/src/features/ChangelogModal/index.tsx new file mode 100644 index 000000000000..fac8bf59d85a --- /dev/null +++ b/src/features/ChangelogModal/index.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useTimeout } from 'ahooks'; +import { useRouter } from 'next/navigation'; +import { memo } from 'react'; + +import { useGlobalStore } from '@/store/global'; + +const ChangelogModal = memo<{ currentId?: string }>(({ currentId }) => { + const latestChangelogId = useGlobalStore((s) => s.status.latestChangelogId); + const router = useRouter(); + + useTimeout(() => { + if (latestChangelogId !== currentId) { + router.push('/changelog/modal'); + } + }, 1000); + + return null; +}); + +export default ChangelogModal; diff --git a/src/features/User/UserPanel/useMenu.tsx b/src/features/User/UserPanel/useMenu.tsx index 154c8a0e8edb..d3e0f651c1e7 100644 --- a/src/features/User/UserPanel/useMenu.tsx +++ b/src/features/User/UserPanel/useMenu.tsx @@ -7,6 +7,7 @@ import { Cloudy, Download, Feather, + FileClockIcon, HardDriveDownload, HardDriveUpload, LifeBuoy, @@ -174,15 +175,38 @@ export const useMenu = () => { }, ].filter(Boolean) as ItemType[]); - const helps: MenuProps['items'] = hideDocs - ? [] - : ([ - showCloudPromotion && { - icon: , - key: 'cloud', + const helps: MenuProps['items'] = [ + showCloudPromotion && { + icon: , + key: 'cloud', + label: ( + + {t('userPanel.cloud', { name: LOBE_CHAT_CLOUD })} + + ), + }, + { + icon: , + key: 'changelog', + label: {t('changelog')}, + }, + { + children: [ + { + icon: , + key: 'docs', label: ( - - {t('userPanel.cloud', { name: LOBE_CHAT_CLOUD })} + + {t('userPanel.docs')} + + ), + }, + { + icon: , + key: 'feedback', + label: ( + + {t('userPanel.feedback')} ), }, @@ -196,56 +220,36 @@ export const useMenu = () => { ), }, { - children: [ - { - icon: , - key: 'docs', - label: ( - - {t('userPanel.docs')} - - ), - }, - { - icon: , - key: 'feedback', - label: ( - - {t('userPanel.feedback')} - - ), - }, - { - icon: , - key: 'email', - label: ( - - {t('userPanel.email')} - - ), - }, - ], - icon: , - key: 'help', - label: t('userPanel.help'), - }, - { - type: 'divider', + icon: , + key: 'email', + label: ( + + {t('userPanel.email')} + + ), }, - ].filter(Boolean) as ItemType[]); + ], + icon: , + key: 'help', + label: t('userPanel.help'), + }, + { + type: 'divider', + }, + ].filter(Boolean) as ItemType[]; const mainItems = [ { type: 'divider', }, - ...(isLogin ? settings : []), ...(isLoginWithClerk ? profile : []), + ...(isLogin ? settings : []), /* ↓ cloud slot ↓ */ /* ↑ cloud slot ↑ */ ...(canInstall ? pwa : []), ...data, - ...helps, + ...(!hideDocs ? helps : []), ].filter(Boolean) as MenuProps['items']; const logoutItems: MenuProps['items'] = isLoginWithAuth diff --git a/src/features/User/__tests__/useMenu.test.tsx b/src/features/User/__tests__/useMenu.test.tsx index dbcdfc7844e2..367f119d21a1 100644 --- a/src/features/User/__tests__/useMenu.test.tsx +++ b/src/features/User/__tests__/useMenu.test.tsx @@ -1,13 +1,14 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { useUserStore } from '@/store/user'; import { ServerConfigStoreProvider } from '@/store/serverConfig'; +import { useUserStore } from '@/store/user'; import { useMenu } from '../UserPanel/useMenu'; -const wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => +const wrapper: React.JSXElementConstructor<{ children: React.ReactNode }> = ({ children }) => ( {children} +); // Mock dependencies vi.mock('next/link', () => ({ @@ -81,7 +82,7 @@ describe('useMenu', () => { expect(mainItems?.some((item) => item?.key === 'setting')).toBe(true); expect(mainItems?.some((item) => item?.key === 'import')).toBe(true); expect(mainItems?.some((item) => item?.key === 'export')).toBe(true); - expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true); + expect(mainItems?.some((item) => item?.key === 'changelog')).toBe(true); expect(logoutItems.some((item) => item?.key === 'logout')).toBe(true); }); }); @@ -101,7 +102,7 @@ describe('useMenu', () => { expect(mainItems?.some((item) => item?.key === 'setting')).toBe(true); expect(mainItems?.some((item) => item?.key === 'import')).toBe(true); expect(mainItems?.some((item) => item?.key === 'export')).toBe(true); - expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true); + expect(mainItems?.some((item) => item?.key === 'changelog')).toBe(true); expect(logoutItems.some((item) => item?.key === 'logout')).toBe(true); }); }); @@ -120,7 +121,7 @@ describe('useMenu', () => { expect(mainItems?.some((item) => item?.key === 'setting')).toBe(true); expect(mainItems?.some((item) => item?.key === 'import')).toBe(true); expect(mainItems?.some((item) => item?.key === 'export')).toBe(true); - expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true); + expect(mainItems?.some((item) => item?.key === 'changelog')).toBe(true); expect(logoutItems.some((item) => item?.key === 'logout')).toBe(false); }); }); @@ -139,7 +140,7 @@ describe('useMenu', () => { expect(mainItems?.some((item) => item?.key === 'setting')).toBe(false); expect(mainItems?.some((item) => item?.key === 'import')).toBe(false); expect(mainItems?.some((item) => item?.key === 'export')).toBe(false); - expect(mainItems?.some((item) => item?.key === 'discord')).toBe(true); + expect(mainItems?.some((item) => item?.key === 'changelog')).toBe(true); expect(logoutItems.some((item) => item?.key === 'logout')).toBe(false); }); }); diff --git a/src/hooks/useInterceptingRoutes.ts b/src/hooks/useInterceptingRoutes.ts index 8baf426019b6..253519ac9ffb 100644 --- a/src/hooks/useInterceptingRoutes.ts +++ b/src/hooks/useInterceptingRoutes.ts @@ -1,7 +1,6 @@ -import { useContext, useMemo } from 'react'; +import { useMemo } from 'react'; import urlJoin from 'url-join'; -import { InterceptContext } from '@/app/@modal/features/InterceptingContext'; import { INBOX_SESSION_ID } from '@/const/session'; import { useIsMobile } from '@/hooks/useIsMobile'; import { useQueryRoute } from '@/hooks/useQueryRoute'; @@ -45,7 +44,3 @@ export const useOpenChatSettings = (tab: ChatSettingsTabs = ChatSettingsTabs.Met } }, [openSettings, mobile, activeId, router, tab]); }; - -export const useInterceptingRoutes = () => { - return useContext(InterceptContext); -}; diff --git a/src/hooks/useShare.tsx b/src/hooks/useShare.tsx index d44f5a00a410..d4f011175a02 100644 --- a/src/hooks/useShare.tsx +++ b/src/hooks/useShare.tsx @@ -15,6 +15,7 @@ const stringifyHashtags = (hashtags: string[], joinfix: string = ',', prefix?: s if (prefix) hashtags = hashtags.map((tag) => prefix + camelCase(tag)); return hashtags.filter(Boolean).join(joinfix); }; + export const useShare = ({ url, title, diff --git a/src/locales/default/changelog.ts b/src/locales/default/changelog.ts new file mode 100644 index 000000000000..a76a427554af --- /dev/null +++ b/src/locales/default/changelog.ts @@ -0,0 +1,18 @@ +export default { + actions: { + followOnX: '在 X 上关注我们', + subscribeToUpdates: '订阅更新', + versions: '版本详情', + }, + addedWhileAway: '在您离开期间,我们带来了新的特性。', + allChangelog: '查看所有更新日志', + description: '持续追踪 {{appName}} 的新功能和改进', + pagination: { + older: '查看历史变更', + prev: '上一页', + }, + readDetails: '阅读详情', + title: '更新日志', + versionDetails: '版本详情', + welcomeBack: '欢迎回来!', +}; diff --git a/src/locales/default/common.ts b/src/locales/default/common.ts index e0463b327639..8033e1127465 100644 --- a/src/locales/default/common.ts +++ b/src/locales/default/common.ts @@ -220,6 +220,7 @@ export default { pinOff: '取消置顶', privacy: '隐私政策', regenerate: '重新生成', + releaseNotes: '版本详情', rename: '重命名', reset: '重置', retry: '重试', diff --git a/src/locales/default/index.ts b/src/locales/default/index.ts index 5f39a16425bb..d7e0676315fd 100644 --- a/src/locales/default/index.ts +++ b/src/locales/default/index.ts @@ -1,4 +1,5 @@ import auth from './auth'; +import changelog from './changelog'; import chat from './chat'; import clerk from './clerk'; import common from './common'; @@ -23,6 +24,7 @@ import welcome from './welcome'; const resources = { auth, + changelog, chat, clerk, common, diff --git a/src/locales/default/metadata.ts b/src/locales/default/metadata.ts index bed8d3e6587e..83fe02ed371b 100644 --- a/src/locales/default/metadata.ts +++ b/src/locales/default/metadata.ts @@ -1,4 +1,8 @@ export default { + changelog: { + description: '持续追踪 {{appName}} 的新功能和改进', + title: '更新日志', + }, chat: { description: '{{appName}} 带给你最好的 ChatGPT, Claude , Gemini, OLLaMA WebUI 使用体验', title: '{{appName}}:个人 AI 效能工具,给自己一个更聪明的大脑', diff --git a/src/server/metadata.ts b/src/server/metadata.ts index 4664f9e91007..fae686b0bba5 100644 --- a/src/server/metadata.ts +++ b/src/server/metadata.ts @@ -18,8 +18,10 @@ export class Meta { tags, alternate, locale = DEFAULT_LANG, + canonical, }: { alternate?: boolean; + canonical?: string; description?: string; image?: string; locale?: Locales; @@ -35,9 +37,9 @@ export class Meta { const siteTitle = title.includes(BRANDING_NAME) ? title : title + ` · ${BRANDING_NAME}`; return { alternates: { - canonical: getCanonicalUrl( - alternate ? qs.stringifyUrl({ query: { hl: locale }, url }) : url, - ), + canonical: + canonical || + getCanonicalUrl(alternate ? qs.stringifyUrl({ query: { hl: locale }, url }) : url), languages: alternate ? this.genAlternateLocales(locale, url) : undefined, }, description: formatedDescription, diff --git a/src/server/routers/edge/appStatus.ts b/src/server/routers/edge/appStatus.ts new file mode 100644 index 000000000000..7693789342ad --- /dev/null +++ b/src/server/routers/edge/appStatus.ts @@ -0,0 +1,3 @@ +import { router } from '@/libs/trpc'; + +export const appStatusRouter = router({}); diff --git a/src/server/routers/edge/index.ts b/src/server/routers/edge/index.ts index c76200edb910..da4cea29749c 100644 --- a/src/server/routers/edge/index.ts +++ b/src/server/routers/edge/index.ts @@ -3,10 +3,12 @@ */ import { publicProcedure, router } from '@/libs/trpc'; +import { appStatusRouter } from './appStatus'; import { configRouter } from './config'; import { uploadRouter } from './upload'; export const edgeRouter = router({ + appStatus: appStatusRouter, config: configRouter, healthcheck: publicProcedure.query(() => "i'm live!"), upload: uploadRouter, diff --git a/src/server/services/changelog/index.test.ts b/src/server/services/changelog/index.test.ts new file mode 100644 index 000000000000..2448ffd05441 --- /dev/null +++ b/src/server/services/changelog/index.test.ts @@ -0,0 +1,310 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChangelogIndexItem } from '@/types/changelog'; + +import { ChangelogService } from './index'; + +// Mock external dependencies +vi.mock('dayjs', () => ({ + default: (date: string) => ({ + format: vi.fn().mockReturnValue(date), + }), +})); + +vi.mock('gray-matter', () => ({ + default: vi.fn().mockImplementation((text) => ({ + data: { date: '2023-01-01' }, + content: text, + })), +})); + +vi.mock('markdown-to-txt', () => ({ + markdownToTxt: vi.fn().mockImplementation((text) => text), +})); + +vi.mock('semver', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + rcompare: vi.fn().mockImplementation((a, b) => b.localeCompare(a)), + lt: vi.fn().mockImplementation((a, b) => a < b), + gt: vi.fn().mockImplementation((a, b) => a > b), + parse: vi.fn().mockImplementation((v) => ({ toString: () => v })), + }; +}); + +vi.mock('url-join', () => ({ + default: vi.fn((...args) => args.join('/')), +})); + +// 模拟 process.env +const originalEnv = process.env; + +beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +describe('ChangelogService', () => { + let service: ChangelogService; + + beforeEach(() => { + service = new ChangelogService(); + // Mock fetch globally + global.fetch = vi.fn(); + }); + + describe('getLatestChangelogId', () => { + it('should return the id of the first changelog item', async () => { + const mockIndex = [{ id: 'latest' }, { id: 'older' }]; + vi.spyOn(service, 'getChangelogIndex').mockResolvedValue(mockIndex as ChangelogIndexItem[]); + + const result = await service.getLatestChangelogId(); + expect(result).toBe('latest'); + }); + + it('should return undefined if the index is empty', async () => { + vi.spyOn(service, 'getChangelogIndex').mockResolvedValue([]); + + const result = await service.getLatestChangelogId(); + expect(result).toBeUndefined(); + }); + }); + + describe('getChangelogIndex', () => { + it('should fetch and merge changelog data', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ + cloud: [{ id: 'cloud1', date: '2023-01-01', versionRange: ['1.0.0'] }], + community: [{ id: 'community1', date: '2023-01-02', versionRange: ['1.1.0'] }], + }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + const result = await service.getChangelogIndex(); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('community1'); + expect(result[1].id).toBe('cloud1'); + }); + + it('should handle fetch errors', async () => { + (global.fetch as any).mockRejectedValue(new Error('Fetch failed')); + + const result = await service.getChangelogIndex(); + expect(result).toBe(false); + }); + + it('should return only community items when config type is community', async () => { + service.config.type = 'community'; + const mockResponse = { + json: vi.fn().mockResolvedValue({ + cloud: [{ id: 'cloud1', date: '2023-01-01', versionRange: ['1.0.0'] }], + community: [{ id: 'community1', date: '2023-01-02', versionRange: ['1.1.0'] }], + }), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + const result = await service.getChangelogIndex(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('community1'); + }); + }); + + describe('getIndexItemById', () => { + it('should return the correct item by id', async () => { + const mockIndex = [ + { id: 'item1', date: '2023-01-01', versionRange: ['1.0.0'] }, + { id: 'item2', date: '2023-01-02', versionRange: ['1.1.0'] }, + ]; + vi.spyOn(service, 'getChangelogIndex').mockResolvedValue(mockIndex as ChangelogIndexItem[]); + + const result = await service.getIndexItemById('item2'); + expect(result).toEqual({ id: 'item2', date: '2023-01-02', versionRange: ['1.1.0'] }); + }); + + it('should return undefined for non-existent id', async () => { + vi.spyOn(service, 'getChangelogIndex').mockResolvedValue([]); + + const result = await service.getIndexItemById('nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('getPostById', () => { + it('should fetch and parse post content', async () => { + vi.spyOn(service, 'getIndexItemById').mockResolvedValue({ + id: 'post1', + date: '2023-01-01', + versionRange: ['1.0.0'], + } as ChangelogIndexItem); + + const mockResponse = { + text: vi.fn().mockResolvedValue('# Post Title\nPost content'), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + const result = await service.getPostById('post1'); + expect(result).toMatchObject({ + content: 'Post content', + date: expect.any(String), // 改为期望字符串而不是 Date 对象 + description: 'Post content', + image: undefined, + rawTitle: 'Post Title', + tags: ['changelog'], + title: 'Post Title', + }); + + // 额外检查日期格式 + expect(result.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('should handle fetch errors', async () => { + vi.spyOn(service, 'getIndexItemById').mockResolvedValue({} as ChangelogIndexItem); + (global.fetch as any).mockRejectedValue(new Error('Fetch failed')); + + const result = await service.getPostById('error'); + expect(result).toBe(false); + }); + + it('should use the correct locale for fetching content', async () => { + vi.spyOn(service, 'getIndexItemById').mockResolvedValue({ + id: 'post1', + date: '2023-01-01', + versionRange: ['1.0.0'], + } as ChangelogIndexItem); + + const mockResponse = { + text: vi.fn().mockResolvedValue('# Chinese Title\n中文内容'), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + const result = await service.getPostById('post1', { locale: 'zh-CN' }); + expect(result).toEqual({ + content: '中文内容', + date: '2023-01-01', + description: '中文内容', + image: undefined, + rawTitle: 'Chinese Title', + tags: ['changelog'], + title: 'Chinese Title', + }); + }); + }); + + describe('private methods', () => { + describe('mergeChangelogs', () => { + it('should merge and sort changelogs correctly', () => { + const cloud = [{ id: 'cloud1', date: '2023-01-01', versionRange: ['1.0.0'] }]; + const community = [{ id: 'community1', date: '2023-01-02', versionRange: ['1.1.0'] }]; + + // @ts-ignore - accessing private method for testing + const result = service.mergeChangelogs(cloud, community); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('community1'); + expect(result[1].id).toBe('cloud1'); + }); + + it('should override community items with cloud items when ids match', () => { + const cloud = [{ id: 'item1', date: '2023-01-01', versionRange: ['1.0.0'], type: 'cloud' }]; + const community = [ + { id: 'item1', date: '2023-01-01', versionRange: ['1.0.0'], type: 'community' }, + ]; + + // @ts-ignore - accessing private method for testing + const result = service.mergeChangelogs(cloud, community); + expect(result).toHaveLength(1); + // @ts-ignore + expect(result[0].type).toBe('cloud'); + }); + }); + + describe('formatVersionRange', () => { + it('should format version range correctly', () => { + // @ts-ignore - accessing private method for testing + const result = service.formatVersionRange(['1.0.0', '1.1.0']); + expect(result).toEqual(['1.1.0', '1.0.0']); + }); + + it('should return single version as is', () => { + // @ts-ignore - accessing private method for testing + const result = service.formatVersionRange(['1.0.0']); + expect(result).toEqual(['1.0.0']); + }); + }); + + describe('genUrl', () => { + it('should generate correct URL', () => { + // @ts-ignore - accessing private method for testing + const result = service.genUrl('test/path'); + expect(result).toBe('https://raw.githubusercontent.com/lobehub/lobe-chat/main/test/path'); + }); + }); + + describe('extractHttpsLinks', () => { + it('should extract HTTPS links from text', () => { + const text = 'Text with https://example.com and https://test.com/image.jpg links'; + // @ts-ignore - accessing private method for testing + const result = service.extractHttpsLinks(text); + expect(result).toEqual(['https://example.com', 'https://test.com/image.jpg']); + }); + }); + + describe('cdnInit', () => { + it('should initialize CDN URLs if docCdnPrefix is set', async () => { + // 设置环境变量 + process.env.DOC_S3_PUBLIC_DOMAIN = 'https://cdn.example.com'; + + // 重新导入模块以确保环境变量生效 + const { ChangelogService } = await import('./index'); + const service = new ChangelogService(); + + const mockData = { 'https://example.com/image.jpg': 'image-hash.jpg' }; + const mockResponse = { + json: vi.fn().mockResolvedValue(mockData), + }; + global.fetch = vi.fn().mockResolvedValue(mockResponse); + + // @ts-ignore - accessing private method for testing + await service.cdnInit(); + + expect(service.cdnUrls).toEqual(mockData); + }); + }); + + describe('replaceCdnUrl', () => { + it('should replace URL with CDN URL if available', async () => { + // 设置环境变量 + process.env.DOC_S3_PUBLIC_DOMAIN = 'https://cdn.example.com'; + + // 重新导入模块以确保环境变量生效 + const { ChangelogService } = await import('./index'); + const service = new ChangelogService(); + + service.cdnUrls = { 'https://example.com/image.jpg': 'image-hash.jpg' }; + + // @ts-ignore - accessing private method for testing + const result = service.replaceCdnUrl('https://example.com/image.jpg'); + + expect(result).toBe('https://cdn.example.com/image-hash.jpg'); + }); + + it('should return original URL if CDN URL is not available', () => { + const originalDocCdnPrefix = process.env.DOC_S3_PUBLIC_DOMAIN; + process.env.DOC_S3_PUBLIC_DOMAIN = 'https://cdn.example.com'; + service.cdnUrls = {}; + + // @ts-ignore - accessing private method for testing + const result = service.replaceCdnUrl('https://example.com/image.jpg'); + expect(result).toBe('https://example.com/image.jpg'); + + // Restore original value + process.env.DOC_S3_PUBLIC_DOMAIN = originalDocCdnPrefix; + }); + }); + }); +}); diff --git a/src/server/services/changelog/index.ts b/src/server/services/changelog/index.ts new file mode 100644 index 000000000000..45e1e2014cad --- /dev/null +++ b/src/server/services/changelog/index.ts @@ -0,0 +1,196 @@ +import dayjs from 'dayjs'; +import matter from 'gray-matter'; +import { markdownToTxt } from 'markdown-to-txt'; +import semver from 'semver'; +import urlJoin from 'url-join'; + +import { Locales } from '@/locales/resources'; +import { ChangelogIndexItem } from '@/types/changelog'; + +const BASE_URL = 'https://raw.githubusercontent.com'; +const LAST_MODIFIED = new Date().toISOString(); + +const docCdnPrefix = process.env.DOC_S3_PUBLIC_DOMAIN || ''; + +export interface ChangelogConfig { + branch: string; + cdnPath: string; + changelogPath: string; + docsPath: string; + majorVersion: number; + repo: string; + type: 'cloud' | 'community'; + user: string; +} + +export class ChangelogService { + cdnUrls: { + [key: string]: string; + } = {}; + config: ChangelogConfig = { + branch: process.env.DOCS_BRANCH || 'main', + cdnPath: 'docs/.cdn.cache.json', + changelogPath: 'changelog', + docsPath: 'docs/changelog', + majorVersion: 1, + repo: 'lobe-chat', + type: 'cloud', + user: 'lobehub', + }; + + async getLatestChangelogId() { + const index = await this.getChangelogIndex(); + return index[0]?.id; + } + + async getChangelogIndex(): Promise { + try { + const url = this.genUrl(urlJoin(this.config.docsPath, 'index.json')); + + const res = await fetch(url); + + const data = await res.json(); + + return this.mergeChangelogs(data.cloud, data.community).slice(0, 5); + } catch (e) { + console.error('Error getting changelog lists:', e); + return false as any; + } + } + + async getIndexItemById(id: string) { + const index = await this.getChangelogIndex(); + return index.find((item) => item.id === id); + } + + async getPostById(id: string, options?: { locale?: Locales }) { + await this.cdnInit(); + try { + const post = await this.getIndexItemById(id); + + const filename = options?.locale === 'en-US' ? `${id}.mdx` : `${id}.zh-CN.mdx`; + const url = this.genUrl(urlJoin(this.config.docsPath, filename)); + + const response = await fetch(url); + const text = await response.text(); + const { data, content } = matter(text); + + const regex = /^#\s(.+)/; + const match = regex.exec(content.trim()); + const matches = content.trim().split(regex); + + let description: string; + + if (matches[2]) { + description = matches[2] ? matches[2].trim() : ''; + } else { + description = matches[1] ? matches[1].trim() : ''; + } + + if (docCdnPrefix) { + const images = this.extractHttpsLinks(content); + for (const url of images) { + const cdnUrl = this.replaceCdnUrl(url); + if (cdnUrl && url !== cdnUrl) { + description = description.replaceAll(url, cdnUrl); + } + } + } + + return { + date: post?.date + ? new Date(post.date) + : data?.date + ? new Date(data.date) + : new Date(LAST_MODIFIED), + description: markdownToTxt(description.replaceAll('\n', '').replaceAll(' ', ' ')).slice( + 0, + 160, + ), + image: post?.image ? this.replaceCdnUrl(post.image) : undefined, + tags: ['changelog'], + title: match ? match[1] : '', + ...data, + content: description, + rawTitle: match ? match[1] : '', + }; + } catch { + console.error('Error getting changlog post by id', id); + return false as any; + } + } + + private mergeChangelogs( + cloud: ChangelogIndexItem[], + community: ChangelogIndexItem[], + ): ChangelogIndexItem[] { + if (this.config.type === 'community') { + return community; + } + + const merged = [...community]; + + for (const cloudItem of cloud) { + const index = merged.findIndex((item) => item.id === cloudItem.id); + if (index !== -1) { + merged[index] = cloudItem; + } else { + merged.push(cloudItem); + } + } + + return merged + .map((item) => ({ + ...item, + date: dayjs(item.date).format('YYYY-MM-DD'), + versionRange: this.formatVersionRange(item.versionRange), + })) + .sort((a, b) => semver.rcompare(a.versionRange[0], b.versionRange[0])); + } + + private formatVersionRange(range: string[]): string[] { + if (range.length === 1) { + return range; + } + + const [v1, v2]: any = range.map((v) => semver.parse(v)?.toString()); + + const minVersion = semver.lt(v1, v2) ? v1 : v2; + const maxVersion = semver.gt(v1, v2) ? v1 : v2; + + return [maxVersion, minVersion]; + } + + private genUrl(path: string) { + return urlJoin(BASE_URL, this.config.user, this.config.repo, this.config.branch, path); + } + + private extractHttpsLinks(text: string) { + const regex = /https:\/\/[^\s"')>]+/g; + const links = text.match(regex); + return links || []; + } + + private async cdnInit() { + if (!docCdnPrefix) return; + if (Object.keys(this.cdnUrls).length === 0) { + try { + const url = this.genUrl(this.config.cdnPath); + const res = await fetch(url); + const data = await res.json(); + if (data) { + this.cdnUrls = data; + } + } catch (error) { + console.error('Error getting changelog cdn cache:', error); + } + } + } + + private replaceCdnUrl(url: string) { + if (!docCdnPrefix || !this.cdnUrls?.[url]) { + return url; + } + return urlJoin(docCdnPrefix, this.cdnUrls[url]); + } +} diff --git a/src/server/services/discover/index.test.ts b/src/server/services/discover/index.test.ts index 9a123ed778eb..e1715aa0ca30 100644 --- a/src/server/services/discover/index.test.ts +++ b/src/server/services/discover/index.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_LANG } from '@/const/locale'; import { AssistantCategory, PluginCategory } from '@/types/discover'; import { DiscoverService } from './index'; diff --git a/src/server/sitemap.ts b/src/server/sitemap.ts index 0bed4f8055bd..93ed628e30e1 100644 --- a/src/server/sitemap.ts +++ b/src/server/sitemap.ts @@ -3,6 +3,7 @@ import { MetadataRoute } from 'next'; import qs from 'query-string'; import urlJoin from 'url-join'; +import { serverFeatureFlags } from '@/config/featureFlags'; import { DEFAULT_LANG } from '@/const/locale'; import { SITEMAP_BASE_URL } from '@/const/url'; import { Locales, locales as allLocales } from '@/locales/resources'; @@ -195,12 +196,14 @@ export class Sitemap { } async getPage(): Promise { + const hideDocs = serverFeatureFlags().hideDocs; const assistantsCategory = Object.values(AssistantCategory); const pluginCategory = Object.values(PluginCategory); const modelCategory = await this.discoverService.getProviderList(DEFAULT_LANG); return [ ...this._genSitemap('/', { noLocales: true }), ...this._genSitemap('/chat', { noLocales: true }), + ...(!hideDocs ? this._genSitemap('/changelog', { noLocales: true }) : []), /* ↓ cloud slot ↓ */ /* ↑ cloud slot ↑ */ @@ -227,7 +230,7 @@ export class Sitemap { }), ), ...this._genSitemap('/discover/providers', { changeFrequency: 'daily', priority: 0.7 }), - ]; + ].filter(Boolean); } getRobots() { return [ diff --git a/src/services/__tests__/global.test.ts b/src/services/__tests__/global.test.ts index 423d6851d12b..bee3606b2d4e 100644 --- a/src/services/__tests__/global.test.ts +++ b/src/services/__tests__/global.test.ts @@ -18,6 +18,9 @@ vi.mock('@/libs/trpc/client', () => { getGlobalConfig: { query: vi.fn() }, getDefaultAgentConfig: { query: vi.fn() }, }, + appStatus: { + getLatestChangelogId: { query: vi.fn() }, + }, }, }; }); @@ -28,14 +31,14 @@ describe('GlobalService', () => { // Arrange const mockVersion = '1.0.0'; (fetch as Mock).mockResolvedValue({ - json: () => Promise.resolve({ 'dist-tags': { latest: mockVersion } }), + json: () => Promise.resolve({ version: mockVersion }), }); // Act const version = await globalService.getLatestVersion(); // Assert - expect(fetch).toHaveBeenCalledWith('https://registry.npmmirror.com/@lobehub/chat'); + expect(fetch).toHaveBeenCalledWith('https://registry.npmmirror.com/@lobehub/chat/latest'); expect(version).toBe(mockVersion); }); diff --git a/src/services/global.ts b/src/services/global.ts index df0fae2472e2..5c72a9bdb374 100644 --- a/src/services/global.ts +++ b/src/services/global.ts @@ -4,7 +4,7 @@ import { edgeClient } from '@/libs/trpc/client'; import { LobeAgentConfig } from '@/types/agent'; import { GlobalServerConfig } from '@/types/serverConfig'; -const VERSION_URL = 'https://registry.npmmirror.com/@lobehub/chat'; +const VERSION_URL = 'https://registry.npmmirror.com/@lobehub/chat/latest'; class GlobalService { /** @@ -14,7 +14,7 @@ class GlobalService { const res = await fetch(VERSION_URL); const data = await res.json(); - return data['dist-tags']?.latest; + return data['version']; }; getGlobalConfig = async (): Promise => { diff --git a/src/store/global/action.ts b/src/store/global/action.ts index f53895833384..721cf54f5d2e 100644 --- a/src/store/global/action.ts +++ b/src/store/global/action.ts @@ -1,7 +1,7 @@ import isEqual from 'fast-deep-equal'; import { produce } from 'immer'; import { gt, parse, valid } from 'semver'; -import useSWR, { SWRResponse } from 'swr'; +import { SWRResponse } from 'swr'; import type { StateCreator } from 'zustand/vanilla'; import { INBOX_SESSION_ID } from '@/const/session'; @@ -42,6 +42,7 @@ export const globalActionSlice: StateCreator< switchBackToChat: (sessionId) => { get().router?.push(SESSION_CHAT_URL(sessionId || INBOX_SESSION_ID, get().isMobile)); }, + toggleChatSideBar: (newValue) => { const showChatSideBar = typeof newValue === 'boolean' ? newValue : !get().status.showChatSideBar; @@ -97,28 +98,32 @@ export const globalActionSlice: StateCreator< }, useCheckLatestVersion: (enabledCheck = true) => - useSWR(enabledCheck ? 'checkLatestVersion' : null, globalService.getLatestVersion, { - // check latest version every 30 minutes - focusThrottleInterval: 1000 * 60 * 30, - onSuccess: (data: string) => { - if (!valid(CURRENT_VERSION) || !valid(data)) return; - - // Parse versions to ensure we're working with valid SemVer objects - const currentVersion = parse(CURRENT_VERSION); - const latestVersion = parse(data); - - if (!currentVersion || !latestVersion) return; - - // only compare major and minor versions - // solve the problem of frequent patch updates - const currentMajorMinor = `${currentVersion.major}.${currentVersion.minor}.0`; - const latestMajorMinor = `${latestVersion.major}.${latestVersion.minor}.0`; - - if (gt(latestMajorMinor, currentMajorMinor)) { - set({ hasNewVersion: true, latestVersion: data }, false, n('checkLatestVersion')); - } + useOnlyFetchOnceSWR( + enabledCheck ? 'checkLatestVersion' : null, + async () => globalService.getLatestVersion(), + { + // check latest version every 30 minutes + focusThrottleInterval: 1000 * 60 * 30, + onSuccess: (data: string) => { + if (!valid(CURRENT_VERSION) || !valid(data)) return; + + // Parse versions to ensure we're working with valid SemVer objects + const currentVersion = parse(CURRENT_VERSION); + const latestVersion = parse(data); + + if (!currentVersion || !latestVersion) return; + + // only compare major and minor versions + // solve the problem of frequent patch updates + const currentMajorMinor = `${currentVersion.major}.${currentVersion.minor}.0`; + const latestMajorMinor = `${latestVersion.major}.${latestVersion.minor}.0`; + + if (gt(latestMajorMinor, currentMajorMinor)) { + set({ hasNewVersion: true, latestVersion: data }, false, n('checkLatestVersion')); + } + }, }, - }), + ), useInitSystemStatus: () => useOnlyFetchOnceSWR( diff --git a/src/store/global/initialState.ts b/src/store/global/initialState.ts index 681f69950b1c..ad1a81c1e02b 100644 --- a/src/store/global/initialState.ts +++ b/src/store/global/initialState.ts @@ -42,6 +42,7 @@ export interface SystemStatus { * 应用初始化时不启用 PGLite,只有当用户手动开启时才启用 */ isEnablePglite?: boolean; + latestChangelogId?: string; mobileShowPortal?: boolean; mobileShowTopic?: boolean; sessionsWidth: number; diff --git a/src/types/changelog.ts b/src/types/changelog.ts new file mode 100644 index 000000000000..37f54c28714a --- /dev/null +++ b/src/types/changelog.ts @@ -0,0 +1,6 @@ +export interface ChangelogIndexItem { + date: string; + id: string; + image?: string; + versionRange: string[]; +}