@rexvane/piweb 0.3.24 → 0.3.26

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 (285) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +11 -11
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +5 -5
  5. package/.next/react-loadable-manifest.json +13 -20
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +6 -8
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/agent/resources/route.js +1 -0
  32. package/.next/server/app/api/agent/resources/route.js.nft.json +1 -0
  33. package/.next/server/app/api/agent/resources/route_client-reference-manifest.js +1 -0
  34. package/.next/server/app/api/config/route.js +1 -1
  35. package/.next/server/app/api/config/route.js.nft.json +1 -1
  36. package/.next/server/app/api/files/route.js +1 -1
  37. package/.next/server/app/api/files/route.js.nft.json +1 -1
  38. package/.next/server/app/api/fs/route.js +1 -1
  39. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  40. package/.next/server/app/api/git/route.js +4 -4
  41. package/.next/server/app/api/git/route.js.nft.json +1 -1
  42. package/.next/server/app/api/github/route.js +4 -4
  43. package/.next/server/app/api/github/route.js.nft.json +1 -1
  44. package/.next/server/app/api/growth/route.js +1 -1
  45. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  46. package/.next/server/app/api/health/route.js +1 -1
  47. package/.next/server/app/api/models/defaults/route.js +1 -1
  48. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
  49. package/.next/server/app/api/models/route.js +1 -1
  50. package/.next/server/app/api/models/route.js.nft.json +1 -1
  51. package/.next/server/app/api/pi-settings/route.js +1 -1
  52. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  53. package/.next/server/app/api/plugins/route.js +1 -1
  54. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  55. package/.next/server/app/api/prompts/route.js +1 -1
  56. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  57. package/.next/server/app/api/remote/route.js +4 -3
  58. package/.next/server/app/api/security/route.js +1 -1
  59. package/.next/server/app/api/security/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  61. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  63. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  64. package/.next/server/app/api/sessions/import/route.js +2 -2
  65. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  66. package/.next/server/app/api/sessions/route.js +1 -1
  67. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  68. package/.next/server/app/api/skills/route.js +2 -2
  69. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  70. package/.next/server/app/api/update/route.js +2 -2
  71. package/.next/server/app/api/update/route.js.nft.json +1 -1
  72. package/.next/server/app/api/version/route.js +1 -1
  73. package/.next/server/app/api/version/route.js.nft.json +1 -1
  74. package/.next/server/app/api/web-auth/route.js +2 -2
  75. package/.next/server/app/api/workspaces/route.js +1 -1
  76. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  77. package/.next/server/app/icon.svg/route.js +1 -1
  78. package/.next/server/app/index.html +1 -1
  79. package/.next/server/app/index.rsc +6 -7
  80. package/.next/server/app/index.segments/__PAGE__.segment.rsc +6 -7
  81. package/.next/server/app/index.segments/_full.segment.rsc +6 -7
  82. package/.next/server/app/index.segments/_tree.segment.rsc +2 -3
  83. package/.next/server/app/login/page.js +1 -1
  84. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/login.html +1 -1
  86. package/.next/server/app/login.rsc +3 -3
  87. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  88. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  89. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  90. package/.next/server/app/page.js +1 -1
  91. package/.next/server/app/page.js.nft.json +1 -1
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app-paths-manifest.json +11 -11
  94. package/.next/server/chunks/3924.js +3 -3
  95. package/.next/server/chunks/538.js +1 -1
  96. package/.next/server/chunks/620.js +1 -0
  97. package/.next/server/chunks/6229.js +1 -1
  98. package/.next/server/chunks/7496.js +1 -1
  99. package/.next/server/chunks/7662.js +2 -2
  100. package/.next/server/chunks/8686.js +1 -1
  101. package/.next/server/functions-config-manifest.json +1 -2
  102. package/.next/server/instrumentation.js.nft.json +1 -1
  103. package/.next/server/middleware-build-manifest.js +1 -1
  104. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/preview-props.json +3 -3
  108. package/.next/server/server-reference-manifest.json +1 -1
  109. package/.next/static/QJCtqgDKzGS2BdKABd9ch/_buildManifest.js +1 -0
  110. package/.next/static/chunks/2577.86c8538fbf05a8b2.js +1 -0
  111. package/.next/static/chunks/2716.fa561519675a3ff1.js +1 -0
  112. package/.next/static/chunks/2906.8a0e09bb47605cf1.js +1 -0
  113. package/.next/static/chunks/4673.ea36e55005c0f82f.js +1 -0
  114. package/.next/static/chunks/4686-4dc565817a160351.js +1 -0
  115. package/.next/static/chunks/4778.0d2c5d75b791d352.js +1 -0
  116. package/.next/static/chunks/{618.949981da99be7182.js → 618.9a22656b863f242b.js} +2 -2
  117. package/.next/static/chunks/app/_global-error/page-f2d93969c0bcf406.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-f2d93969c0bcf406.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-f2d93969c0bcf406.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-f2d93969c0bcf406.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-f2d93969c0bcf406.js +1 -0
  122. package/.next/static/chunks/app/api/agent/resources/route-f2d93969c0bcf406.js +1 -0
  123. package/.next/static/chunks/app/api/config/route-f2d93969c0bcf406.js +1 -0
  124. package/.next/static/chunks/app/api/files/route-f2d93969c0bcf406.js +1 -0
  125. package/.next/static/chunks/app/api/fs/route-f2d93969c0bcf406.js +1 -0
  126. package/.next/static/chunks/app/api/git/route-f2d93969c0bcf406.js +1 -0
  127. package/.next/static/chunks/app/api/github/route-f2d93969c0bcf406.js +1 -0
  128. package/.next/static/chunks/app/api/growth/route-f2d93969c0bcf406.js +1 -0
  129. package/.next/static/chunks/app/api/health/route-f2d93969c0bcf406.js +1 -0
  130. package/.next/static/chunks/app/api/models/defaults/route-f2d93969c0bcf406.js +1 -0
  131. package/.next/static/chunks/app/api/models/route-f2d93969c0bcf406.js +1 -0
  132. package/.next/static/chunks/app/api/pi-settings/route-f2d93969c0bcf406.js +1 -0
  133. package/.next/static/chunks/app/api/plugins/route-f2d93969c0bcf406.js +1 -0
  134. package/.next/static/chunks/app/api/prompts/route-f2d93969c0bcf406.js +1 -0
  135. package/.next/static/chunks/app/api/remote/route-f2d93969c0bcf406.js +1 -0
  136. package/.next/static/chunks/app/api/security/route-f2d93969c0bcf406.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/[id]/export/route-f2d93969c0bcf406.js +1 -0
  138. package/.next/static/chunks/app/api/sessions/[id]/route-f2d93969c0bcf406.js +1 -0
  139. package/.next/static/chunks/app/api/sessions/import/route-f2d93969c0bcf406.js +1 -0
  140. package/.next/static/chunks/app/api/sessions/route-f2d93969c0bcf406.js +1 -0
  141. package/.next/static/chunks/app/api/skills/route-f2d93969c0bcf406.js +1 -0
  142. package/.next/static/chunks/app/api/update/route-f2d93969c0bcf406.js +1 -0
  143. package/.next/static/chunks/app/api/version/route-f2d93969c0bcf406.js +1 -0
  144. package/.next/static/chunks/app/api/web-auth/route-f2d93969c0bcf406.js +1 -0
  145. package/.next/static/chunks/app/api/workspaces/route-f2d93969c0bcf406.js +1 -0
  146. package/.next/static/chunks/app/page-71f2d3b3ed864b3d.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f2d93969c0bcf406.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f2d93969c0bcf406.js +1 -0
  149. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f2d93969c0bcf406.js +1 -0
  150. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f2d93969c0bcf406.js +1 -0
  151. package/.next/static/chunks/webpack-ffc09b5fcc8aaa6d.js +1 -0
  152. package/.next/static/css/014eb1f21414e615.css +1 -0
  153. package/.next/static/css/56b129a13637fcfd.css +1 -0
  154. package/.next/static/css/7e3caf797e4cb19a.css +1 -0
  155. package/.next/types/app/api/agent/{[id]/side-chat → resources}/route.ts +3 -3
  156. package/.next/types/routes.d.ts +2 -2
  157. package/.next/types/validator.ts +9 -9
  158. package/README.md +1 -1
  159. package/next.config.ts +1 -1
  160. package/package.json +9 -9
  161. package/scripts/apply-global.mjs +87 -0
  162. package/scripts/launcher.mjs +21 -0
  163. package/scripts/testing/prepare-sdk-tools.mjs +19 -0
  164. package/src/app/api/agent/new/route.ts +2 -1
  165. package/src/app/api/agent/resources/route.ts +18 -0
  166. package/src/app/api/models/route.ts +22 -1
  167. package/src/app/api/plugins/route.ts +3 -2
  168. package/src/app/api/prompts/route.ts +3 -2
  169. package/src/app/api/remote/route.ts +22 -4
  170. package/src/app/api/sessions/[id]/export/route.ts +3 -1
  171. package/src/app/api/skills/route.ts +3 -2
  172. package/src/app/globals.css +1002 -1765
  173. package/src/components/AppShell.tsx +128 -93
  174. package/src/components/ChatInput.tsx +177 -98
  175. package/src/components/ChatWindow.tsx +189 -56
  176. package/src/components/ContextMeter.tsx +33 -9
  177. package/src/components/CustomSelect.module.css +142 -0
  178. package/src/components/CustomSelect.tsx +157 -0
  179. package/src/components/DrawerPanel.tsx +1 -1
  180. package/src/components/ExtensionUI.tsx +9 -3
  181. package/src/components/FileViewer.tsx +24 -21
  182. package/src/components/GitPanel.tsx +35 -13
  183. package/src/components/GrowthTree.tsx +6 -2
  184. package/src/components/ImageViewer.tsx +72 -0
  185. package/src/components/MarkdownBlock.tsx +67 -96
  186. package/src/components/ModelSelector.tsx +36 -39
  187. package/src/components/PiMark.tsx +76 -41
  188. package/src/components/PreviewImage.tsx +33 -0
  189. package/src/components/ProjectPanel.tsx +8 -5
  190. package/src/components/PromptPanel.tsx +85 -20
  191. package/src/components/ProviderSetupModal.module.css +12 -11
  192. package/src/components/ProviderSetupModal.tsx +107 -36
  193. package/src/components/RemoteSection.tsx +53 -2
  194. package/src/components/SessionImportSection.tsx +30 -15
  195. package/src/components/SessionSidebar.tsx +72 -62
  196. package/src/components/SettingsPanel.tsx +49 -151
  197. package/src/components/TrajectoryAnalyzer.module.css +26 -11
  198. package/src/components/TrajectoryView.tsx +11 -3
  199. package/src/components/chat/markdown-actions.tsx +23 -0
  200. package/src/components/composer/ComposerFiles.tsx +224 -0
  201. package/src/components/composer/ComposerImages.tsx +59 -127
  202. package/src/hooks/usePiWeb.ts +47 -2
  203. package/src/i18n.tsx +62 -80
  204. package/src/lib/agent-manager.ts +365 -33
  205. package/src/lib/chat/file-path.ts +54 -0
  206. package/src/lib/chat/skill-expansion.ts +51 -0
  207. package/src/lib/extension-ui.ts +25 -1
  208. package/src/lib/font.ts +37 -39
  209. package/src/lib/git-service.ts +4 -2
  210. package/src/lib/growth-turns.ts +3 -1
  211. package/src/lib/model-draft.ts +69 -2
  212. package/src/lib/models-service.ts +53 -3
  213. package/src/lib/pi.ts +2 -2
  214. package/src/lib/remote/assets.json +1 -1
  215. package/src/lib/remote/supervisor.ts +48 -5
  216. package/src/lib/security-service.ts +2 -1
  217. package/src/lib/session-reader.ts +25 -13
  218. package/src/lib/session-retention.ts +11 -8
  219. package/src/lib/skills-service.ts +2 -1
  220. package/src/lib/slash-command-token.ts +14 -0
  221. package/src/lib/trajectory.ts +111 -15
  222. package/src/lib/types.ts +28 -3
  223. package/src/lib/update-service.ts +29 -9
  224. package/src/lib/workspace-store.ts +36 -1
  225. package/.next/server/app/api/agent/[id]/side-chat/route.js +0 -5
  226. package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +0 -1
  227. package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +0 -1
  228. package/.next/server/chunks/3421.js +0 -1
  229. package/.next/static/chunks/2716.44fdf1b21454b961.js +0 -1
  230. package/.next/static/chunks/4673.e261707dce391be7.js +0 -1
  231. package/.next/static/chunks/4686-0cd87513f9ea3538.js +0 -1
  232. package/.next/static/chunks/4778.eddd3a5813f85824.js +0 -1
  233. package/.next/static/chunks/7411.5f79671208209b13.js +0 -1
  234. package/.next/static/chunks/8552.25c39ef4c995eba0.js +0 -1
  235. package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +0 -1
  236. package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +0 -1
  237. package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +0 -1
  238. package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +0 -1
  239. package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +0 -1
  240. package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +0 -1
  241. package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +0 -1
  242. package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +0 -1
  243. package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +0 -1
  244. package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +0 -1
  245. package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +0 -1
  246. package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +0 -1
  247. package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +0 -1
  248. package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +0 -1
  249. package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +0 -1
  250. package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +0 -1
  251. package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +0 -1
  252. package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +0 -1
  253. package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +0 -1
  254. package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +0 -1
  255. package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +0 -1
  256. package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +0 -1
  257. package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +0 -1
  258. package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +0 -1
  259. package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +0 -1
  260. package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +0 -1
  261. package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +0 -1
  262. package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +0 -1
  263. package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +0 -1
  264. package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +0 -1
  265. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +0 -1
  266. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +0 -1
  267. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +0 -1
  268. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +0 -1
  269. package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +0 -1
  270. package/.next/static/css/08815507594e8b8d.css +0 -1
  271. package/.next/static/css/9fc559c2c4ad1d0f.css +0 -1
  272. package/.next/static/css/c6e2227c36ec2fd6.css +0 -1
  273. package/.next/static/css/f15a853af8b5127f.css +0 -1
  274. package/.next/static/lvKCJmjFj8LuqSojRVLRF/_buildManifest.js +0 -1
  275. package/src/app/api/agent/[id]/side-chat/route.ts +0 -77
  276. package/src/components/side-chat/SideChatPanel.module.css +0 -131
  277. package/src/components/side-chat/SideChatPanel.tsx +0 -189
  278. package/src/components/side-chat/icons.tsx +0 -8
  279. package/src/components/side-chat/request-history.ts +0 -29
  280. package/src/components/side-chat/store.ts +0 -248
  281. package/src/lib/chat/stream-settings.ts +0 -192
  282. package/src/lib/side-chat/context.ts +0 -123
  283. package/src/lib/side-chat/service.ts +0 -191
  284. package/src/lib/side-chat/types.ts +0 -61
  285. /package/.next/static/{lvKCJmjFj8LuqSojRVLRF → QJCtqgDKzGS2BdKABd9ch}/_ssgManifest.js +0 -0
@@ -1,189 +0,0 @@
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
- }
@@ -1,8 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
1
- import { SIDE_CHAT_BODY_LIMIT, SIDE_CHAT_HISTORY_LIMIT, SIDE_CHAT_TEXT_LIMIT, type SideChatHistoryMessage } from "@/lib/side-chat/types";
2
-
3
- /** Bound only the transmitted context; the browser retains the original transcript. */
4
- export function requestHistory(messages: SideChatHistoryMessage[]): { history: SideChatHistoryMessage[]; truncated: boolean } {
5
- const encoder = new TextEncoder();
6
- const history: SideChatHistoryMessage[] = [];
7
- // Reserve the other half for the question (including JSON escapes), model and envelope.
8
- let remaining = Math.floor(SIDE_CHAT_BODY_LIMIT / 2) - 2;
9
- let truncated = false;
10
- for (let i = messages.length - 1; i >= 0; i--) {
11
- if (history.length === SIDE_CHAT_HISTORY_LIMIT) { truncated = true; break; }
12
- const message = messages[i];
13
- let text = message.text;
14
- if (text.length > SIDE_CHAT_TEXT_LIMIT) {
15
- text = text.slice(-SIDE_CHAT_TEXT_LIMIT);
16
- if (/^[\uDC00-\uDFFF]/.test(text)) text = text.slice(1);
17
- truncated = true;
18
- }
19
- const entry = { role: message.role, text };
20
- const size = encoder.encode(JSON.stringify(entry)).byteLength + 1;
21
- if (size > remaining) { truncated = true; break; }
22
- remaining -= size;
23
- history.push(entry);
24
- }
25
- history.reverse();
26
- // A retained transcript must begin with a question, not an orphaned answer.
27
- while (history[0]?.role === "assistant") { history.shift(); truncated = true; }
28
- return { history, truncated };
29
- }
@@ -1,248 +0,0 @@
1
- "use client";
2
-
3
- import { isSideChatId, isSideChatModel, type SideChatModel, type SideChatHistoryMessage, type SideChatProgress, type SideChatRequest, type SideChatUsage } from "@/lib/side-chat/types";
4
-
5
- import { requestHistory } from "./request-history";
6
-
7
- export interface SideChatMessage {
8
- id: string;
9
- role: "user" | "assistant";
10
- text: string;
11
- status?: "pending" | "done" | "error" | "aborted";
12
- error?: string;
13
- contextTruncated?: boolean;
14
- lengthLimited?: boolean;
15
- }
16
- export interface SideChatThread {
17
- id: string;
18
- title: string;
19
- messages: SideChatMessage[];
20
- draft: string;
21
- model?: SideChatModel;
22
- request?: { id: string; revision: number };
23
- /** 最近一次回答的真实用量与窗口:面板右下角的上下文环用它 */
24
- usage?: SideChatUsage;
25
- contextWindow?: number;
26
- /** 这次上下文的真实字符分项(系统提示词 / 引用的主会话背景) */
27
- characters?: { systemPrompt: number; background: number };
28
- connectionLost?: boolean;
29
- }
30
- export interface SideChatState {
31
- currentThreadId: string;
32
- threads: SideChatThread[];
33
- storageFailed: boolean;
34
- }
35
- export const EMPTY_SIDE_CHAT: SideChatState = { currentThreadId: "", threads: [], storageFailed: false };
36
- const PREFIX = "piweb.sidechat.v1.";
37
-
38
- class ApiError extends Error {
39
- constructor(message: string, readonly status: number) { super(message); }
40
- }
41
-
42
- function pause(ms: number, signal: AbortSignal): Promise<void> {
43
- return new Promise((resolve) => {
44
- const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
45
- const timer = setTimeout(finish, ms);
46
- signal.addEventListener("abort", finish, { once: true });
47
- if (signal.aborted) finish();
48
- });
49
- }
50
-
51
- /** Browser-owned threads outlive panel unmounts. Only their own subscribers render on an answer update. */
52
- export class SideChatStore {
53
- private states = new Map<string, SideChatState>();
54
- private listeners = new Map<string, Set<() => void>>();
55
- private requests = new Map<string, AbortController>();
56
- private saves = new Map<string, ReturnType<typeof setTimeout>>();
57
- constructor(private options: { fetch: typeof fetch; storage: () => Storage; id: () => string } = {
58
- fetch: (...args) => fetch(...args), storage: () => localStorage, id: () => crypto.randomUUID(),
59
- }) {}
60
-
61
- get = (sessionId: string | null): SideChatState => {
62
- if (!sessionId) return EMPTY_SIDE_CHAT;
63
- let state = this.states.get(sessionId);
64
- if (state) return state;
65
- try {
66
- const raw = this.options.storage().getItem(PREFIX + sessionId);
67
- if (raw) {
68
- const saved = JSON.parse(raw) as SideChatState;
69
- if (isSideChatId(saved.currentThreadId) && Array.isArray(saved.threads) && saved.threads.length && saved.threads.every((thread) =>
70
- isSideChatId(thread.id) && typeof thread.title === "string" && typeof thread.draft === "string" && Array.isArray(thread.messages) && thread.messages.every((message) =>
71
- isSideChatId(message.id) && (message.role === "user" || message.role === "assistant") && typeof message.text === "string") &&
72
- (!thread.request || (isSideChatId(thread.request.id) && Number.isSafeInteger(thread.request.revision)))) && saved.threads.some((thread) => thread.id === saved.currentThreadId)) {
73
- state = { ...saved, storageFailed: false, threads: saved.threads.map((thread) => ({ ...thread, model: isSideChatModel(thread.model) ? thread.model : undefined })) };
74
- }
75
- }
76
- } catch { /* A unavailable or full browser store must not disable questions. */ }
77
- if (!state) {
78
- const thread = this.newThread();
79
- state = { currentThreadId: thread.id, threads: [thread], storageFailed: false };
80
- }
81
- this.states.set(sessionId, state);
82
- return state;
83
- };
84
-
85
- flush = (): void => { for (const sessionId of this.states.keys()) this.save(sessionId); };
86
-
87
- subscribe = (sessionId: string | null, listener: () => void): (() => void) => {
88
- if (!sessionId) return () => {};
89
- const listeners = this.listeners.get(sessionId) ?? new Set();
90
- listeners.add(listener);
91
- this.listeners.set(sessionId, listeners);
92
- queueMicrotask(() => {
93
- for (const thread of this.get(sessionId).threads) {
94
- if (thread.request && !this.requests.has(thread.request.id)) void this.run(sessionId, thread.id, thread.request.id);
95
- }
96
- });
97
- return () => { listeners.delete(listener); };
98
- };
99
-
100
- private newThread(model?: SideChatModel): SideChatThread { return { id: this.options.id(), title: "", messages: [], draft: "", ...(model ? { model: { ...model } } : {}) }; }
101
- private notify(sessionId: string): void { for (const listener of this.listeners.get(sessionId) ?? []) listener(); }
102
- private save(sessionId: string): void {
103
- const timer = this.saves.get(sessionId);
104
- if (timer) clearTimeout(timer);
105
- this.saves.delete(sessionId);
106
- const state = this.get(sessionId);
107
- let failed = false;
108
- try { this.options.storage().setItem(PREFIX + sessionId, JSON.stringify(state)); }
109
- catch { failed = true; }
110
- if (state.storageFailed !== failed) {
111
- this.states.set(sessionId, { ...state, storageFailed: failed });
112
- this.notify(sessionId);
113
- }
114
- }
115
- private update(sessionId: string, state: SideChatState, immediately = false): void {
116
- this.states.set(sessionId, state);
117
- this.notify(sessionId);
118
- if (immediately) this.save(sessionId);
119
- else if (!this.saves.has(sessionId)) this.saves.set(sessionId, setTimeout(() => this.save(sessionId), 300));
120
- }
121
- private updateThread(sessionId: string, threadId: string, change: (thread: SideChatThread) => SideChatThread, immediately = false): void {
122
- const state = this.get(sessionId);
123
- this.update(sessionId, { ...state, threads: state.threads.map((thread) => thread.id === threadId ? change(thread) : thread) }, immediately);
124
- }
125
-
126
- setDraft(sessionId: string, text: string): void {
127
- this.updateThread(sessionId, this.get(sessionId).currentThreadId, (thread) => ({ ...thread, draft: text }));
128
- }
129
- setModel(sessionId: string, model?: SideChatModel): void {
130
- this.updateThread(sessionId, this.get(sessionId).currentThreadId, (thread) => ({ ...thread, model: model ? { ...model } : undefined }), true);
131
- }
132
- createThread(sessionId: string): void {
133
- const state = this.get(sessionId);
134
- const current = state.threads.find((thread) => thread.id === state.currentThreadId);
135
- if (current && !current.messages.length && !current.draft) return;
136
- const thread = this.newThread(current?.model);
137
- this.update(sessionId, { ...state, currentThreadId: thread.id, threads: [...state.threads, thread] }, true);
138
- }
139
- selectThread(sessionId: string, threadId: string): void {
140
- const state = this.get(sessionId);
141
- if (state.threads.some((thread) => thread.id === threadId)) this.update(sessionId, { ...state, currentThreadId: threadId }, true);
142
- }
143
-
144
- send(sessionId: string, retry = false): void {
145
- const state = this.get(sessionId);
146
- const thread = state.threads.find((item) => item.id === state.currentThreadId);
147
- if (!thread || thread.request) return;
148
- const previous = thread.messages.at(-1);
149
- const retrying = retry && previous?.role === "assistant" && previous.status === "error" && thread.messages.at(-2)?.role === "user";
150
- const text = (retrying ? thread.messages.at(-2)!.text : thread.draft).trim();
151
- if (!text) return;
152
- const requestId = this.options.id();
153
- const historyMessages = retrying ? thread.messages.slice(0, -2) : thread.messages;
154
- const availableHistory: SideChatHistoryMessage[] = historyMessages.filter((message) => message.text && (message.role === "user" || message.status === "done" || message.status === "aborted"))
155
- .map(({ role, text }) => ({ role, text }));
156
- const { history, truncated } = requestHistory(availableHistory);
157
- const messages: SideChatMessage[] = retrying
158
- ? [...thread.messages.slice(0, -1), { id: previous!.id, role: "assistant", text: "", status: "pending", contextTruncated: truncated }]
159
- : [...thread.messages, { id: this.options.id(), role: "user", text }, { id: this.options.id(), role: "assistant", text: "", status: "pending", contextTruncated: truncated }];
160
- this.updateThread(sessionId, thread.id, (item) => ({ ...item, title: item.title || text.slice(0, 80), messages, draft: retrying ? item.draft : "", request: { id: requestId, revision: -1 }, connectionLost: false }), true);
161
- void this.run(sessionId, thread.id, requestId, { requestId, threadId: thread.id, text, history, ...(thread.model ? { model: { ...thread.model } } : {}) });
162
- }
163
-
164
- private apply(sessionId: string, threadId: string, requestId: string, progress: SideChatProgress): void {
165
- this.updateThread(sessionId, threadId, (thread) => {
166
- if (thread.request?.id !== requestId) return thread;
167
- const previousText = thread.messages.at(-1)?.text ?? "";
168
- if (progress.textOffset !== undefined && progress.textOffset > previousText.length) throw new Error("side-chat answer cursor is out of sync");
169
- const text = progress.textOffset === undefined ? progress.text : previousText.slice(0, progress.textOffset) + progress.text;
170
- const status = !progress.done ? "pending" : progress.error ? "error" : progress.aborted ? "aborted" : "done";
171
- return {
172
- ...thread, connectionLost: false, request: progress.done ? undefined : { id: requestId, revision: progress.revision },
173
- // 只在这次响应真的报了就更新;没报就保持上一次的值(不回落成 0)
174
- ...(progress.usage ? { usage: progress.usage } : {}),
175
- ...(progress.contextWindow ? { contextWindow: progress.contextWindow } : {}),
176
- ...(progress.characters ? { characters: progress.characters } : {}),
177
- messages: thread.messages.map((message, index) => index === thread.messages.length - 1 ? {
178
- ...message, text, status, error: progress.error, contextTruncated: message.contextTruncated || progress.contextTruncated, lengthLimited: progress.lengthLimited,
179
- } : message),
180
- };
181
- }, progress.done);
182
- }
183
- private fail(sessionId: string, threadId: string, requestId: string, error: string): void {
184
- const thread = this.get(sessionId).threads.find((item) => item.id === threadId);
185
- this.apply(sessionId, threadId, requestId, { requestId, revision: 0, text: thread?.messages.at(-1)?.text ?? "", done: true, aborted: false, error });
186
- }
187
- private async response(response: Response): Promise<SideChatProgress> {
188
- const json = await response.json();
189
- if (!response.ok || !json.success) throw new ApiError(json.error || `side-chat request failed (${response.status})`, response.status);
190
- const data = json.data as SideChatProgress;
191
- if (!data || !isSideChatId(data.requestId) || !Number.isSafeInteger(data.revision) || typeof data.text !== "string" || typeof data.done !== "boolean" || (data.textOffset !== undefined && (!Number.isSafeInteger(data.textOffset) || data.textOffset < 0))) throw new Error("invalid side-chat response");
192
- return data;
193
- }
194
- private async run(sessionId: string, threadId: string, requestId: string, initial?: SideChatRequest): Promise<void> {
195
- if (this.requests.has(requestId)) return;
196
- const controller = new AbortController();
197
- this.requests.set(requestId, controller);
198
- const url = `/api/agent/${sessionId}/side-chat`;
199
- let revision = this.get(sessionId).threads.find((thread) => thread.id === threadId)?.request?.revision ?? -1;
200
- try {
201
- if (initial) {
202
- const progress = await this.response(await this.options.fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial), signal: controller.signal }));
203
- if (progress.requestId !== requestId) throw new Error("side-chat response belongs to a different request");
204
- if (controller.signal.aborted) return;
205
- this.apply(sessionId, threadId, requestId, progress);
206
- if (progress.done) return;
207
- revision = progress.revision;
208
- }
209
- let failures = 0;
210
- while (!controller.signal.aborted) {
211
- let progress: SideChatProgress;
212
- try {
213
- progress = await this.response(await this.options.fetch(`${url}?requestId=${encodeURIComponent(requestId)}&after=${revision}`, { cache: "no-store", signal: controller.signal }));
214
- if (progress.requestId !== requestId) throw new Error("side-chat response belongs to a different request");
215
- } catch (error) {
216
- if (controller.signal.aborted) return;
217
- if (error instanceof ApiError || ++failures >= 6) throw error;
218
- this.updateThread(sessionId, threadId, (thread) => ({ ...thread, connectionLost: true }));
219
- await pause(Math.min(500 * 2 ** (failures - 1), 5000), controller.signal);
220
- continue;
221
- }
222
- if (controller.signal.aborted) return;
223
- failures = 0;
224
- this.apply(sessionId, threadId, requestId, progress);
225
- if (progress.done) return;
226
- revision = progress.revision;
227
- }
228
- } catch (error) {
229
- if (!controller.signal.aborted) this.fail(sessionId, threadId, requestId, error instanceof Error ? error.message : "side-chat request failed");
230
- } finally {
231
- if (this.requests.get(requestId) === controller) this.requests.delete(requestId);
232
- }
233
- }
234
-
235
- stop(sessionId: string): void {
236
- const thread = this.get(sessionId).threads.find((item) => item.id === this.get(sessionId).currentThreadId);
237
- const requestId = thread?.request?.id;
238
- if (!thread || !requestId) return;
239
- this.requests.get(requestId)?.abort();
240
- this.apply(sessionId, thread.id, requestId, { requestId, revision: 0, text: thread.messages.at(-1)?.text ?? "", done: true, aborted: true });
241
- void this.options.fetch(`/api/agent/${sessionId}/side-chat?requestId=${encodeURIComponent(requestId)}`, { method: "DELETE" })
242
- .then(async (response) => { if (!response.ok) throw new Error((await response.json()).error || "failed to stop side-chat answer"); })
243
- .catch((error) => this.updateThread(sessionId, thread.id, (current) => ({ ...current, messages: current.messages.map((message) => message.id === thread.messages.at(-1)?.id ? { ...message, error: error instanceof Error ? error.message : "failed to stop side-chat answer" } : message) }), true));
244
- }
245
- }
246
-
247
- export const sideChatStore = new SideChatStore();
248
- if (typeof window !== "undefined") window.addEventListener("pagehide", sideChatStore.flush);
@@ -1,192 +0,0 @@
1
- /**
2
- * 流式文字输出偏好:显示样式 / 推进速度 / 打字光标。
3
- *
4
- * 存 localStorage(piweb.streamStyle / piweb.streamSpeed / piweb.streamCaret),
5
- * 模块级订阅:设置面板一改,正在流的那条回答在下一帧就换样式,不必重开会话;
6
- * 跨标签页由 storage 事件同步。
7
- *
8
- * 推进量是纯函数 nextRevealEnd(target, shownLength, settings):给定目标文本、已显示长度和偏好,
9
- * 算出这一帧该显示到哪。不变量是「显示内容始终是目标前缀、单调增长、最终追平」——
10
- * 见 tests/stream-settings.test.ts。
11
- */
12
-
13
- export type StreamStyle = "smooth" | "instant" | "typewriter" | "fade";
14
-
15
- export interface StreamSettings {
16
- /** 文字出现的方式 */
17
- style: StreamStyle;
18
- /** 推进速度档位 1..5(3 = 标准) */
19
- speed: number;
20
- /** 打字光标:流式期间在文字末尾闪烁 */
21
- caret: boolean;
22
- }
23
-
24
- /** 设置面板里的展示顺序(即时排在最后:它是「不要平滑」的退路) */
25
- export const STREAM_STYLES: readonly StreamStyle[] = ["smooth", "typewriter", "fade", "instant"];
26
- export const STREAM_SPEED_MIN = 1;
27
- export const STREAM_SPEED_MAX = 5;
28
- export const DEFAULT_STREAM_SETTINGS: StreamSettings = { style: "smooth", speed: 3, caret: false };
29
-
30
- const KEY = {
31
- style: "piweb.streamStyle",
32
- speed: "piweb.streamSpeed",
33
- caret: "piweb.streamCaret",
34
- } as const;
35
-
36
- /** 每帧间隔(约 60fps 起步):档位越高越密 */
37
- const STEP_MS = [24, 20, 16, 12, 8];
38
- /** 平滑 / 渐显:每帧推进「积压 / 该值」,值越大追赶越缓、手感越匀(突发不再狂奔) */
39
- const SMOOTH_DIVISOR = [14, 10, 7, 5, 3];
40
- /** 平滑 / 渐显:每帧至少推进的字符数,保证收尾时也能走完 */
41
- const SMOOTH_MIN_CHARS = [1, 1, 1, 2, 3];
42
- /** 平滑 / 渐显:每帧至多推进的字符数——大块突发被摊到更多帧里缓缓出现,不会一口吞掉 */
43
- const SMOOTH_MAX_CHARS = [140, 110, 90, 70, 50];
44
- /** 打字机:每帧固定敲出的字符数 */
45
- const TYPEWRITER_CHARS = [1, 2, 3, 5, 8];
46
- /** 打字机也要有底线:积压超过约 1.5 秒的量就跟着加速,不出现「回答完了字还在爬」 */
47
- const TYPEWRITER_CATCHUP_DIVISOR = 90;
48
-
49
- export function isStreamStyle(value: string | null | undefined): value is StreamStyle {
50
- return value === "smooth" || value === "instant" || value === "typewriter" || value === "fade";
51
- }
52
-
53
- export function clampStreamSpeed(value: number): number {
54
- if (!Number.isFinite(value)) return DEFAULT_STREAM_SETTINGS.speed;
55
- return Math.max(STREAM_SPEED_MIN, Math.min(STREAM_SPEED_MAX, Math.round(value)));
56
- }
57
-
58
- /** 这一档速度下每帧的间隔(毫秒) */
59
- export function streamStepMs(speed: number): number {
60
- return STEP_MS[clampStreamSpeed(speed) - 1];
61
- }
62
-
63
- function readStored(): StreamSettings {
64
- try {
65
- const style = localStorage.getItem(KEY.style);
66
- const speed = Number.parseInt(localStorage.getItem(KEY.speed) ?? "", 10);
67
- const caret = localStorage.getItem(KEY.caret);
68
- return {
69
- style: isStreamStyle(style) ? style : DEFAULT_STREAM_SETTINGS.style,
70
- speed: Number.isFinite(speed) ? clampStreamSpeed(speed) : DEFAULT_STREAM_SETTINGS.speed,
71
- caret: caret === null ? DEFAULT_STREAM_SETTINGS.caret : caret === "1" || caret === "true",
72
- };
73
- } catch {
74
- // 隐私模式 / localStorage 不可用:这一份内存偏好照样生效
75
- return DEFAULT_STREAM_SETTINGS;
76
- }
77
- }
78
-
79
- export function loadStreamSettings(): StreamSettings {
80
- if (typeof window === "undefined") return DEFAULT_STREAM_SETTINGS;
81
- return readStored();
82
- }
83
-
84
- function persist(settings: StreamSettings): void {
85
- if (typeof window === "undefined") return;
86
- try {
87
- localStorage.setItem(KEY.style, settings.style);
88
- localStorage.setItem(KEY.speed, String(settings.speed));
89
- localStorage.setItem(KEY.caret, settings.caret ? "1" : "0");
90
- } catch {
91
- /* 配额满 / 被禁用:内存里已经生效 */
92
- }
93
- }
94
-
95
- let cached: StreamSettings | null = null;
96
- const listeners = new Set<() => void>();
97
- let storageBound = false;
98
-
99
- /** useSyncExternalStore 的 getSnapshot:同一份引用,只有真改了才换(否则会无限重渲染) */
100
- export function getStreamSettings(): StreamSettings {
101
- if (!cached) cached = loadStreamSettings();
102
- return cached;
103
- }
104
-
105
- /** 服务端 / 水合首帧没有 localStorage,给默认值 */
106
- export function getServerStreamSettings(): StreamSettings {
107
- return DEFAULT_STREAM_SETTINGS;
108
- }
109
-
110
- function bindStorage(): void {
111
- if (storageBound || typeof window === "undefined") return;
112
- storageBound = true;
113
- window.addEventListener("storage", (event) => {
114
- if (event.key !== null && event.key !== KEY.style && event.key !== KEY.speed && event.key !== KEY.caret) return;
115
- cached = loadStreamSettings();
116
- for (const listener of listeners) listener();
117
- });
118
- }
119
-
120
- export function subscribeStreamSettings(listener: () => void): () => void {
121
- listeners.add(listener);
122
- bindStorage();
123
- return () => {
124
- listeners.delete(listener);
125
- };
126
- }
127
-
128
- /** 改一项并持久化,返回合并后的完整偏好;正在流的回答下帧就用新值 */
129
- export function setStreamSettings(patch: Partial<StreamSettings>): StreamSettings {
130
- const current = getStreamSettings();
131
- const next: StreamSettings = {
132
- style: isStreamStyle(patch.style) ? patch.style : current.style,
133
- speed: patch.speed === undefined ? current.speed : clampStreamSpeed(patch.speed),
134
- caret: patch.caret === undefined ? current.caret : patch.caret === true,
135
- };
136
- cached = next;
137
- persist(next);
138
- for (const listener of listeners) listener();
139
- return next;
140
- }
141
-
142
- /**
143
- * 不在 UTF-16 代理对中间切:切在中间会渲染成一个替换字符(半个 emoji)。
144
- * 端点越界时按文本长度收口。
145
- */
146
- export function safeSliceEnd(text: string, end: number): number {
147
- if (end <= 0) return 0;
148
- if (end >= text.length) return text.length;
149
- const high = text.charCodeAt(end - 1);
150
- const low = text.charCodeAt(end);
151
- if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) return Math.min(end + 1, text.length);
152
- return end;
153
- }
154
-
155
- /**
156
- * 渐显:只在整词处停 —— 从 end 往回找到最近的分隔符,切在它后面;
157
- * 这一段里没有分隔符(正在敲的词)就照常长,否则会停在原地走不完。
158
- */
159
- function snapToWordEnd(target: string, from: number, end: number): number {
160
- if (end >= target.length) return target.length;
161
- for (let i = end - 1; i >= from; i -= 1) {
162
- if (/\s/.test(target[i])) return i + 1;
163
- }
164
- return end;
165
- }
166
-
167
- /**
168
- * 这一帧显示到哪个下标(返回值 = 目标文本前缀长度)。
169
- * - instant:不排队,直接追平;
170
- * - smooth:自适应 —— 积压越多推得越快,但每帧有上限:大块突发被摊到多帧缓缓出现;
171
- * - typewriter:固定节奏逐字,但积压过大时跟着加速(上限约 1.5 秒的落后量);
172
- * - fade:在 smooth 的基础上按整词推进。
173
- */
174
- export function nextRevealEnd(target: string, shownLength: number, settings: StreamSettings): number {
175
- const total = target.length;
176
- if (settings.style === "instant") return total;
177
- const backlog = total - shownLength;
178
- if (backlog <= 0) return total;
179
- const level = clampStreamSpeed(settings.speed) - 1;
180
- let end: number;
181
- if (settings.style === "typewriter") {
182
- end = shownLength + Math.max(TYPEWRITER_CHARS[level], Math.ceil(backlog / TYPEWRITER_CATCHUP_DIVISOR));
183
- } else {
184
- end = shownLength + Math.min(
185
- SMOOTH_MAX_CHARS[level],
186
- Math.max(SMOOTH_MIN_CHARS[level], Math.ceil(backlog / SMOOTH_DIVISOR[level])),
187
- );
188
- }
189
- if (end > total) end = total;
190
- if (settings.style === "fade") end = snapToWordEnd(target, shownLength, end);
191
- return safeSliceEnd(target, end);
192
- }