@lobehub/chat 1.98.2 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (456) hide show
  1. package/.cursor/rules/backend-architecture.mdc +93 -17
  2. package/.cursor/rules/cursor-ux.mdc +45 -35
  3. package/.cursor/rules/project-introduce.mdc +72 -6
  4. package/.cursor/rules/rules-attach.mdc +16 -7
  5. package/.eslintrc.js +10 -0
  6. package/CHANGELOG.md +27 -0
  7. package/apps/desktop/README.md +7 -0
  8. package/apps/desktop/electron-builder.js +5 -0
  9. package/apps/desktop/package.json +2 -1
  10. package/apps/desktop/src/main/const/dir.ts +3 -0
  11. package/apps/desktop/src/main/controllers/UploadFileCtr.ts +13 -8
  12. package/apps/desktop/src/main/core/App.ts +8 -0
  13. package/apps/desktop/src/main/core/StaticFileServerManager.ts +221 -0
  14. package/apps/desktop/src/main/services/fileSrv.ts +231 -44
  15. package/apps/desktop/src/main/utils/next-electron-rsc.ts +36 -5
  16. package/changelog/v1.json +9 -0
  17. package/docs/development/database-schema.dbml +70 -0
  18. package/locales/ar/common.json +2 -0
  19. package/locales/ar/components.json +35 -0
  20. package/locales/ar/error.json +2 -0
  21. package/locales/ar/image.json +100 -0
  22. package/locales/ar/metadata.json +4 -0
  23. package/locales/ar/modelProvider.json +1 -0
  24. package/locales/ar/models.json +15 -0
  25. package/locales/ar/plugin.json +22 -0
  26. package/locales/ar/providers.json +3 -0
  27. package/locales/ar/setting.json +5 -0
  28. package/locales/bg-BG/common.json +2 -0
  29. package/locales/bg-BG/components.json +35 -0
  30. package/locales/bg-BG/error.json +2 -0
  31. package/locales/bg-BG/image.json +100 -0
  32. package/locales/bg-BG/metadata.json +4 -0
  33. package/locales/bg-BG/modelProvider.json +1 -0
  34. package/locales/bg-BG/models.json +15 -0
  35. package/locales/bg-BG/plugin.json +22 -0
  36. package/locales/bg-BG/providers.json +3 -0
  37. package/locales/bg-BG/setting.json +5 -0
  38. package/locales/de-DE/common.json +2 -0
  39. package/locales/de-DE/components.json +35 -0
  40. package/locales/de-DE/error.json +2 -0
  41. package/locales/de-DE/image.json +100 -0
  42. package/locales/de-DE/metadata.json +4 -0
  43. package/locales/de-DE/modelProvider.json +1 -0
  44. package/locales/de-DE/models.json +15 -0
  45. package/locales/de-DE/plugin.json +22 -0
  46. package/locales/de-DE/providers.json +3 -0
  47. package/locales/de-DE/setting.json +5 -0
  48. package/locales/en-US/common.json +2 -0
  49. package/locales/en-US/components.json +35 -0
  50. package/locales/en-US/error.json +2 -0
  51. package/locales/en-US/image.json +100 -0
  52. package/locales/en-US/metadata.json +4 -0
  53. package/locales/en-US/modelProvider.json +1 -0
  54. package/locales/en-US/models.json +15 -0
  55. package/locales/en-US/plugin.json +22 -0
  56. package/locales/en-US/providers.json +3 -0
  57. package/locales/en-US/setting.json +5 -0
  58. package/locales/es-ES/common.json +2 -0
  59. package/locales/es-ES/components.json +35 -0
  60. package/locales/es-ES/error.json +2 -0
  61. package/locales/es-ES/image.json +100 -0
  62. package/locales/es-ES/metadata.json +4 -0
  63. package/locales/es-ES/modelProvider.json +1 -0
  64. package/locales/es-ES/models.json +15 -0
  65. package/locales/es-ES/plugin.json +22 -0
  66. package/locales/es-ES/providers.json +3 -0
  67. package/locales/es-ES/setting.json +5 -0
  68. package/locales/fa-IR/common.json +2 -0
  69. package/locales/fa-IR/components.json +35 -0
  70. package/locales/fa-IR/error.json +2 -0
  71. package/locales/fa-IR/image.json +100 -0
  72. package/locales/fa-IR/metadata.json +4 -0
  73. package/locales/fa-IR/modelProvider.json +1 -0
  74. package/locales/fa-IR/models.json +15 -0
  75. package/locales/fa-IR/plugin.json +22 -0
  76. package/locales/fa-IR/providers.json +3 -0
  77. package/locales/fa-IR/setting.json +5 -0
  78. package/locales/fr-FR/common.json +2 -0
  79. package/locales/fr-FR/components.json +35 -0
  80. package/locales/fr-FR/error.json +2 -0
  81. package/locales/fr-FR/image.json +100 -0
  82. package/locales/fr-FR/metadata.json +4 -0
  83. package/locales/fr-FR/modelProvider.json +1 -0
  84. package/locales/fr-FR/models.json +15 -0
  85. package/locales/fr-FR/plugin.json +22 -0
  86. package/locales/fr-FR/providers.json +3 -0
  87. package/locales/fr-FR/setting.json +5 -0
  88. package/locales/it-IT/common.json +2 -0
  89. package/locales/it-IT/components.json +35 -0
  90. package/locales/it-IT/error.json +2 -0
  91. package/locales/it-IT/image.json +100 -0
  92. package/locales/it-IT/metadata.json +4 -0
  93. package/locales/it-IT/modelProvider.json +1 -0
  94. package/locales/it-IT/models.json +15 -0
  95. package/locales/it-IT/plugin.json +22 -0
  96. package/locales/it-IT/providers.json +3 -0
  97. package/locales/it-IT/setting.json +5 -0
  98. package/locales/ja-JP/common.json +2 -0
  99. package/locales/ja-JP/components.json +35 -0
  100. package/locales/ja-JP/error.json +2 -0
  101. package/locales/ja-JP/image.json +100 -0
  102. package/locales/ja-JP/metadata.json +4 -0
  103. package/locales/ja-JP/modelProvider.json +1 -0
  104. package/locales/ja-JP/models.json +15 -0
  105. package/locales/ja-JP/plugin.json +22 -0
  106. package/locales/ja-JP/providers.json +3 -0
  107. package/locales/ja-JP/setting.json +5 -0
  108. package/locales/ko-KR/common.json +2 -0
  109. package/locales/ko-KR/components.json +35 -0
  110. package/locales/ko-KR/error.json +2 -0
  111. package/locales/ko-KR/image.json +100 -0
  112. package/locales/ko-KR/metadata.json +4 -0
  113. package/locales/ko-KR/modelProvider.json +1 -0
  114. package/locales/ko-KR/models.json +15 -0
  115. package/locales/ko-KR/plugin.json +22 -0
  116. package/locales/ko-KR/providers.json +3 -0
  117. package/locales/ko-KR/setting.json +5 -0
  118. package/locales/nl-NL/common.json +2 -0
  119. package/locales/nl-NL/components.json +35 -0
  120. package/locales/nl-NL/error.json +2 -0
  121. package/locales/nl-NL/image.json +100 -0
  122. package/locales/nl-NL/metadata.json +4 -0
  123. package/locales/nl-NL/modelProvider.json +1 -0
  124. package/locales/nl-NL/models.json +15 -0
  125. package/locales/nl-NL/plugin.json +22 -0
  126. package/locales/nl-NL/providers.json +3 -0
  127. package/locales/nl-NL/setting.json +5 -0
  128. package/locales/pl-PL/common.json +2 -0
  129. package/locales/pl-PL/components.json +35 -0
  130. package/locales/pl-PL/error.json +2 -0
  131. package/locales/pl-PL/image.json +100 -0
  132. package/locales/pl-PL/metadata.json +4 -0
  133. package/locales/pl-PL/modelProvider.json +1 -0
  134. package/locales/pl-PL/models.json +15 -0
  135. package/locales/pl-PL/plugin.json +22 -0
  136. package/locales/pl-PL/providers.json +3 -0
  137. package/locales/pl-PL/setting.json +5 -0
  138. package/locales/pt-BR/common.json +2 -0
  139. package/locales/pt-BR/components.json +35 -0
  140. package/locales/pt-BR/error.json +2 -0
  141. package/locales/pt-BR/image.json +100 -0
  142. package/locales/pt-BR/metadata.json +4 -0
  143. package/locales/pt-BR/modelProvider.json +1 -0
  144. package/locales/pt-BR/models.json +15 -0
  145. package/locales/pt-BR/plugin.json +22 -0
  146. package/locales/pt-BR/providers.json +3 -0
  147. package/locales/pt-BR/setting.json +5 -0
  148. package/locales/ru-RU/common.json +2 -0
  149. package/locales/ru-RU/components.json +35 -0
  150. package/locales/ru-RU/error.json +2 -0
  151. package/locales/ru-RU/image.json +100 -0
  152. package/locales/ru-RU/metadata.json +4 -0
  153. package/locales/ru-RU/modelProvider.json +1 -0
  154. package/locales/ru-RU/models.json +15 -0
  155. package/locales/ru-RU/plugin.json +22 -0
  156. package/locales/ru-RU/providers.json +3 -0
  157. package/locales/ru-RU/setting.json +5 -0
  158. package/locales/tr-TR/common.json +2 -0
  159. package/locales/tr-TR/components.json +35 -0
  160. package/locales/tr-TR/error.json +2 -0
  161. package/locales/tr-TR/image.json +100 -0
  162. package/locales/tr-TR/metadata.json +4 -0
  163. package/locales/tr-TR/modelProvider.json +1 -0
  164. package/locales/tr-TR/models.json +15 -0
  165. package/locales/tr-TR/plugin.json +22 -0
  166. package/locales/tr-TR/providers.json +3 -0
  167. package/locales/tr-TR/setting.json +5 -0
  168. package/locales/vi-VN/common.json +2 -0
  169. package/locales/vi-VN/components.json +35 -0
  170. package/locales/vi-VN/error.json +2 -0
  171. package/locales/vi-VN/image.json +100 -0
  172. package/locales/vi-VN/metadata.json +4 -0
  173. package/locales/vi-VN/modelProvider.json +1 -0
  174. package/locales/vi-VN/models.json +15 -0
  175. package/locales/vi-VN/plugin.json +22 -0
  176. package/locales/vi-VN/providers.json +3 -0
  177. package/locales/vi-VN/setting.json +5 -0
  178. package/locales/zh-CN/common.json +2 -0
  179. package/locales/zh-CN/components.json +35 -0
  180. package/locales/zh-CN/error.json +2 -0
  181. package/locales/zh-CN/image.json +100 -0
  182. package/locales/zh-CN/metadata.json +4 -0
  183. package/locales/zh-CN/modelProvider.json +1 -0
  184. package/locales/zh-CN/models.json +15 -0
  185. package/locales/zh-CN/plugin.json +22 -0
  186. package/locales/zh-CN/providers.json +3 -0
  187. package/locales/zh-CN/setting.json +5 -0
  188. package/locales/zh-TW/common.json +2 -0
  189. package/locales/zh-TW/components.json +35 -0
  190. package/locales/zh-TW/error.json +2 -0
  191. package/locales/zh-TW/image.json +100 -0
  192. package/locales/zh-TW/metadata.json +4 -0
  193. package/locales/zh-TW/modelProvider.json +1 -0
  194. package/locales/zh-TW/models.json +15 -0
  195. package/locales/zh-TW/plugin.json +22 -0
  196. package/locales/zh-TW/providers.json +3 -0
  197. package/locales/zh-TW/setting.json +5 -0
  198. package/package.json +11 -4
  199. package/packages/electron-server-ipc/src/events/file.ts +3 -1
  200. package/packages/electron-server-ipc/src/types/file.ts +15 -0
  201. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/TopActions.tsx +11 -1
  202. package/src/app/[variants]/(main)/image/@menu/components/AspectRatioSelect/index.tsx +73 -0
  203. package/src/app/[variants]/(main)/image/@menu/components/SeedNumberInput/index.tsx +39 -0
  204. package/src/app/[variants]/(main)/image/@menu/components/SizeSelect/index.tsx +89 -0
  205. package/src/app/[variants]/(main)/image/@menu/default.tsx +11 -0
  206. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/AspectRatioSelect.tsx +24 -0
  207. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/DimensionControlGroup.tsx +107 -0
  208. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageNum.tsx +290 -0
  209. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUpload.tsx +504 -0
  210. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrl.tsx +18 -0
  211. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx +19 -0
  212. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ModelSelect.tsx +155 -0
  213. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/ImageManageModal.tsx +415 -0
  214. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/index.tsx +732 -0
  215. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/SeedNumberInput.tsx +24 -0
  216. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/SizeSelect.tsx +17 -0
  217. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/SizeSliderInput.tsx +15 -0
  218. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/StepsSliderInput.tsx +11 -0
  219. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/constants.ts +1 -0
  220. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +93 -0
  221. package/src/app/[variants]/(main)/image/@topic/default.tsx +17 -0
  222. package/src/app/[variants]/(main)/image/@topic/features/Topics/NewTopicButton.tsx +64 -0
  223. package/src/app/[variants]/(main)/image/@topic/features/Topics/SkeletonList.tsx +34 -0
  224. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItem.tsx +136 -0
  225. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItemContainer.tsx +91 -0
  226. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicList.tsx +57 -0
  227. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicUrlSync.tsx +37 -0
  228. package/src/app/[variants]/(main)/image/@topic/features/Topics/index.tsx +19 -0
  229. package/src/app/[variants]/(main)/image/NotSupportClient.tsx +153 -0
  230. package/src/app/[variants]/(main)/image/_layout/Desktop/Container.tsx +35 -0
  231. package/src/app/[variants]/(main)/image/_layout/Desktop/RegisterHotkeys.tsx +10 -0
  232. package/src/app/[variants]/(main)/image/_layout/Desktop/index.tsx +30 -0
  233. package/src/app/[variants]/(main)/image/_layout/Mobile/index.tsx +14 -0
  234. package/src/app/[variants]/(main)/image/_layout/type.ts +7 -0
  235. package/src/app/[variants]/(main)/image/features/GenerationFeed/BatchItem.tsx +196 -0
  236. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ActionButtons.tsx +60 -0
  237. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ElapsedTime.tsx +90 -0
  238. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx +65 -0
  239. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/LoadingState.tsx +43 -0
  240. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/SuccessState.tsx +49 -0
  241. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/index.tsx +156 -0
  242. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/styles.ts +51 -0
  243. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/types.ts +39 -0
  244. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +11 -0
  245. package/src/app/[variants]/(main)/image/features/GenerationFeed/index.tsx +97 -0
  246. package/src/app/[variants]/(main)/image/features/ImageWorkspace/Content.tsx +48 -0
  247. package/src/app/[variants]/(main)/image/features/ImageWorkspace/EmptyState.tsx +37 -0
  248. package/src/app/[variants]/(main)/image/features/ImageWorkspace/SkeletonList.tsx +50 -0
  249. package/src/app/[variants]/(main)/image/features/ImageWorkspace/index.tsx +23 -0
  250. package/src/app/[variants]/(main)/image/features/PromptInput/Title.tsx +38 -0
  251. package/src/app/[variants]/(main)/image/features/PromptInput/index.tsx +114 -0
  252. package/src/app/[variants]/(main)/image/layout.tsx +19 -0
  253. package/src/app/[variants]/(main)/image/loading.tsx +3 -0
  254. package/src/app/[variants]/(main)/image/page.tsx +47 -0
  255. package/src/app/[variants]/(main)/settings/system-agent/index.tsx +2 -1
  256. package/src/chains/summaryGenerationTitle.ts +25 -0
  257. package/src/components/ImageItem/index.tsx +9 -6
  258. package/src/{features/Conversation/Error → components/InvalidAPIKey}/APIKeyForm/Bedrock.tsx +3 -4
  259. package/src/{features/Conversation/Error → components/InvalidAPIKey}/APIKeyForm/ProviderApiKeyForm.tsx +5 -4
  260. package/src/components/InvalidAPIKey/APIKeyForm/index.tsx +108 -0
  261. package/src/{features/Conversation/Error → components/InvalidAPIKey}/APIKeyForm/useApiKey.ts +2 -1
  262. package/src/components/InvalidAPIKey/index.tsx +30 -0
  263. package/src/components/KeyValueEditor/index.tsx +203 -0
  264. package/src/components/KeyValueEditor/utils.ts +42 -0
  265. package/src/config/aiModels/fal.ts +52 -0
  266. package/src/config/aiModels/index.ts +3 -0
  267. package/src/config/aiModels/openai.ts +20 -6
  268. package/src/config/llm.ts +6 -0
  269. package/src/config/modelProviders/fal.ts +21 -0
  270. package/src/config/modelProviders/index.ts +3 -0
  271. package/src/config/paramsSchemas/fal/flux-kontext-dev.ts +8 -0
  272. package/src/config/paramsSchemas/fal/flux-pro-kontext.ts +11 -0
  273. package/src/config/paramsSchemas/fal/flux-schnell.ts +9 -0
  274. package/src/config/paramsSchemas/fal/imagen4.ts +10 -0
  275. package/src/config/paramsSchemas/openai/gpt-image-1.ts +10 -0
  276. package/src/const/hotkeys.ts +2 -2
  277. package/src/const/image.ts +6 -0
  278. package/src/const/settings/systemAgent.ts +1 -0
  279. package/src/database/client/migrations.json +27 -0
  280. package/src/database/migrations/0026_add_autovacuum_tuning.sql +2 -0
  281. package/src/database/migrations/0027_ai_image.sql +47 -0
  282. package/src/database/migrations/meta/0027_snapshot.json +6003 -0
  283. package/src/database/migrations/meta/_journal.json +7 -0
  284. package/src/database/models/__tests__/asyncTask.test.ts +7 -5
  285. package/src/database/models/__tests__/file.test.ts +287 -0
  286. package/src/database/models/__tests__/generation.test.ts +786 -0
  287. package/src/database/models/__tests__/generationBatch.test.ts +614 -0
  288. package/src/database/models/__tests__/generationTopic.test.ts +411 -0
  289. package/src/database/models/aiModel.ts +2 -0
  290. package/src/database/models/asyncTask.ts +1 -1
  291. package/src/database/models/file.ts +28 -20
  292. package/src/database/models/generation.ts +197 -0
  293. package/src/database/models/generationBatch.ts +212 -0
  294. package/src/database/models/generationTopic.ts +131 -0
  295. package/src/database/repositories/aiInfra/index.test.ts +151 -1
  296. package/src/database/repositories/aiInfra/index.ts +28 -19
  297. package/src/database/repositories/tableViewer/index.test.ts +1 -1
  298. package/src/database/schemas/file.ts +8 -0
  299. package/src/database/schemas/generation.ts +127 -0
  300. package/src/database/schemas/index.ts +1 -0
  301. package/src/database/schemas/relations.ts +45 -1
  302. package/src/database/type.ts +2 -0
  303. package/src/database/utils/idGenerator.ts +3 -0
  304. package/src/features/Conversation/Error/ChatInvalidApiKey.tsx +39 -0
  305. package/src/features/Conversation/Error/InvalidAccessCode.tsx +2 -2
  306. package/src/features/Conversation/Error/index.tsx +3 -3
  307. package/src/features/ImageSidePanel/index.tsx +83 -0
  308. package/src/features/ImageTopicPanel/index.tsx +79 -0
  309. package/src/features/PluginDevModal/MCPManifestForm/CollapsibleSection.tsx +62 -0
  310. package/src/features/PluginDevModal/MCPManifestForm/QuickImportSection.tsx +158 -0
  311. package/src/features/PluginDevModal/MCPManifestForm/index.tsx +99 -155
  312. package/src/features/PluginStore/McpList/Detail/Settings/index.tsx +5 -2
  313. package/src/hooks/useDownloadImage.ts +31 -0
  314. package/src/hooks/useFetchGenerationTopics.ts +13 -0
  315. package/src/hooks/useHotkeys/imageScope.ts +48 -0
  316. package/src/libs/mcp/client.ts +55 -22
  317. package/src/libs/mcp/types.ts +42 -6
  318. package/src/libs/model-runtime/BaseAI.ts +3 -1
  319. package/src/libs/model-runtime/ModelRuntime.test.ts +80 -0
  320. package/src/libs/model-runtime/ModelRuntime.ts +15 -1
  321. package/src/libs/model-runtime/UniformRuntime/index.ts +4 -1
  322. package/src/libs/model-runtime/fal/index.test.ts +442 -0
  323. package/src/libs/model-runtime/fal/index.ts +88 -0
  324. package/src/libs/model-runtime/openai/index.test.ts +396 -2
  325. package/src/libs/model-runtime/openai/index.ts +129 -3
  326. package/src/libs/model-runtime/runtimeMap.ts +2 -0
  327. package/src/libs/model-runtime/types/image.ts +25 -0
  328. package/src/libs/model-runtime/types/type.ts +1 -0
  329. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +10 -0
  330. package/src/libs/standard-parameters/index.ts +1 -0
  331. package/src/libs/standard-parameters/meta-schema.test.ts +214 -0
  332. package/src/libs/standard-parameters/meta-schema.ts +147 -0
  333. package/src/libs/swr/index.ts +1 -0
  334. package/src/libs/trpc/async/asyncAuth.ts +29 -8
  335. package/src/libs/trpc/async/context.ts +42 -4
  336. package/src/libs/trpc/async/index.ts +17 -4
  337. package/src/libs/trpc/async/init.ts +8 -0
  338. package/src/libs/trpc/client/lambda.ts +19 -2
  339. package/src/locales/default/common.ts +2 -0
  340. package/src/locales/default/components.ts +35 -0
  341. package/src/locales/default/error.ts +2 -0
  342. package/src/locales/default/image.ts +100 -0
  343. package/src/locales/default/index.ts +2 -0
  344. package/src/locales/default/metadata.ts +4 -0
  345. package/src/locales/default/modelProvider.ts +2 -0
  346. package/src/locales/default/plugin.ts +22 -0
  347. package/src/locales/default/setting.ts +5 -0
  348. package/src/middleware.ts +1 -0
  349. package/src/server/modules/ElectronIPCClient/index.ts +9 -1
  350. package/src/server/modules/S3/index.ts +15 -0
  351. package/src/server/routers/async/caller.ts +9 -1
  352. package/src/server/routers/async/image.ts +253 -0
  353. package/src/server/routers/async/index.ts +2 -0
  354. package/src/server/routers/lambda/aiProvider.test.ts +1 -0
  355. package/src/server/routers/lambda/generation.test.ts +267 -0
  356. package/src/server/routers/lambda/generation.ts +86 -0
  357. package/src/server/routers/lambda/generationBatch.test.ts +376 -0
  358. package/src/server/routers/lambda/generationBatch.ts +56 -0
  359. package/src/server/routers/lambda/generationTopic.test.ts +508 -0
  360. package/src/server/routers/lambda/generationTopic.ts +93 -0
  361. package/src/server/routers/lambda/image.ts +248 -0
  362. package/src/server/routers/lambda/index.ts +8 -0
  363. package/src/server/routers/tools/mcp.ts +15 -0
  364. package/src/server/services/file/__tests__/index.test.ts +135 -0
  365. package/src/server/services/file/impls/local.test.ts +153 -52
  366. package/src/server/services/file/impls/local.ts +70 -46
  367. package/src/server/services/file/impls/s3.test.ts +114 -0
  368. package/src/server/services/file/impls/s3.ts +40 -0
  369. package/src/server/services/file/impls/type.ts +10 -0
  370. package/src/server/services/file/index.ts +14 -0
  371. package/src/server/services/generation/index.ts +239 -0
  372. package/src/server/services/mcp/index.ts +20 -2
  373. package/src/services/__tests__/generation.test.ts +40 -0
  374. package/src/services/__tests__/generationBatch.test.ts +36 -0
  375. package/src/services/__tests__/generationTopic.test.ts +72 -0
  376. package/src/services/electron/file.ts +3 -1
  377. package/src/services/generation.ts +16 -0
  378. package/src/services/generationBatch.ts +25 -0
  379. package/src/services/generationTopic.ts +28 -0
  380. package/src/services/image.ts +33 -0
  381. package/src/services/mcp.ts +12 -7
  382. package/src/services/upload.ts +43 -9
  383. package/src/store/aiInfra/slices/aiProvider/action.ts +25 -5
  384. package/src/store/aiInfra/slices/aiProvider/initialState.ts +1 -0
  385. package/src/store/aiInfra/slices/aiProvider/selectors.ts +3 -0
  386. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +5 -5
  387. package/src/store/chat/slices/message/action.ts +2 -2
  388. package/src/store/chat/slices/translate/action.ts +1 -1
  389. package/src/store/global/initialState.ts +9 -0
  390. package/src/store/global/selectors/systemStatus.ts +8 -0
  391. package/src/store/image/index.ts +2 -0
  392. package/src/store/image/initialState.ts +25 -0
  393. package/src/store/image/selectors.ts +4 -0
  394. package/src/store/image/slices/createImage/action.test.ts +330 -0
  395. package/src/store/image/slices/createImage/action.ts +134 -0
  396. package/src/store/image/slices/createImage/initialState.ts +9 -0
  397. package/src/store/image/slices/createImage/selectors.test.ts +114 -0
  398. package/src/store/image/slices/createImage/selectors.ts +9 -0
  399. package/src/store/image/slices/generationBatch/action.test.ts +495 -0
  400. package/src/store/image/slices/generationBatch/action.ts +303 -0
  401. package/src/store/image/slices/generationBatch/initialState.ts +13 -0
  402. package/src/store/image/slices/generationBatch/reducer.test.ts +568 -0
  403. package/src/store/image/slices/generationBatch/reducer.ts +101 -0
  404. package/src/store/image/slices/generationBatch/selectors.test.ts +307 -0
  405. package/src/store/image/slices/generationBatch/selectors.ts +36 -0
  406. package/src/store/image/slices/generationConfig/action.test.ts +351 -0
  407. package/src/store/image/slices/generationConfig/action.ts +295 -0
  408. package/src/store/image/slices/generationConfig/hooks.test.ts +304 -0
  409. package/src/store/image/slices/generationConfig/hooks.ts +118 -0
  410. package/src/store/image/slices/generationConfig/index.ts +1 -0
  411. package/src/store/image/slices/generationConfig/initialState.ts +37 -0
  412. package/src/store/image/slices/generationConfig/selectors.test.ts +204 -0
  413. package/src/store/image/slices/generationConfig/selectors.ts +25 -0
  414. package/src/store/image/slices/generationTopic/action.test.ts +687 -0
  415. package/src/store/image/slices/generationTopic/action.ts +319 -0
  416. package/src/store/image/slices/generationTopic/index.ts +2 -0
  417. package/src/store/image/slices/generationTopic/initialState.ts +14 -0
  418. package/src/store/image/slices/generationTopic/reducer.test.ts +198 -0
  419. package/src/store/image/slices/generationTopic/reducer.ts +66 -0
  420. package/src/store/image/slices/generationTopic/selectors.test.ts +103 -0
  421. package/src/store/image/slices/generationTopic/selectors.ts +15 -0
  422. package/src/store/image/store.ts +42 -0
  423. package/src/store/image/utils/size.ts +51 -0
  424. package/src/store/tool/slices/customPlugin/action.ts +10 -1
  425. package/src/store/tool/slices/mcpStore/action.ts +6 -4
  426. package/src/store/user/slices/settings/selectors/__snapshots__/settings.test.ts.snap +4 -0
  427. package/src/store/user/slices/settings/selectors/systemAgent.ts +2 -0
  428. package/src/types/aiModel.ts +8 -3
  429. package/src/types/aiProvider.ts +1 -0
  430. package/src/types/asyncTask.ts +2 -0
  431. package/src/types/files/index.ts +5 -0
  432. package/src/types/generation/index.ts +80 -0
  433. package/src/types/hotkey.ts +2 -0
  434. package/src/types/plugins/mcp.ts +2 -6
  435. package/src/types/tool/plugin.ts +8 -0
  436. package/src/types/user/settings/keyVaults.ts +5 -0
  437. package/src/types/user/settings/systemAgent.ts +1 -0
  438. package/src/utils/client/downloadFile.ts +33 -4
  439. package/src/utils/number.test.ts +105 -0
  440. package/src/utils/number.ts +25 -0
  441. package/src/utils/server/__tests__/geo.test.ts +6 -3
  442. package/src/utils/storeDebug.test.ts +152 -0
  443. package/src/utils/storeDebug.ts +16 -7
  444. package/src/utils/time.test.ts +259 -0
  445. package/src/utils/time.ts +18 -0
  446. package/src/utils/units.ts +61 -0
  447. package/src/utils/url.test.ts +358 -9
  448. package/src/utils/url.ts +105 -3
  449. package/{vitest.server.config.ts → vitest.config.server.ts} +3 -0
  450. package/.cursor/rules/i18n/i18n-auto-attached.mdc +0 -6
  451. package/src/features/Conversation/Error/APIKeyForm/index.tsx +0 -105
  452. package/src/features/Conversation/Error/InvalidAPIKey.tsx +0 -16
  453. package/src/features/PluginDevModal/MCPManifestForm/EnvEditor.tsx +0 -227
  454. /package/.cursor/rules/{i18n/i18n.mdc → i18n.mdc} +0 -0
  455. /package/src/app/[variants]/(main)/settings/system-agent/features/{createForm.tsx → SystemAgentForm.tsx} +0 -0
  456. /package/src/{features/Conversation/Error → components/InvalidAPIKey}/APIKeyForm/LoadingContext.ts +0 -0
@@ -17,6 +17,7 @@ vi.mock('@/server/modules/ElectronIPCClient', () => ({
17
17
  electronIpcClient: {
18
18
  getFilePathById: vi.fn(),
19
19
  deleteFiles: vi.fn(),
20
+ createFile: vi.fn(),
20
21
  },
21
22
  }));
22
23
 
@@ -40,58 +41,7 @@ describe('DesktopLocalFileImpl', () => {
40
41
  });
41
42
 
42
43
  describe('getLocalFileUrl', () => {
43
- it.skip('应该正确获取本地文件URL并转换为data URL', async () => {
44
- // 准备: readFileSync在第一次被调用时返回文件内容
45
- vi.mocked(readFileSync).mockReturnValueOnce(testFileBuffer);
46
-
47
- // 使用私有方法进行测试,通过原型访问
48
- const result = await (service as any).getLocalFileUrl(testFileKey);
49
-
50
- // 验证
51
- expect(electronIpcClient.getFilePathById).toHaveBeenCalledWith(testFileKey);
52
- expect(existsSync).toHaveBeenCalledWith(testFilePath);
53
- expect(readFileSync).toHaveBeenCalledWith(testFilePath);
54
-
55
- // 验证返回的data URL格式正确
56
- expect(result).toContain('data:text/plain;base64,');
57
- expect(result).toContain(testFileBuffer.toString('base64'));
58
- });
59
-
60
- it('当文件不存在时应返回原始键', async () => {
61
- // 准备: 文件不存在
62
- vi.mocked(existsSync).mockReturnValueOnce(false);
63
-
64
- // 使用私有方法进行测试
65
- const result = await (service as any).getLocalFileUrl(testFileKey);
66
-
67
- // 验证
68
- expect(result).toBe(testFileKey);
69
- });
70
-
71
- it('当发生错误时应返回空字符串', async () => {
72
- // 准备: 模拟错误
73
- vi.mocked(electronIpcClient.getFilePathById).mockRejectedValueOnce(new Error('测试错误'));
74
-
75
- // 使用私有方法进行测试
76
- const result = await (service as any).getLocalFileUrl(testFileKey);
77
-
78
- // 验证
79
- expect(result).toBe('');
80
- });
81
- });
82
-
83
- describe('getMimeTypeFromPath', () => {
84
- it('应该返回正确的MIME类型', () => {
85
- // 使用私有方法进行测试
86
- const jpgResult = (service as any).getMimeTypeFromPath('test.jpg');
87
- const pngResult = (service as any).getMimeTypeFromPath('test.png');
88
- const unknownResult = (service as any).getMimeTypeFromPath('test.unknown');
89
-
90
- // 验证
91
- expect(jpgResult).toBe('image/jpeg');
92
- expect(pngResult).toBe('image/png');
93
- expect(unknownResult).toBe('application/octet-stream');
94
- });
44
+ it.skip('应该返回 localhost 的 http url', async () => {});
95
45
  });
96
46
 
97
47
  describe('createPreSignedUrl', () => {
@@ -293,6 +243,157 @@ describe('DesktopLocalFileImpl', () => {
293
243
 
294
244
  await service.uploadContent('path/to/file', 'content');
295
245
 
246
+ expect(consoleSpy).toHaveBeenCalledWith(
247
+ 'uploadContent not implemented for Desktop local file service',
248
+ 'path/to/file',
249
+ 'content',
250
+ );
251
+ });
252
+ });
253
+
254
+ describe('getKeyFromFullUrl', () => {
255
+ it('应该从HTTP URL中正确提取desktop://路径', () => {
256
+ // 准备
257
+ const url = 'http://localhost:3000/desktop-file/documents/test.txt';
258
+
259
+ // 执行
260
+ const result = service.getKeyFromFullUrl(url);
261
+
262
+ // 验证
263
+ expect(result).toBe('desktop://documents/test.txt');
264
+ });
265
+
266
+ it('应该处理复杂的文件路径', () => {
267
+ // 准备
268
+ const url = 'http://localhost:3000/desktop-file/folder/subfolder/image.png';
269
+
270
+ // 执行
271
+ const result = service.getKeyFromFullUrl(url);
272
+
273
+ // 验证
274
+ expect(result).toBe('desktop://folder/subfolder/image.png');
275
+ });
276
+
277
+ it('应该处理根目录下的文件', () => {
278
+ // 准备
279
+ const url = 'http://localhost:3000/desktop-file/test.pdf';
280
+
281
+ // 执行
282
+ const result = service.getKeyFromFullUrl(url);
283
+
284
+ // 验证
285
+ expect(result).toBe('desktop://test.pdf');
286
+ });
287
+
288
+ it('当URL格式不正确时应返回空字符串', () => {
289
+ // 准备
290
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
291
+ const invalidUrl = 'not-a-valid-url';
292
+
293
+ // 执行
294
+ const result = service.getKeyFromFullUrl(invalidUrl);
295
+
296
+ // 验证
297
+ expect(result).toBe('');
298
+ expect(consoleSpy).toHaveBeenCalled();
299
+ });
300
+ });
301
+
302
+ describe('uploadMedia', () => {
303
+ it('应该成功上传媒体文件', async () => {
304
+ // 准备
305
+ const testKey = 'desktop://images/test.jpg';
306
+ const testBuffer = Buffer.from('fake image data');
307
+ const mockResult = {
308
+ success: true,
309
+ metadata: {
310
+ path: testKey,
311
+ filename: 'test.jpg',
312
+ dirname: 'desktop://images',
313
+ date: new Date().toISOString(),
314
+ },
315
+ };
316
+
317
+ vi.mocked(electronIpcClient.createFile).mockResolvedValueOnce(mockResult);
318
+
319
+ // 执行
320
+ const result = await service.uploadMedia(testKey, testBuffer);
321
+
322
+ // 验证
323
+ expect(electronIpcClient.createFile).toHaveBeenCalledWith({
324
+ content: testBuffer.toString('base64'),
325
+ filename: 'test.jpg',
326
+ hash: expect.any(String), // SHA256 hash
327
+ path: testKey,
328
+ type: 'image/jpeg',
329
+ });
330
+ expect(result).toEqual({ key: testKey });
331
+ });
332
+
333
+ it('应该正确处理PNG文件', async () => {
334
+ // 准备
335
+ const testKey = 'desktop://images/test.png';
336
+ const testBuffer = Buffer.from('fake png data');
337
+ const mockResult = {
338
+ success: true,
339
+ metadata: {
340
+ path: testKey,
341
+ filename: 'test.png',
342
+ dirname: 'desktop://images',
343
+ date: new Date().toISOString(),
344
+ },
345
+ };
346
+
347
+ vi.mocked(electronIpcClient.createFile).mockResolvedValueOnce(mockResult);
348
+
349
+ // 执行
350
+ const result = await service.uploadMedia(testKey, testBuffer);
351
+
352
+ // 验证
353
+ expect(electronIpcClient.createFile).toHaveBeenCalledWith(
354
+ expect.objectContaining({
355
+ filename: 'test.png',
356
+ type: 'image/png',
357
+ }),
358
+ );
359
+ expect(result).toEqual({ key: testKey });
360
+ });
361
+
362
+ it('当上传失败时应抛出错误', async () => {
363
+ // 准备
364
+ const testKey = 'desktop://images/test.jpg';
365
+ const testBuffer = Buffer.from('fake image data');
366
+ const mockResult = {
367
+ success: false,
368
+ metadata: {
369
+ path: testKey,
370
+ filename: 'test.jpg',
371
+ dirname: 'desktop://images',
372
+ date: new Date().toISOString(),
373
+ },
374
+ error: 'Upload failed',
375
+ };
376
+
377
+ vi.mocked(electronIpcClient.createFile).mockResolvedValueOnce(mockResult);
378
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
379
+
380
+ // 执行和验证
381
+ await expect(service.uploadMedia(testKey, testBuffer)).rejects.toThrow(
382
+ 'Failed to upload file via Electron IPC',
383
+ );
384
+ expect(consoleSpy).toHaveBeenCalled();
385
+ });
386
+
387
+ it('当IPC调用失败时应抛出错误', async () => {
388
+ // 准备
389
+ const testKey = 'desktop://images/test.jpg';
390
+ const testBuffer = Buffer.from('fake image data');
391
+
392
+ vi.mocked(electronIpcClient.createFile).mockRejectedValueOnce(new Error('IPC error'));
393
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
394
+
395
+ // 执行和验证
396
+ await expect(service.uploadMedia(testKey, testBuffer)).rejects.toThrow('IPC error');
296
397
  expect(consoleSpy).toHaveBeenCalled();
297
398
  });
298
399
  });
@@ -1,7 +1,9 @@
1
+ import { sha256 } from 'js-sha256';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
4
5
  import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
6
+ import { inferContentTypeFromImageUrl } from '@/utils/url';
5
7
 
6
8
  import { FileServiceImpl } from './type';
7
9
 
@@ -11,59 +13,17 @@ import { FileServiceImpl } from './type';
11
13
  export class DesktopLocalFileImpl implements FileServiceImpl {
12
14
  /**
13
15
  * 获取本地文件的URL
14
- * Electron返回文件的绝对路径,然后在服务端将文件转为base64
16
+ * 通过 IPC 从主进程获取 HTTP URL
15
17
  */
16
18
  private async getLocalFileUrl(key: string): Promise<string> {
17
19
  try {
18
- // 从Electron获取文件的绝对路径
19
- const filePath = await electronIpcClient.getFilePathById(key);
20
-
21
- // 检查文件是否存在
22
- if (!existsSync(filePath)) {
23
- console.error(`File not found: ${filePath}`);
24
- return key;
25
- }
26
-
27
- // 读取文件内容
28
- const fileContent = readFileSync(filePath);
29
-
30
- // 确定文件的MIME类型
31
- const mimeType = this.getMimeTypeFromPath(filePath);
32
-
33
- // 转换为base64并返回data URL
34
- const base64 = fileContent.toString('base64');
35
- return `data:${mimeType};base64,${base64}`;
20
+ return await electronIpcClient.getFileHTTPURL(key);
36
21
  } catch (e) {
37
- console.error('[DesktopLocalFileImpl] Failed to process file from Electron IPC:', e);
22
+ console.error('[DesktopLocalFileImpl] Failed to get file HTTP URL via IPC:', e);
38
23
  return '';
39
24
  }
40
25
  }
41
26
 
42
- /**
43
- * 根据文件路径获取MIME类型
44
- */
45
- private getMimeTypeFromPath(filePath: string): string {
46
- const extension = path.extname(filePath).toLowerCase();
47
-
48
- // 常见文件类型的MIME映射
49
- const mimeTypes: Record<string, string> = {
50
- '.css': 'text/css',
51
- '.gif': 'image/gif',
52
- '.html': 'text/html',
53
- '.jpeg': 'image/jpeg',
54
- '.jpg': 'image/jpeg',
55
- '.js': 'application/javascript',
56
- '.json': 'application/json',
57
- '.pdf': 'application/pdf',
58
- '.png': 'image/png',
59
- '.svg': 'image/svg+xml',
60
- '.txt': 'text/plain',
61
- '.webp': 'image/webp',
62
- };
63
-
64
- return mimeTypes[extension] || 'application/octet-stream';
65
- }
66
-
67
27
  /**
68
28
  * 创建预签名上传URL(本地版实际上是直接返回文件路径,可能需要进一步扩展)
69
29
  */
@@ -74,7 +34,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
74
34
  }
75
35
 
76
36
  /**
77
- * 创建预签名预览URL(本地版是通过Electron获取本地文件URL)
37
+ * 创建预签名预览URL(本地版是通过HTTP路径访问本地文件)
78
38
  */
79
39
  async createPreSignedUrlForPreview(key: string): Promise<string> {
80
40
  return this.getLocalFileUrl(key);
@@ -180,4 +140,68 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
180
140
  console.warn('uploadContent not implemented for Desktop local file service', filePath, content);
181
141
  return;
182
142
  }
143
+
144
+ /**
145
+ * 从完整URL中提取key
146
+ * 从 HTTP URL 中提取 desktop:// 格式的路径
147
+ */
148
+ getKeyFromFullUrl(url: string): string {
149
+ try {
150
+ const urlObj = new URL(url);
151
+ const pathSegments = urlObj.pathname.split('/').filter((segment) => segment !== '');
152
+
153
+ // 移除第一个路径段(desktop-file)
154
+ pathSegments.shift();
155
+
156
+ // 重新组合剩余的路径段
157
+ const filePath = pathSegments.join('/');
158
+
159
+ // 返回 desktop:// 格式的路径
160
+ return `desktop://${filePath}`;
161
+ } catch (e) {
162
+ console.error('[DesktopLocalFileImpl] Failed to extract key from URL:', e);
163
+ return '';
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 上传媒体文件
169
+ */
170
+ async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
171
+ try {
172
+ // 将 Buffer 转换为 Base64 字符串
173
+ const content = buffer.toString('base64');
174
+
175
+ // 从 key 中提取文件名
176
+ const filename = path.basename(key);
177
+
178
+ // 计算文件的 SHA256 hash
179
+ const hash = sha256(buffer);
180
+
181
+ // 根据文件URL推断 MIME 类型
182
+ const type = inferContentTypeFromImageUrl(key)!;
183
+
184
+ // 构造上传参数
185
+ const uploadParams = {
186
+ content,
187
+ filename,
188
+ hash,
189
+ path: key,
190
+ type,
191
+ };
192
+
193
+ // 调用 electronIpcClient 上传文件
194
+ const result = await electronIpcClient.createFile(uploadParams);
195
+
196
+ if (!result.success) {
197
+ throw new Error('Failed to upload file via Electron IPC');
198
+ }
199
+
200
+ console.log('[DesktopLocalFileImpl] File uploaded successfully:', result.metadata);
201
+ return { key: result.metadata.path };
202
+ } catch (error) {
203
+ console.error('[DesktopLocalFileImpl] Failed to upload media file:', error);
204
+ throw error;
205
+ }
206
+ }
183
207
  }
@@ -28,6 +28,7 @@ vi.mock('@/server/modules/S3', () => ({
28
28
  deleteFiles: vi.fn().mockResolvedValue({}),
29
29
  createPreSignedUrl: vi.fn().mockResolvedValue('https://upload.example.com/test.jpg'),
30
30
  uploadContent: vi.fn().mockResolvedValue({}),
31
+ uploadMedia: vi.fn().mockResolvedValue({}),
31
32
  })),
32
33
  }));
33
34
 
@@ -107,4 +108,117 @@ describe('S3StaticFileImpl', () => {
107
108
  expect(fileService['s3'].uploadContent).toHaveBeenCalledWith('test.jpg', 'content');
108
109
  });
109
110
  });
111
+
112
+ describe('getKeyFromFullUrl', () => {
113
+ it('当S3_ENABLE_PATH_STYLE为false时应该正确提取key', () => {
114
+ config.S3_ENABLE_PATH_STYLE = false;
115
+ const url = 'https://example.com/path/to/file.jpg';
116
+
117
+ const result = fileService.getKeyFromFullUrl(url);
118
+
119
+ expect(result).toBe('path/to/file.jpg');
120
+ config.S3_ENABLE_PATH_STYLE = false; // reset
121
+ });
122
+
123
+ it('当S3_ENABLE_PATH_STYLE为true时应该正确提取key', () => {
124
+ config.S3_ENABLE_PATH_STYLE = true;
125
+ const url = 'https://example.com/my-bucket/path/to/file.jpg';
126
+
127
+ const result = fileService.getKeyFromFullUrl(url);
128
+
129
+ expect(result).toBe('path/to/file.jpg');
130
+ config.S3_ENABLE_PATH_STYLE = false; // reset
131
+ });
132
+
133
+ it('当S3_ENABLE_PATH_STYLE为true但缺少bucket名称时应该返回pathname', () => {
134
+ config.S3_ENABLE_PATH_STYLE = true;
135
+ config.S3_BUCKET = '';
136
+ const url = 'https://example.com/path/to/file.jpg';
137
+
138
+ const result = fileService.getKeyFromFullUrl(url);
139
+
140
+ expect(result).toBe('path/to/file.jpg');
141
+ config.S3_ENABLE_PATH_STYLE = false; // reset
142
+ config.S3_BUCKET = 'my-bucket'; // reset
143
+ });
144
+
145
+ it('当URL格式不正确时应该返回原始字符串', () => {
146
+ const invalidUrl = 'not-a-valid-url';
147
+
148
+ const result = fileService.getKeyFromFullUrl(invalidUrl);
149
+
150
+ expect(result).toBe('not-a-valid-url');
151
+ });
152
+
153
+ it('应该处理根路径文件', () => {
154
+ config.S3_ENABLE_PATH_STYLE = false;
155
+ const url = 'https://example.com/file.jpg';
156
+
157
+ const result = fileService.getKeyFromFullUrl(url);
158
+
159
+ expect(result).toBe('file.jpg');
160
+ });
161
+
162
+ it('当path-style URL路径格式不符合预期时应该使用fallback', () => {
163
+ config.S3_ENABLE_PATH_STYLE = true;
164
+ const url = 'https://example.com/unexpected/path/file.jpg';
165
+
166
+ const result = fileService.getKeyFromFullUrl(url);
167
+
168
+ expect(result).toBe('unexpected/path/file.jpg');
169
+ config.S3_ENABLE_PATH_STYLE = false; // reset
170
+ });
171
+ });
172
+
173
+ describe('uploadMedia', () => {
174
+ beforeEach(() => {
175
+ // 重置 S3 mock
176
+ vi.clearAllMocks();
177
+ });
178
+
179
+ it('应该调用S3的uploadMedia方法并返回key', async () => {
180
+ // 准备
181
+ const testKey = 'images/test.jpg';
182
+ const testBuffer = Buffer.from('fake image data');
183
+
184
+ fileService['s3'].uploadMedia = vi.fn().mockResolvedValue(undefined);
185
+
186
+ // 执行
187
+ const result = await fileService.uploadMedia(testKey, testBuffer);
188
+
189
+ // 验证
190
+ expect(fileService['s3'].uploadMedia).toHaveBeenCalledWith(testKey, testBuffer);
191
+ expect(result).toEqual({ key: testKey });
192
+ });
193
+
194
+ it('应该正确处理不同类型的媒体文件', async () => {
195
+ // 准备
196
+ const testKey = 'videos/test.mp4';
197
+ const testBuffer = Buffer.from('fake video data');
198
+
199
+ fileService['s3'].uploadMedia = vi.fn().mockResolvedValue(undefined);
200
+
201
+ // 执行
202
+ const result = await fileService.uploadMedia(testKey, testBuffer);
203
+
204
+ // 验证
205
+ expect(fileService['s3'].uploadMedia).toHaveBeenCalledWith(testKey, testBuffer);
206
+ expect(result).toEqual({ key: testKey });
207
+ });
208
+
209
+ it('当S3上传失败时应该抛出错误', async () => {
210
+ // 准备
211
+ const testKey = 'images/test.jpg';
212
+ const testBuffer = Buffer.from('fake image data');
213
+ const uploadError = new Error('S3 upload failed');
214
+
215
+ fileService['s3'].uploadMedia = vi.fn().mockRejectedValue(uploadError);
216
+
217
+ // 执行和验证
218
+ await expect(fileService.uploadMedia(testKey, testBuffer)).rejects.toThrow(
219
+ 'S3 upload failed',
220
+ );
221
+ expect(fileService['s3'].uploadMedia).toHaveBeenCalledWith(testKey, testBuffer);
222
+ });
223
+ });
110
224
  });
@@ -57,4 +57,44 @@ export class S3StaticFileImpl implements FileServiceImpl {
57
57
 
58
58
  return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, url);
59
59
  }
60
+
61
+ getKeyFromFullUrl(url: string): string {
62
+ try {
63
+ const urlObject = new URL(url);
64
+ const { pathname } = urlObject;
65
+
66
+ let key: string;
67
+
68
+ if (fileEnv.S3_ENABLE_PATH_STYLE) {
69
+ if (!fileEnv.S3_BUCKET) {
70
+ // In path-style, we need bucket name to extract key
71
+ // but if not provided, we can only guess the key is the pathname
72
+ return pathname.startsWith('/') ? pathname.slice(1) : pathname;
73
+ }
74
+ // For path-style URLs, the path is /<bucket>/<key>
75
+ // We need to remove the leading slash and the bucket name.
76
+ const bucketPrefix = `/${fileEnv.S3_BUCKET}/`;
77
+ if (pathname.startsWith(bucketPrefix)) {
78
+ key = pathname.slice(bucketPrefix.length);
79
+ } else {
80
+ // Fallback for unexpected path format
81
+ key = pathname.startsWith('/') ? pathname.slice(1) : pathname;
82
+ }
83
+ } else {
84
+ // For virtual-hosted-style URLs, the path is /<key>
85
+ // We just need to remove the leading slash.
86
+ key = pathname.slice(1);
87
+ }
88
+
89
+ return key;
90
+ } catch {
91
+ // if url is not a valid URL, it may be a key itself
92
+ return url;
93
+ }
94
+ }
95
+
96
+ async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
97
+ await this.s3.uploadMedia(key, buffer);
98
+ return { key };
99
+ }
60
100
  }
@@ -37,8 +37,18 @@ export interface FileServiceImpl {
37
37
  */
38
38
  getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string>;
39
39
 
40
+ /**
41
+ * 从完整URL中提取key
42
+ */
43
+ getKeyFromFullUrl(url: string): string;
44
+
40
45
  /**
41
46
  * 上传内容
42
47
  */
43
48
  uploadContent(path: string, content: string): Promise<any>;
49
+
50
+ /**
51
+ * 上传媒体文件
52
+ */
53
+ uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }>;
44
54
  }
@@ -80,6 +80,20 @@ export class FileService {
80
80
  return this.impl.getFullFileUrl(url, expiresIn);
81
81
  }
82
82
 
83
+ /**
84
+ * 从完整 URL中 提取 key
85
+ */
86
+ public getKeyFromFullUrl(url: string): string {
87
+ return this.impl.getKeyFromFullUrl(url);
88
+ }
89
+
90
+ /**
91
+ * 上传媒体文件
92
+ */
93
+ public async uploadMedia(key: string, buffer: Buffer): Promise<{ key: string }> {
94
+ return this.impl.uploadMedia(key, buffer);
95
+ }
96
+
83
97
  async downloadFileToLocal(
84
98
  fileId: string,
85
99
  ): Promise<{ cleanup: () => void; file: FileItem; filePath: string }> {