@rexvane/piweb 0.3.21 → 0.3.23

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 (343) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +9 -6
  3. package/.next/build-manifest.json +5 -5
  4. package/.next/next-minimal-server.js.nft.json +1 -1
  5. package/.next/next-server.js.nft.json +1 -1
  6. package/.next/prerender-manifest.json +7 -7
  7. package/.next/react-loadable-manifest.json +18 -11
  8. package/.next/required-server-files.js +16 -1
  9. package/.next/required-server-files.json +16 -1
  10. package/.next/routes-manifest.json +20 -0
  11. package/.next/server/app/_global-error/page.js +2 -2
  12. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  13. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_global-error.html +1 -1
  15. package/.next/server/app/_global-error.meta +8 -0
  16. package/.next/server/app/_global-error.rsc +15 -11
  17. package/.next/server/app/_global-error.segments/_full.segment.rsc +15 -11
  18. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +20 -16
  19. package/.next/server/app/_global-error.segments/_tree.segment.rsc +3 -1
  20. package/.next/server/app/_not-found/page.js +1 -1
  21. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/_not-found.html +1 -1
  24. package/.next/server/app/_not-found.meta +8 -0
  25. package/.next/server/app/_not-found.rsc +17 -13
  26. package/.next/server/app/_not-found.segments/_full.segment.rsc +17 -13
  27. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +24 -20
  28. package/.next/server/app/_not-found.segments/_tree.segment.rsc +4 -2
  29. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  30. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  31. package/.next/server/app/api/agent/[id]/route.js +1 -1
  32. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  33. package/.next/server/app/api/agent/[id]/side-chat/route.js +5 -0
  34. package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +1 -0
  35. package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +1 -0
  36. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  37. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  38. package/.next/server/app/api/agent/new/route.js +1 -1
  39. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  40. package/.next/server/app/api/config/route.js +1 -1
  41. package/.next/server/app/api/config/route.js.nft.json +1 -1
  42. package/.next/server/app/api/files/route.js +1 -1
  43. package/.next/server/app/api/files/route.js.nft.json +1 -1
  44. package/.next/server/app/api/fs/route.js +1 -1
  45. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  46. package/.next/server/app/api/git/route.js +4 -4
  47. package/.next/server/app/api/git/route.js.nft.json +1 -1
  48. package/.next/server/app/api/github/route.js +5 -0
  49. package/.next/server/app/api/github/route.js.nft.json +1 -0
  50. package/.next/server/app/api/github/route_client-reference-manifest.js +1 -0
  51. package/.next/server/app/api/growth/route.js +1 -1
  52. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  53. package/.next/server/app/api/health/route.js +1 -1
  54. package/.next/server/app/api/health/route.js.nft.json +1 -1
  55. package/.next/server/app/api/models/defaults/route.js +1 -0
  56. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -0
  57. package/.next/server/app/api/models/defaults/route_client-reference-manifest.js +1 -0
  58. package/.next/server/app/api/models/route.js +1 -1
  59. package/.next/server/app/api/models/route.js.nft.json +1 -1
  60. package/.next/server/app/api/pi-settings/route.js +1 -1
  61. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  62. package/.next/server/app/api/plugins/route.js +1 -1
  63. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  64. package/.next/server/app/api/prompts/route.js +1 -1
  65. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  66. package/.next/server/app/api/remote/route.js +4 -4
  67. package/.next/server/app/api/remote/route.js.nft.json +1 -1
  68. package/.next/server/app/api/security/route.js +1 -1
  69. package/.next/server/app/api/security/route.js.nft.json +1 -1
  70. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  71. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  72. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  73. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  74. package/.next/server/app/api/sessions/import/route.js +2 -2
  75. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  76. package/.next/server/app/api/sessions/route.js +1 -1
  77. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  78. package/.next/server/app/api/skills/route.js +2 -2
  79. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  80. package/.next/server/app/api/update/route.js +2 -2
  81. package/.next/server/app/api/update/route.js.nft.json +1 -1
  82. package/.next/server/app/api/version/route.js +1 -1
  83. package/.next/server/app/api/version/route.js.nft.json +1 -1
  84. package/.next/server/app/api/web-auth/route.js +2 -2
  85. package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
  86. package/.next/server/app/api/workspaces/route.js +1 -1
  87. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  88. package/.next/server/app/icon.svg/route.js +1 -1
  89. package/.next/server/app/icon.svg/route.js.nft.json +1 -1
  90. package/.next/server/app/icon.svg.meta +1 -1
  91. package/.next/server/app/index.html +1 -1
  92. package/.next/server/app/index.meta +8 -0
  93. package/.next/server/app/index.rsc +20 -13
  94. package/.next/server/app/index.segments/__PAGE__.segment.rsc +26 -19
  95. package/.next/server/app/index.segments/_full.segment.rsc +20 -13
  96. package/.next/server/app/index.segments/_tree.segment.rsc +4 -2
  97. package/.next/server/app/login/page.js +1 -1
  98. package/.next/server/app/login/page.js.nft.json +1 -1
  99. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  100. package/.next/server/app/login.html +1 -1
  101. package/.next/server/app/login.meta +8 -0
  102. package/.next/server/app/login.rsc +23 -17
  103. package/.next/server/app/login.segments/_full.segment.rsc +23 -17
  104. package/.next/server/app/login.segments/_tree.segment.rsc +4 -2
  105. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +30 -24
  106. package/.next/server/app/page.js +1 -1
  107. package/.next/server/app/page.js.nft.json +1 -1
  108. package/.next/server/app/page_client-reference-manifest.js +1 -1
  109. package/.next/server/app-paths-manifest.json +9 -6
  110. package/.next/server/chunks/1813.js +23 -0
  111. package/.next/server/chunks/2430.js +377 -0
  112. package/.next/server/chunks/2970.js +305 -0
  113. package/.next/server/chunks/3421.js +1 -0
  114. package/.next/server/chunks/3924.js +6 -0
  115. package/.next/server/chunks/4807.js +1 -0
  116. package/.next/server/chunks/538.js +1 -1
  117. package/.next/server/chunks/5393.js +1 -0
  118. package/.next/server/chunks/5802.js +2 -0
  119. package/.next/server/chunks/6229.js +1 -0
  120. package/.next/server/chunks/7496.js +1 -0
  121. package/.next/server/chunks/7662.js +2 -0
  122. package/.next/server/chunks/8686.js +1 -0
  123. package/.next/server/chunks/{593.js → 974.js} +1 -1
  124. package/.next/server/chunks/{920.js → 9920.js} +2 -2
  125. package/.next/server/functions-config-manifest.json +2 -1
  126. package/.next/server/instrumentation.js +1 -1
  127. package/.next/server/instrumentation.js.nft.json +1 -1
  128. package/.next/server/middleware-build-manifest.js +1 -1
  129. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  130. package/.next/server/middleware.js +185 -23
  131. package/.next/server/middleware.js.nft.json +1 -1
  132. package/.next/server/pages/404.html +1 -1
  133. package/.next/server/pages/500.html +1 -1
  134. package/.next/server/preview-props.json +5 -0
  135. package/.next/server/server-reference-manifest.json +1 -1
  136. package/.next/server/webpack-runtime.js +1 -1
  137. package/.next/static/chunks/2716.44fdf1b21454b961.js +1 -0
  138. package/.next/static/chunks/316-698d10eb465bd8ef.js +1 -0
  139. package/.next/static/chunks/3794-0978865cda773008.js +52 -0
  140. package/.next/static/chunks/4673.e261707dce391be7.js +1 -0
  141. package/.next/static/chunks/4686-0cd87513f9ea3538.js +1 -0
  142. package/.next/static/chunks/4778.eddd3a5813f85824.js +1 -0
  143. package/.next/static/chunks/4bd1b696-e199e788e58acf79.js +1 -0
  144. package/.next/static/chunks/{5173.91ddb803785009f8.js → 5173.dfd1cbb795b9a21c.js} +1 -1
  145. package/.next/static/chunks/618.949981da99be7182.js +4 -0
  146. package/.next/static/chunks/7411.5f79671208209b13.js +1 -0
  147. package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +1 -0
  148. package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +1 -0
  149. package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +1 -0
  150. package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +1 -0
  151. package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +1 -0
  152. package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +1 -0
  153. package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +1 -0
  154. package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +1 -0
  155. package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +1 -0
  156. package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +1 -0
  157. package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +1 -0
  158. package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +1 -0
  159. package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +1 -0
  160. package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +1 -0
  161. package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +1 -0
  162. package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +1 -0
  163. package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +1 -0
  164. package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +1 -0
  165. package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +1 -0
  166. package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +1 -0
  167. package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +1 -0
  168. package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +1 -0
  169. package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +1 -0
  170. package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +1 -0
  171. package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +1 -0
  172. package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +1 -0
  173. package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +1 -0
  174. package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +1 -0
  175. package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +1 -0
  176. package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +1 -0
  177. package/.next/static/chunks/framework-47b723d27f4a4963.js +1 -0
  178. package/.next/static/chunks/main-5403f0764579e5fe.js +5 -0
  179. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +1 -0
  180. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +1 -0
  181. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +1 -0
  182. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +1 -0
  183. package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +1 -0
  184. package/.next/static/css/9fc559c2c4ad1d0f.css +1 -0
  185. package/.next/static/css/c6e2227c36ec2fd6.css +1 -0
  186. package/.next/static/css/{2959bd6b626b7ffe.css → f15a853af8b5127f.css} +1 -1
  187. package/.next/static/hYPL1-dmhKFjLS3f5yoqN/_buildManifest.js +1 -0
  188. package/.next/types/app/api/agent/[id]/events/route.ts +3 -1
  189. package/.next/types/app/api/agent/[id]/route.ts +3 -1
  190. package/.next/types/app/api/agent/[id]/side-chat/route.ts +355 -0
  191. package/.next/types/app/api/agent/[id]/tree/route.ts +3 -1
  192. package/.next/types/app/api/agent/new/route.ts +3 -1
  193. package/.next/types/app/api/config/route.ts +3 -1
  194. package/.next/types/app/api/files/route.ts +3 -1
  195. package/.next/types/app/api/fs/route.ts +3 -1
  196. package/.next/types/app/api/git/route.ts +3 -1
  197. package/.next/types/app/api/github/route.ts +355 -0
  198. package/.next/types/app/api/growth/route.ts +3 -1
  199. package/.next/types/app/api/health/route.ts +3 -1
  200. package/.next/types/app/api/models/defaults/route.ts +355 -0
  201. package/.next/types/app/api/models/route.ts +3 -1
  202. package/.next/types/app/api/pi-settings/route.ts +3 -1
  203. package/.next/types/app/api/plugins/route.ts +3 -1
  204. package/.next/types/app/api/prompts/route.ts +3 -1
  205. package/.next/types/app/api/remote/route.ts +3 -1
  206. package/.next/types/app/api/security/route.ts +3 -1
  207. package/.next/types/app/api/sessions/[id]/export/route.ts +3 -1
  208. package/.next/types/app/api/sessions/[id]/route.ts +3 -1
  209. package/.next/types/app/api/sessions/import/route.ts +3 -1
  210. package/.next/types/app/api/sessions/route.ts +3 -1
  211. package/.next/types/app/api/skills/route.ts +3 -1
  212. package/.next/types/app/api/update/route.ts +3 -1
  213. package/.next/types/app/api/version/route.ts +3 -1
  214. package/.next/types/app/api/web-auth/route.ts +3 -1
  215. package/.next/types/app/api/workspaces/route.ts +3 -1
  216. package/.next/types/app/layout.ts +4 -1
  217. package/.next/types/app/login/page.ts +4 -1
  218. package/.next/types/app/page.ts +4 -1
  219. package/.next/types/cache-life.d.ts +11 -43
  220. package/.next/types/routes.d.ts +4 -1
  221. package/.next/types/validator.ts +86 -2
  222. package/next.config.ts +2 -0
  223. package/package.json +8 -2
  224. package/scripts/release.mjs +3 -7
  225. package/scripts/runtime/check-installed.mjs +66 -0
  226. package/scripts/runtime/pi-packages.mjs +51 -0
  227. package/scripts/verify-package.mjs +29 -7
  228. package/src/app/api/agent/[id]/side-chat/route.ts +77 -0
  229. package/src/app/api/files/route.ts +5 -2
  230. package/src/app/api/github/route.ts +26 -0
  231. package/src/app/api/models/defaults/route.ts +18 -0
  232. package/src/app/api/models/route.ts +24 -1
  233. package/src/app/globals.css +1748 -0
  234. package/src/components/AppShell.tsx +145 -43
  235. package/src/components/ChatInput.tsx +264 -79
  236. package/src/components/ChatWindow.tsx +13 -23
  237. package/src/components/ContextMeter.tsx +213 -144
  238. package/src/components/DrawerPanel.tsx +1 -1
  239. package/src/components/FileViewer.tsx +89 -8
  240. package/src/components/GitPanel.tsx +110 -40
  241. package/src/components/GrowthTree.tsx +30 -5
  242. package/src/components/ModelSelector.tsx +21 -5
  243. package/src/components/ProjectPanel.tsx +55 -30
  244. package/src/components/PromptPanel.tsx +2 -4
  245. package/src/components/ProviderSetupModal.module.css +20 -0
  246. package/src/components/ProviderSetupModal.tsx +134 -29
  247. package/src/components/SettingsPanel.tsx +252 -27
  248. package/src/components/TrajectoryView.tsx +4 -6
  249. package/src/components/chat/reading-anchor.ts +81 -0
  250. package/src/components/composer/ComposerImages.tsx +326 -0
  251. package/src/components/side-chat/SideChatPanel.module.css +131 -0
  252. package/src/components/side-chat/SideChatPanel.tsx +189 -0
  253. package/src/components/side-chat/icons.tsx +8 -0
  254. package/src/components/side-chat/request-history.ts +29 -0
  255. package/src/components/side-chat/store.ts +248 -0
  256. package/src/hooks/useFileViewer.ts +6 -4
  257. package/src/hooks/useGrowth.ts +3 -0
  258. package/src/hooks/usePiWeb.ts +12 -2
  259. package/src/hooks/useWorkspaceFiles.ts +93 -0
  260. package/src/i18n.tsx +150 -2
  261. package/src/lib/agent-manager.ts +135 -104
  262. package/src/lib/files-service.ts +166 -3
  263. package/src/lib/format.ts +56 -0
  264. package/src/lib/git-service.ts +45 -13
  265. package/src/lib/github-identity.ts +245 -0
  266. package/src/lib/growth-service.ts +6 -2
  267. package/src/lib/hero-model-memory.ts +28 -0
  268. package/src/lib/model-defaults.ts +105 -0
  269. package/src/lib/model-draft.ts +257 -9
  270. package/src/lib/model-endpoint.ts +23 -0
  271. package/src/lib/model-files.ts +33 -0
  272. package/src/lib/models-service.ts +240 -78
  273. package/src/lib/path-security.ts +24 -32
  274. package/src/lib/pi.ts +27 -2
  275. package/src/lib/process-format.ts +3 -2
  276. package/src/lib/session/metrics.ts +31 -0
  277. package/src/lib/session-reader.ts +1 -1
  278. package/src/lib/side-chat/context.ts +123 -0
  279. package/src/lib/side-chat/service.ts +191 -0
  280. package/src/lib/side-chat/types.ts +61 -0
  281. package/src/lib/thinking-level.ts +39 -0
  282. package/src/lib/trajectory.ts +17 -3
  283. package/src/lib/types.ts +12 -1
  284. package/src/lib/update-service.ts +4 -6
  285. package/src/lib/workspace-index.ts +270 -0
  286. package/.next/server/chunks/229.js +0 -1
  287. package/.next/server/chunks/393.js +0 -1
  288. package/.next/server/chunks/421.js +0 -1
  289. package/.next/server/chunks/430.js +0 -193
  290. package/.next/server/chunks/496.js +0 -1
  291. package/.next/server/chunks/662.js +0 -2
  292. package/.next/server/chunks/686.js +0 -1
  293. package/.next/server/chunks/741.js +0 -165
  294. package/.next/server/chunks/802.js +0 -2
  295. package/.next/server/chunks/807.js +0 -1
  296. package/.next/server/chunks/813.js +0 -1
  297. package/.next/server/chunks/924.js +0 -6
  298. package/.next/static/chunks/2716.7ad073038e94a802.js +0 -1
  299. package/.next/static/chunks/316-5ea443ee6b512aa9.js +0 -1
  300. package/.next/static/chunks/3794-3679f1c05160977c.js +0 -52
  301. package/.next/static/chunks/4673.8a5a9e6855350e34.js +0 -1
  302. package/.next/static/chunks/4686-882531aef1d2e250.js +0 -1
  303. package/.next/static/chunks/4778.305dc6abb9889627.js +0 -1
  304. package/.next/static/chunks/4bd1b696-93bf506b236d6248.js +0 -1
  305. package/.next/static/chunks/618.ee5d5bf1c647e483.js +0 -4
  306. package/.next/static/chunks/839.bfd2db36483faa3e.js +0 -1
  307. package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +0 -1
  308. package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +0 -1
  309. package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +0 -1
  310. package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +0 -1
  311. package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +0 -1
  312. package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +0 -1
  313. package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +0 -1
  314. package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +0 -1
  315. package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +0 -1
  316. package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +0 -1
  317. package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +0 -1
  318. package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +0 -1
  319. package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +0 -1
  320. package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +0 -1
  321. package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +0 -1
  322. package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +0 -1
  323. package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +0 -1
  324. package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +0 -1
  325. package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +0 -1
  326. package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +0 -1
  327. package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +0 -1
  328. package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +0 -1
  329. package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +0 -1
  330. package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +0 -1
  331. package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +0 -1
  332. package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +0 -1
  333. package/.next/static/chunks/app/page-c04af72e549695bd.js +0 -1
  334. package/.next/static/chunks/framework-c4e651fe89edfa91.js +0 -1
  335. package/.next/static/chunks/main-6159b22a8cb0b7c5.js +0 -5
  336. package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +0 -1
  337. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +0 -1
  338. package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +0 -1
  339. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +0 -1
  340. package/.next/static/chunks/webpack-9b4bb574e5817654.js +0 -1
  341. package/.next/static/css/ea27b9e30f76303f.css +0 -1
  342. package/.next/static/sNX5GQkOWuK5YEID1hT-C/_buildManifest.js +0 -1
  343. /package/.next/static/{sNX5GQkOWuK5YEID1hT-C → hYPL1-dmhKFjLS3f5yoqN}/_ssgManifest.js +0 -0
@@ -0,0 +1,326 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 输入卡里待发送的图片。
5
+ * - 不超过 COLLAPSE_AT 张:平铺缩略图(不裁切),右上角删除。
6
+ * - 更多时折叠成牌堆:正面那张在最上,后面最多露 4 层 4px 的右边缘;点「展开 N」沿一条向上拱的弧线扇开。
7
+ * 两种状态是同一批卡片换 transform,所以有从牌堆扇开 / 收拢的动画;扇形按容器实测宽度铺开。
8
+ * 层级始终是左压右(和牌堆同一个顺序,展开时不会闪一下换层),每张露出来的是右侧那条,
9
+ * 删除按钮就在那条的右上角——不用悬停也点得到,触屏同样能删;张数多、条带窄到按钮会挤成一排时,
10
+ * 只在悬停 / 聚焦的那张上显示(触屏点开预览里删)。悬停 / 聚焦的那张抬起来、整张露出。
11
+ * 牌堆里后面几张只露空白的卡边(不露图),看起来是一叠;扇开时图再淡入。
12
+ * - 点任意一张进预览:大图、← → 切换、删除当前张(删完自动看下一张);Esc / 点背景 / 关闭按钮退出,焦点回到原处。
13
+ * 预览挂到 body 上(祖先的 backdrop-filter 会让 fixed 只盖住输入卡),按键在捕获阶段拦下,不会再关掉页面上的其它面板。
14
+ */
15
+ import { useEffect, useRef, useState } from "react";
16
+ import { createPortal } from "react-dom";
17
+ import { useI18n } from "@/i18n";
18
+ import type { ImageAttachment } from "@/lib/types";
19
+
20
+ /** 超过这个数量就折叠(引用 chip 用同一个阈值) */
21
+ export const COLLAPSE_AT = 4;
22
+ /** 牌堆 / 扇形里卡片的边长 */
23
+ const CARD = 96;
24
+ /** 牌堆里后面每层露出的右边缘、最多露几层(再多的叠在最后一层下面、不显示) */
25
+ const DECK_PEEK = 4;
26
+ const DECK_LAYERS = 4;
27
+ /** 扇形所在圆弧的半径(越大弧越平)、相邻两张中心的最大间距(再大就散成一排,不像一手牌了) */
28
+ const FAN_RADIUS = 520;
29
+ const FAN_GAP_MAX = 76;
30
+ /** 悬停 / 聚焦时那张往上抬多少;左右给旋转后探出去的角留的余量 */
31
+ const FAN_LIFT = 12;
32
+ const FAN_SIDE = 8;
33
+ /** 量不到宽度时(首帧、测试环境)按这个宽度排扇形 */
34
+ const FALLBACK_WIDTH = 360;
35
+
36
+ export interface CardPose {
37
+ x: number;
38
+ y: number;
39
+ angle: number;
40
+ /** 牌堆里压在最后一层下面、看不见的 */
41
+ hidden?: boolean;
42
+ }
43
+
44
+ interface Layout {
45
+ cards: CardPose[];
46
+ width: number;
47
+ height: number;
48
+ /** 相邻两张露出来的宽度;太窄时删除按钮会挤成一排,只在悬停 / 聚焦那张上显示 */
49
+ gap: number;
50
+ }
51
+
52
+ /** 露出的条带至少这么宽,每张的删除按钮才常驻(按钮 22px + 一点间隔) */
53
+ const ROOMY_GAP = 30;
54
+
55
+ const round = (value: number) => Math.round(value * 10) / 10;
56
+
57
+ /** 牌堆:只做横向错位,最多露 DECK_LAYERS 层 */
58
+ export function deckLayout(count: number): Layout {
59
+ const cards = Array.from({ length: count }, (_, i) => ({ x: Math.min(i, DECK_LAYERS) * DECK_PEEK, y: 0, angle: 0, hidden: i > DECK_LAYERS }));
60
+ return { cards, width: CARD + Math.min(Math.max(count - 1, 0), DECK_LAYERS) * DECK_PEEK, height: CARD, gap: DECK_PEEK };
61
+ }
62
+
63
+ /**
64
+ * 扇形:卡片中心落在一段圆弧上(中间最高、两侧顺着弧下垂),每张转到切线方向;
65
+ * 间距按可用宽度均分、不超过 FAN_GAP_MAX,从左边开始铺(离牌堆原来的位置最近)。
66
+ */
67
+ export function fanLayout(count: number, width: number): Layout {
68
+ const gap = count > 1 ? Math.min(FAN_GAP_MAX, Math.max(8, (width - CARD - FAN_SIDE * 2) / (count - 1))) : 0;
69
+ const step = gap / FAN_RADIUS;
70
+ const mid = (count - 1) / 2;
71
+ const edge = -mid * step;
72
+ // 正方形转过 θ 后比原来高出(也宽出)的那一截,最外侧两张最大
73
+ const corner = (CARD / 2) * (Math.abs(Math.sin(edge)) + Math.cos(edge) - 1);
74
+ const top = FAN_LIFT + corner;
75
+ const cards = Array.from({ length: count }, (_, i) => {
76
+ const theta = (i - mid) * step;
77
+ return {
78
+ x: round(FAN_SIDE + FAN_RADIUS * (Math.sin(theta) - Math.sin(edge))),
79
+ y: round(top + FAN_RADIUS * (1 - Math.cos(theta))),
80
+ angle: round((theta * 180) / Math.PI),
81
+ };
82
+ });
83
+ const span = cards.length ? cards[cards.length - 1].x + CARD + FAN_SIDE : 0;
84
+ return { cards, width: Math.ceil(span), height: Math.ceil(top + FAN_RADIUS * (1 - Math.cos(edge)) + CARD + corner), gap: round(gap) };
85
+ }
86
+
87
+ /** 每张图一个稳定的 key:前几位 base64 对同格式的图几乎都一样(PNG 都是 iVBORw0KGgo…),不能拿来当 key */
88
+ const imageKeys = new WeakMap<ImageAttachment, string>();
89
+ let nextImageKey = 0;
90
+ function keyOf(image: ImageAttachment): string {
91
+ let key = imageKeys.get(image);
92
+ if (!key) {
93
+ nextImageKey += 1;
94
+ key = `image-${nextImageKey}`;
95
+ imageKeys.set(image, key);
96
+ }
97
+ return key;
98
+ }
99
+
100
+ const srcOf = (image: ImageAttachment) => `data:${image.mimeType};base64,${image.data}`;
101
+
102
+ export function ComposerImages({ images, disabled, onRemove, onClear }: { images: ImageAttachment[]; disabled?: boolean; onRemove: (index: number) => void; onClear?: () => void }) {
103
+ const { t } = useI18n();
104
+ const collapsible = images.length > COLLAPSE_AT;
105
+ const [expanded, setExpanded] = useState(false);
106
+ const fanned = collapsible && expanded;
107
+ /** 扇形里悬停 / 聚焦的那张:抬起来、放到最上层 */
108
+ const [lifted, setLifted] = useState<number | null>(null);
109
+ const [preview, setPreview] = useState<number | null>(null);
110
+ const railRef = useRef<HTMLDivElement>(null);
111
+ const [railWidth, setRailWidth] = useState(0);
112
+
113
+ useEffect(() => {
114
+ const rail = railRef.current;
115
+ if (!rail || !collapsible) return;
116
+ // 量的是整条附件轨的内容宽度(去掉左右 4px 内边距):扇形展开后占满这一行
117
+ const measure = () => setRailWidth(Math.max(0, rail.clientWidth - 8));
118
+ measure();
119
+ if (typeof ResizeObserver === "undefined") return;
120
+ const observer = new ResizeObserver(measure);
121
+ observer.observe(rail);
122
+ return () => observer.disconnect();
123
+ }, [collapsible]);
124
+
125
+ const remove = (index: number) => {
126
+ onRemove(index);
127
+ setLifted(null);
128
+ // 预览里删的:还有图就接着看下一张(删的是最后一张就看前一张)
129
+ setPreview((current) => (current === null ? null : images.length <= 1 ? null : Math.min(current, images.length - 2)));
130
+ };
131
+
132
+ const deleteButton = (index: number) => (
133
+ <button type="button" className="pw-image-delete" disabled={disabled} aria-label={`${t.delete} ${index + 1}`} onClick={() => remove(index)}>
134
+ ✕
135
+ </button>
136
+ );
137
+
138
+ const layout = fanned ? fanLayout(images.length, railWidth || FALLBACK_WIDTH) : deckLayout(images.length);
139
+ const shown = preview !== null && images[preview] ? preview : null;
140
+
141
+ return (
142
+ <>
143
+ <div ref={railRef} className="pointer-events-auto flex flex-wrap items-center gap-2 px-1" data-testid="composer-images">
144
+ {collapsible ? (
145
+ <div
146
+ className="pw-image-fan"
147
+ data-testid="image-fan"
148
+ data-expanded={fanned}
149
+ data-crowded={fanned && layout.gap < ROOMY_GAP ? true : undefined}
150
+ // 容器(composer-attachments)已经浮在输入卡上方、不占布局高度,这里不用再抬
151
+ style={{
152
+ position: "relative",
153
+ width: fanned ? "100%" : layout.width,
154
+ height: layout.height,
155
+ }}
156
+ >
157
+ {images.map((image, i) => {
158
+ const pose = layout.cards[i];
159
+ const up = fanned && lifted === i;
160
+ // 牌堆里只有正面那张能点、能删;后面那几张只是露个边,不进 Tab 顺序
161
+ const reachable = fanned || i === 0;
162
+ // 展开从左往右依次扇出,收起反过来;错开的总时长封顶,张数多也不拖沓
163
+ const delay = Math.min(fanned ? i * 14 : (images.length - 1 - i) * 10, 140);
164
+ return (
165
+ <div
166
+ key={keyOf(image)}
167
+ className="pw-image-card"
168
+ data-hidden={pose.hidden || undefined}
169
+ data-lifted={up || undefined}
170
+ style={{
171
+ zIndex: up ? images.length + 1 : images.length - i,
172
+ transform: `translate(${pose.x}px, ${pose.y - (up ? FAN_LIFT : 0)}px) rotate(${pose.angle}deg)`,
173
+ transitionDelay: up || lifted !== null ? "0ms" : `${delay}ms`,
174
+ }}
175
+ onMouseEnter={() => fanned && setLifted(i)}
176
+ onMouseLeave={() => setLifted((current) => (current === i ? null : current))}
177
+ onFocus={() => fanned && setLifted(i)}
178
+ onBlur={(event) => {
179
+ // 焦点从图片挪到同一张的删除按钮不算离开
180
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setLifted((current) => (current === i ? null : current));
181
+ }}
182
+ >
183
+ <button
184
+ type="button"
185
+ className="pw-image-face"
186
+ tabIndex={reachable ? undefined : -1}
187
+ aria-hidden={reachable ? undefined : true}
188
+ title={t.imagePreview}
189
+ aria-label={t.imagePreview}
190
+ onClick={() => setPreview(i)}
191
+ >
192
+ <img src={srcOf(image)} alt="" className="h-full w-full object-contain" draggable={false} />
193
+ </button>
194
+ {reachable ? deleteButton(i) : null}
195
+ </div>
196
+ );
197
+ })}
198
+ </div>
199
+ ) : (
200
+ images.map((image, i) => (
201
+ <div key={keyOf(image)} className="pw-image-thumb">
202
+ <button type="button" className="block" title={t.imagePreview} aria-label={t.imagePreview} onClick={() => setPreview(i)}>
203
+ <img src={srcOf(image)} alt="" className="max-h-24 max-w-32 rounded-[10px] object-contain" draggable={false} />
204
+ </button>
205
+ {deleteButton(i)}
206
+ </div>
207
+ ))
208
+ )}
209
+ {images.length > 1 && onClear ? (
210
+ <button
211
+ type="button"
212
+ className="pw-chip"
213
+ data-testid="images-clear"
214
+ disabled={disabled}
215
+ onClick={() => {
216
+ onClear();
217
+ setLifted(null);
218
+ setPreview(null);
219
+ }}
220
+ >
221
+ {t.imageClearAll}
222
+ </button>
223
+ ) : null}
224
+ {collapsible ? (
225
+ <button
226
+ type="button"
227
+ className="pw-chip"
228
+ data-testid="images-toggle"
229
+ aria-expanded={fanned}
230
+ onClick={() => {
231
+ setExpanded((open) => !open);
232
+ setLifted(null);
233
+ }}
234
+ >
235
+ {fanned ? t.chipCollapse : t.chipExpandMore.replace("{n}", String(images.length - 1))}
236
+ </button>
237
+ ) : null}
238
+ </div>
239
+ {shown !== null
240
+ ? createPortal(
241
+ <ImageViewer images={images} index={shown} disabled={disabled} onIndex={setPreview} onRemove={remove} onClose={() => setPreview(null)} />,
242
+ document.body,
243
+ )
244
+ : null}
245
+ </>
246
+ );
247
+ }
248
+
249
+ /** 放大预览:手机上没法用本机程序打开图片,应用内看最实在 */
250
+ function ImageViewer({
251
+ images,
252
+ index,
253
+ disabled,
254
+ onIndex,
255
+ onRemove,
256
+ onClose,
257
+ }: {
258
+ images: ImageAttachment[];
259
+ index: number;
260
+ disabled?: boolean;
261
+ onIndex: (index: number) => void;
262
+ onRemove: (index: number) => void;
263
+ onClose: () => void;
264
+ }) {
265
+ const { t } = useI18n();
266
+ const dialogRef = useRef<HTMLDivElement>(null);
267
+ const closeRef = useRef<HTMLButtonElement>(null);
268
+ const count = images.length;
269
+ const go = (delta: number) => onIndex((index + delta + count) % count);
270
+
271
+ // 打开时把焦点移进来,关掉时还给原来那个元素(还在页面上的话)
272
+ useEffect(() => {
273
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
274
+ closeRef.current?.focus();
275
+ return () => {
276
+ if (opener?.isConnected) opener.focus();
277
+ };
278
+ }, []);
279
+
280
+ // 捕获阶段处理:Esc / 方向键不再传给页面上其它监听 document 的面板;Tab 只在预览里转
281
+ useEffect(() => {
282
+ const onKey = (event: KeyboardEvent) => {
283
+ if (event.key === "Escape") onClose();
284
+ else if (event.key === "ArrowLeft" && count > 1) go(-1);
285
+ else if (event.key === "ArrowRight" && count > 1) go(1);
286
+ else if (event.key === "Tab") {
287
+ const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled)") ?? [])];
288
+ if (!focusable.length) return;
289
+ const at = focusable.indexOf(document.activeElement as HTMLElement);
290
+ const next = event.shiftKey ? (at <= 0 ? focusable.length - 1 : at - 1) : at === focusable.length - 1 ? 0 : at + 1;
291
+ focusable[next].focus();
292
+ } else return;
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ };
296
+ document.addEventListener("keydown", onKey, true);
297
+ return () => document.removeEventListener("keydown", onKey, true);
298
+ // onClose / onIndex 来自父组件的 setState 包装,语义不变;只随张数和当前张重绑
299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
300
+ }, [count, index]);
301
+
302
+ return (
303
+ <div ref={dialogRef} className="pw-lightbox" role="dialog" aria-modal="true" aria-label={t.imageViewer} onClick={onClose}>
304
+ <div className="pw-lightbox-bar" onClick={(event) => event.stopPropagation()}>
305
+ <span className="pw-lightbox-count">{count > 1 ? `${index + 1} / ${count}` : ""}</span>
306
+ <button type="button" className="pw-lightbox-btn" disabled={disabled} onClick={() => onRemove(index)}>
307
+ {t.delete}
308
+ </button>
309
+ <button ref={closeRef} type="button" className="pw-lightbox-btn" aria-label={t.close} onClick={onClose}>
310
+ ✕
311
+ </button>
312
+ </div>
313
+ <img src={srcOf(images[index])} alt="" className="pw-lightbox-image" onClick={(event) => event.stopPropagation()} />
314
+ {count > 1 ? (
315
+ <>
316
+ <button type="button" className="pw-lightbox-nav" data-side="prev" aria-label={t.imagePrev} onClick={(event) => { event.stopPropagation(); go(-1); }}>
317
+ ‹
318
+ </button>
319
+ <button type="button" className="pw-lightbox-nav" data-side="next" aria-label={t.imageNext} onClick={(event) => { event.stopPropagation(); go(1); }}>
320
+ ›
321
+ </button>
322
+ </>
323
+ ) : null}
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,131 @@
1
+ .panel {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ min-height: 0;
6
+ min-width: 0;
7
+ }
8
+ .header {
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 6px;
12
+ padding: 10px 14px;
13
+ border-bottom: 0.5px solid var(--dsw-border-l2);
14
+ }
15
+ .header button { width: 28px; height: 28px; flex-shrink: 0; }
16
+ .title { flex: 1; font-size: 13.5px; font-weight: 600; }
17
+ /* 历史记录:按钮 + 浮层菜单(原生 select 不跟深色主题) */
18
+ .history { position: relative; padding: 8px 14px 0; }
19
+ .historyButton {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 6px;
23
+ width: 100%;
24
+ min-width: 0;
25
+ height: 30px;
26
+ padding: 0 8px;
27
+ border: 0.5px solid var(--dsw-border-l2);
28
+ border-radius: 8px;
29
+ background: var(--dsw-hover);
30
+ color: var(--dsw-label-primary);
31
+ font-size: 12px;
32
+ text-align: left;
33
+ transition: background var(--ds-duration-fast) var(--ds-ease-in-out), border-color var(--ds-duration-fast) var(--ds-ease-in-out);
34
+ }
35
+ .historyButton:hover { background: var(--dsw-selector); }
36
+ .historyButton[aria-expanded="true"] { border-color: var(--dsw-border-l3); background: var(--dsw-selector); }
37
+ .historyMenu {
38
+ position: absolute;
39
+ left: 14px;
40
+ right: 14px;
41
+ top: calc(100% + 4px);
42
+ z-index: 30;
43
+ max-height: 260px;
44
+ overflow-y: auto;
45
+ padding: 4px;
46
+ border: 0.5px solid var(--dsw-border-l2);
47
+ border-radius: 10px;
48
+ background: var(--dsw-glass-popover);
49
+ box-shadow: var(--dsw-elevation-soft);
50
+ }
51
+ .historyItem {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 6px;
55
+ width: 100%;
56
+ padding: 6px 8px;
57
+ border-radius: 7px;
58
+ font-size: 12px;
59
+ color: var(--dsw-label-secondary);
60
+ text-align: left;
61
+ }
62
+ .historyItem:hover { background: var(--dsw-hover); color: var(--dsw-label-primary); }
63
+ .historyItem[aria-checked="true"] { background: var(--dsw-accent-soft); color: var(--dsw-accent); }
64
+ .historyMeta { flex: none; font-size: 11px; color: var(--dsw-label-caption); }
65
+ .messages { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
66
+ .messageContent { display: flex; flex-direction: column; gap: 20px; padding: 20px 16px; }
67
+ .empty { margin: 36px 8px; color: var(--dsw-label-secondary); text-align: center; line-height: 1.8; }
68
+ .empty p { margin: 0 0 8px; font-size: 14px; }
69
+ .empty span { font-size: 12px; color: var(--dsw-label-tertiary); }
70
+ .question {
71
+ align-self: flex-end;
72
+ max-width: 94%;
73
+ padding: 9px 12px;
74
+ border-radius: 14px 14px 4px 14px;
75
+ background: var(--dsw-accent-soft);
76
+ font-size: var(--piweb-chat-font-size, var(--dsh-content-font-size));
77
+ line-height: 1.65;
78
+ white-space: pre-wrap;
79
+ overflow-wrap: anywhere;
80
+ }
81
+ .answer { min-width: 0; font-size: var(--piweb-chat-font-size, var(--dsh-content-font-size)); line-height: 1.75; overflow-wrap: anywhere; }
82
+ .answer :global(.md) { font-size: inherit; }
83
+ .answer :global(.md pre), .answer :global(.md table) { max-width: 100%; }
84
+ .notice, .waiting { font-size: 12px; line-height: 1.7; color: var(--dsw-label-tertiary); }
85
+ .notice { margin: 4px 0 10px; }
86
+ .error, .storageError { font-size: 12px; line-height: 1.7; color: var(--dsw-danger); overflow-wrap: anywhere; }
87
+ .error button { margin-left: 10px; text-decoration: underline; }
88
+ .storageError { margin: 0; padding: 4px 14px; }
89
+ .composer {
90
+ margin: 10px 12px 12px;
91
+ padding: 11px 12px 9px;
92
+ border: 0.5px solid var(--dsw-border-l2);
93
+ border-radius: 14px;
94
+ background: var(--dsw-bg-base);
95
+ flex-shrink: 0;
96
+ }
97
+ .composer:focus-within { border-color: var(--dsw-border-l3); }
98
+ .composer textarea {
99
+ display: block;
100
+ width: 100%;
101
+ min-height: 64px;
102
+ max-height: 180px;
103
+ resize: none;
104
+ border: 0;
105
+ outline: none;
106
+ background: transparent;
107
+ color: inherit;
108
+ font-family: inherit;
109
+ font-size: var(--piweb-chat-font-size, var(--dsh-content-font-size));
110
+ line-height: 1.65;
111
+ }
112
+ .composer textarea::placeholder { color: var(--dsw-label-tertiary); }
113
+ .actions { display: flex; align-items: center; gap: 8px; justify-content: space-between; padding-top: 5px; }
114
+ .model { flex: 1; min-width: 0; }
115
+ /* 右下角的上下文环:夹在模型选择器与发送键之间 */
116
+ .meter { display: flex; align-items: center; flex: none; }
117
+ .model :global(.chip) { max-width: 100%; font-size: 12px; }
118
+ .model :global(.chip:disabled) { opacity: 0.6; }
119
+ .model :global([role="menu"]) { left: 0; right: auto; width: min(312px, calc(100% + 38px)); }
120
+ .send {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ width: 30px;
125
+ height: 30px;
126
+ flex-shrink: 0;
127
+ border-radius: 50%;
128
+ background: var(--dsw-label-primary);
129
+ color: var(--dsw-bg-base);
130
+ }
131
+ .send:disabled { opacity: 0.3; cursor: default; }
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import dynamic from "next/dynamic";
5
+ import { IconChevronDown14, IconCloseOutline14, IconNewChatOutline16, IconSendArrowUp14, IconStopFill16 } from "@/components/icons";
6
+ import { ContextMeter } from "@/components/ContextMeter";
7
+ import { ModelSelector, type ModelChoice } from "@/components/ModelSelector";
8
+ import { useI18n } from "@/i18n";
9
+ import { EMPTY_SIDE_CHAT, sideChatStore, type SideChatStore } from "./store";
10
+ import styles from "./SideChatPanel.module.css";
11
+
12
+ const Markdown = dynamic(() => import("@/components/MarkdownBlock").then((module) => module.MarkdownBlock));
13
+ const StreamingMarkdown = dynamic(() => import("@/components/MarkdownBlock").then((module) => module.StreamingMarkdownBlock));
14
+
15
+ export function SideChatPanel({ sessionId, ready = true, mainModel, models = [], providerNames = {}, authByProvider = {}, onClose, store = sideChatStore }: {
16
+ sessionId: string | null;
17
+ ready?: boolean;
18
+ mainModel?: { provider: string; id: string; name: string };
19
+ models?: ModelChoice[];
20
+ providerNames?: Record<string, string>;
21
+ authByProvider?: Record<string, boolean>;
22
+ onClose: () => void;
23
+ store?: SideChatStore;
24
+ }) {
25
+ const { t } = useI18n();
26
+ const subscribe = useCallback((listener: () => void) => store.subscribe(sessionId, listener), [sessionId, store]);
27
+ const getSnapshot = useCallback(() => store.get(sessionId), [sessionId, store]);
28
+ const state = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_SIDE_CHAT);
29
+ const thread = state.threads.find((item) => item.id === state.currentThreadId);
30
+ const scrollRef = useRef<HTMLDivElement>(null);
31
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
32
+ const pinned = useRef(true);
33
+ const lastTop = useRef(0);
34
+ const busy = Boolean(thread?.request);
35
+ const threadId = thread?.id;
36
+ /** 历史记录菜单是否展开 */
37
+ const [historyOpen, setHistoryOpen] = useState(false);
38
+ const historyRef = useRef<HTMLDivElement>(null);
39
+ const selectedModel = thread?.model
40
+ ? models.find((model) => model.provider === thread.model!.provider && model.id === thread.model!.id) ?? { ...thread.model, name: thread.model.id }
41
+ : mainModel;
42
+ const canSend = Boolean(sessionId && ready && selectedModel);
43
+
44
+ useEffect(() => {
45
+ pinned.current = true;
46
+ lastTop.current = 0;
47
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
48
+ }, [sessionId, threadId]);
49
+ useEffect(() => {
50
+ const node = scrollRef.current;
51
+ if (!node) return;
52
+ const pin = () => { if (pinned.current) node.scrollTop = node.scrollHeight; };
53
+ const observer = new ResizeObserver(pin);
54
+ if (node.firstElementChild) observer.observe(node.firstElementChild);
55
+ pin();
56
+ return () => observer.disconnect();
57
+ }, [thread?.messages]);
58
+ useEffect(() => {
59
+ const textarea = textareaRef.current;
60
+ if (!textarea) return;
61
+ textarea.style.height = "auto";
62
+ textarea.style.height = `${Math.min(180, Math.max(64, textarea.scrollHeight))}px`;
63
+ }, [thread?.draft, threadId]);
64
+
65
+ // 历史记录菜单:点外部或 Esc 关掉(与圆环弹窗同一套做法)
66
+ useEffect(() => {
67
+ if (!historyOpen) return;
68
+ const onDown = (event: MouseEvent) => {
69
+ if (!historyRef.current?.contains(event.target as Node)) setHistoryOpen(false);
70
+ };
71
+ const onKey = (event: KeyboardEvent) => {
72
+ if (event.key === "Escape") setHistoryOpen(false);
73
+ };
74
+ document.addEventListener("mousedown", onDown);
75
+ document.addEventListener("keydown", onKey);
76
+ return () => {
77
+ document.removeEventListener("mousedown", onDown);
78
+ document.removeEventListener("keydown", onKey);
79
+ };
80
+ }, [historyOpen]);
81
+
82
+ // 上下文:这次问出去的真实 prompt(input + 缓存读写)对上该模型的窗口。
83
+ // 没有真实用量就一律 null —— 圆环显示「用量未知」,不编数字。
84
+ const usage = thread?.usage;
85
+ const contextTokens = usage ? usage.input + usage.cacheRead + usage.cacheWrite : null;
86
+ const contextWindow = thread?.contextWindow ?? null;
87
+ const contextPercent = contextTokens !== null && contextWindow ? (contextTokens / contextWindow) * 100 : null;
88
+
89
+ const send = () => {
90
+ if (!sessionId || !canSend || busy || !thread?.draft.trim()) return;
91
+ pinned.current = true;
92
+ store.send(sessionId);
93
+ textareaRef.current?.focus({ preventScroll: true });
94
+ };
95
+
96
+ return (
97
+ <section className={styles.panel} aria-label={t.sideChat} data-testid="side-chat-panel">
98
+ <header className={styles.header}>
99
+ <span className={styles.title}>{t.sideChat}</span>
100
+ <button type="button" className="icon-btn" aria-label={t.sideChatNew} title={t.sideChatNew} disabled={!sessionId} onClick={() => sessionId && store.createThread(sessionId)}>
101
+ <IconNewChatOutline16 size={16} />
102
+ </button>
103
+ <button type="button" className="icon-btn" aria-label={t.close} title={t.close} onClick={onClose}><IconCloseOutline14 size={14} /></button>
104
+ </header>
105
+ {state.threads.length > 1 && (
106
+ // 原生 select 在深色界面里是系统白底菜单(又丑又不跟主题),改用与模型选择器一致的浮层菜单
107
+ <div className={styles.history} ref={historyRef}>
108
+ <button type="button" className={styles.historyButton} aria-label={t.sideChatHistory} aria-haspopup="menu" aria-expanded={historyOpen} onClick={() => setHistoryOpen((open) => !open)}>
109
+ <span className="truncate">{thread ? `${thread.title || t.sideChatNew}${thread.request ? " · …" : ""}` : t.sideChatHistory}</span>
110
+ <IconChevronDown14 size={12} style={{ flex: "none", color: "var(--dsw-label-caption)" }} />
111
+ </button>
112
+ {historyOpen && (
113
+ <div className={styles.historyMenu} role="menu" aria-label={t.sideChatHistory}>
114
+ {[...state.threads].reverse().map((item) => (
115
+ <button key={item.id} type="button" role="menuitemradio" aria-checked={item.id === threadId} className={styles.historyItem}
116
+ onClick={() => { if (sessionId) store.selectThread(sessionId, item.id); setHistoryOpen(false); }}>
117
+ <span className="truncate">{item.title || t.sideChatNew}</span>
118
+ {item.request ? <span className={styles.historyMeta}>…</span> : null}
119
+ </button>
120
+ ))}
121
+ </div>
122
+ )}
123
+ </div>
124
+ )}
125
+ <div className={styles.messages} ref={scrollRef} role="log" aria-label={t.sideChatMessages} aria-live="polite" aria-relevant="additions" onScroll={(event) => {
126
+ const node = event.currentTarget;
127
+ const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
128
+ const movedUp = node.scrollTop < lastTop.current - 1 && distance > 2;
129
+ pinned.current = !movedUp && distance < 48;
130
+ lastTop.current = node.scrollTop;
131
+ }}>
132
+ <div className={styles.messageContent}>
133
+ {!thread?.messages.length ? (
134
+ <div className={styles.empty}>
135
+ <p>{sessionId ? t.sideChatEmpty : t.sideChatPickSession}</p>
136
+ {sessionId && <span>{t.sideChatHint}</span>}
137
+ </div>
138
+ ) : thread.messages.map((message) => (
139
+ <div key={message.id} className={message.role === "user" ? styles.question : styles.answer} data-role={message.role}>
140
+ {message.role === "user" ? message.text : <>
141
+ {message.contextTruncated && <p className={styles.notice}>{t.sideChatContextTruncated}</p>}
142
+ {message.text && (message.status === "pending" ? <StreamingMarkdown text={message.text} /> : <Markdown text={message.text} />)}
143
+ {message.status === "pending" && !message.text && <span className={styles.waiting} role="status">{thread.connectionLost ? t.reconnecting : t.sideChatAnswering}</span>}
144
+ {message.error && <div className={styles.error} role="alert">{message.error}{message.status === "error" && message === thread.messages.at(-1) && <button type="button" disabled={busy} onClick={() => sessionId && store.send(sessionId, true)}>{t.retry}</button>}</div>}
145
+ {message.status === "aborted" && <p className={styles.notice}>{t.turnAborted}</p>}
146
+ {message.lengthLimited && <p className={styles.notice}>{t.sideChatLengthLimited}</p>}
147
+ </>}
148
+ </div>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ {state.storageFailed && <p className={styles.storageError} role="alert">{t.sideChatStorageFailed}</p>}
153
+ <form className={styles.composer} onSubmit={(event) => { event.preventDefault(); send(); }}>
154
+ <textarea ref={textareaRef} aria-label={t.sideChatQuestion} placeholder={sessionId ? t.sideChatPlaceholder : t.sideChatPickSession} value={thread?.draft ?? ""} disabled={!canSend}
155
+ onChange={(event) => sessionId && store.setDraft(sessionId, event.target.value)}
156
+ onKeyDown={(event) => {
157
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) { event.preventDefault(); send(); }
158
+ }} />
159
+ <div className={styles.actions}>
160
+ <div className={styles.model} role="group" aria-label={t.sideChatModel}>
161
+ <ModelSelector model={selectedModel} models={models} providerNames={providerNames} authByProvider={authByProvider}
162
+ modelOnly disabled={!sessionId} thinkingLevels={[]} onSelectLevel={() => {}}
163
+ defaultOption={{ label: t.sideChatFollowMain, selected: !thread?.model, onSelect: () => sessionId && store.setModel(sessionId) }}
164
+ onSelectModel={(provider, id) => sessionId && store.setModel(sessionId, { provider, id })} />
165
+ </div>
166
+ <div className={styles.meter}>
167
+ <ContextMeter
168
+ percent={contextPercent}
169
+ tokens={contextTokens}
170
+ contextWindow={contextWindow}
171
+ label={t.contextMeasured}
172
+ source={thread?.characters ? {
173
+ systemChars: 0,
174
+ messages: thread.messages.map((message) => ({ role: message.role, content: [{ type: "text", text: message.text }] })),
175
+ breakdown: {
176
+ characters: { systemPrompt: thread.characters.systemPrompt, background: thread.characters.background, systemTools: 0, customTools: 0, memory: 0, skills: 0, compacted: 0 },
177
+ systemPrompt: 0, systemTools: 0, customTools: 0, memory: 0, skills: 0, compacted: 0, autoCompactBuffer: 0,
178
+ },
179
+ } : undefined}
180
+ model={selectedModel ? { provider: selectedModel.provider, id: selectedModel.id } : null}
181
+ />
182
+ </div>
183
+ {busy ? <button type="button" className={styles.send} title={t.stop} aria-label={t.stop} onClick={() => sessionId && store.stop(sessionId)}><IconStopFill16 size={15} /></button>
184
+ : <button type="submit" className={styles.send} title={t.send} aria-label={t.send} disabled={!canSend || !thread?.draft.trim()}><IconSendArrowUp14 size={16} /></button>}
185
+ </div>
186
+ </form>
187
+ </section>
188
+ );
189
+ }
@@ -0,0 +1,8 @@
1
+ import type { SVGProps } from "react";
2
+
3
+ export function SideChatIcon({ size = 16, ...props }: SVGProps<SVGSVGElement> & { size?: number }) {
4
+ return <svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
5
+ <path d="M12.5 11.5H6l-3.5 2V4.5A2 2 0 0 1 4.5 2.5h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2Z" />
6
+ <path d="M7.5 14.5h6.5l3.5 2V7.5a2 2 0 0 0-1-1.73" />
7
+ </svg>;
8
+ }