@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
@@ -8,32 +8,25 @@
8
8
  * 而 Hero 页(还没开会话)根本用不到它。ChatWindow 用 next/dynamic 引用本模块,
9
9
  * 首屏不再下载这 472KB;AppShell 在空闲时预取,真正打开会话时通常已经就绪。
10
10
  *
11
- * 流式输出版(v7):
12
- * - 显示样式可配(见 lib/chat/stream-settings):平滑 / 打字机 / 渐显 / 即时,速度五档、可选打字光标;
13
- * 设置里改完下一帧生效,正在流的那条回答不用重开。
14
- * - 逐帧推进:把「已到达的完整文本」和「已显示的文本」分开,每帧只把显示进度往前推一小步,
15
- * 服务端 token 再来得快、再成串到达,界面也是匀速长出来的,不再一跳一跳。
11
+ * 流式输出版(v8):
12
+ * - 文本到达即整段显示:模型吐字速度由上游与网络决定,本地不再加一层追赶节奏(原先的
13
+ * 平滑 / 打字机 / 渐显 / 即时四样式与五档速度、打字光标已随设置项一并移除)。
16
14
  * - 增量切分 + 分块记忆化:「已稳定的完整块」按 key 记忆化,扫描进度也复用上一帧,
17
- * 每帧代价只与「新增的那几个字」成正比(旧版每帧重扫全文才是长回答的卡顿来源)。
15
+ * 每次更新代价只与「新增的那几个字」成正比(旧版每帧重扫全文才是长回答的卡顿来源)。
18
16
  * - 代码围栏内的 tail 用纯文本渲染,等闭合后再交给 highlight.js 高亮一次,
19
17
  * 避免流式代码块每帧重新高亮。
20
- * - 贴底听对话区的(见 chat/chat-scroll);回答结束后先把还没放出来的字放完,再换成整段渲染。
18
+ * - 贴底听对话区的(见 chat/chat-scroll);回答结束后换成整段渲染。
21
19
  */
22
- import { memo, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
20
+ import { memo, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
23
21
  import ReactMarkdown from "react-markdown";
24
22
  import rehypeHighlight from "rehype-highlight";
25
23
  import remarkGfm from "remark-gfm";
26
24
  import { ChatScrollContext } from "@/components/chat/chat-scroll";
25
+ import { PreviewImage } from "@/components/PreviewImage";
26
+ import { useMarkdownActions } from "@/components/chat/markdown-actions";
27
+ import { looksLikeFilePath } from "@/lib/chat/file-path";
27
28
  import { IconCheckOutline14, IconCopyOutline16 } from "@/components/icons";
28
29
  import { copyText } from "@/lib/clipboard";
29
- import {
30
- getServerStreamSettings,
31
- getStreamSettings,
32
- nextRevealEnd,
33
- streamStepMs,
34
- subscribeStreamSettings,
35
- type StreamSettings,
36
- } from "@/lib/chat/stream-settings";
37
30
  import { useI18n } from "@/i18n";
38
31
 
39
32
  type MarkdownTextNode = { value?: unknown; children?: MarkdownTextNode[] };
@@ -90,7 +83,7 @@ function SafeImage({ src, alt, title }: { src?: unknown; alt?: string; title?: s
90
83
  const { t } = useI18n();
91
84
  const source = typeof src === "string" ? src : "";
92
85
  if (!source) return null;
93
- if (allowed || isInlineImage(source)) return <img src={source} alt={alt ?? ""} title={title} />;
86
+ if (allowed || isInlineImage(source)) return <PreviewImage image={source} alt={alt ?? ""} title={title} />;
94
87
  let host = source;
95
88
  try {
96
89
  host = new URL(source).host || source;
@@ -109,13 +102,39 @@ function SafeImage({ src, alt, title }: { src?: unknown; alt?: string; title?: s
109
102
  * 流式分块时,多个块共用同一个 .md 容器(外边距规则因此照旧生效),
110
103
  * 每块各自记忆化:只要 text 没变就不会重新解析。
111
104
  */
105
+ /**
106
+ * 链接:外部链接加一个 ↗ 标记,让「点了会离开本站」一眼可见。
107
+ * 站内链接(同源)不加标记,避免到处都是符号。
108
+ */
109
+ function MarkdownLink({ href, children, ...props }: { href?: string; children?: React.ReactNode } & Record<string, unknown>) {
110
+ let external = false;
111
+ if (typeof href === "string" && href) {
112
+ if (/^https?:\/\//i.test(href)) {
113
+ external = true;
114
+ try {
115
+ external = typeof window === "undefined" || new URL(href).origin !== window.location.origin;
116
+ } catch {
117
+ /* 解析不了就当外链 */
118
+ }
119
+ }
120
+ }
121
+ if (!external) return <a href={href} {...props}>{children}</a>;
122
+ return (
123
+ <a href={href} target="_blank" rel="noopener noreferrer" className="md-external" {...props}>
124
+ {children}
125
+ <span className="md-external-mark" aria-hidden="true">↗</span>
126
+ </a>
127
+ );
128
+ }
129
+
112
130
  const MarkdownContent = memo(function MarkdownContent({ text }: { text: string }) {
131
+ const actions = useMarkdownActions();
113
132
  return (
114
133
  <ReactMarkdown
115
134
  remarkPlugins={[remarkGfm]}
116
135
  rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
117
136
  components={{
118
- a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
137
+ a: ({ node: _node, ...props }) => <MarkdownLink {...props} />,
119
138
  img: ({ src, alt, title }) => <SafeImage src={src} alt={alt} title={title} />,
120
139
  pre: ({ node, children, ...props }) => (
121
140
  <div className="md-code-block">
@@ -123,6 +142,22 @@ const MarkdownContent = memo(function MarkdownContent({ text }: { text: string }
123
142
  <pre {...props}>{children}</pre>
124
143
  </div>
125
144
  ),
145
+ // 行内代码里常是文件路径:能认出来且能打开时,变成可点(只变色,不加下划线)
146
+ code: ({ node: _node, className, children }) => {
147
+ const raw = typeof children === "string" ? children : Array.isArray(children) ? children.join("") : "";
148
+ const path = raw.trim();
149
+ // 代码块里的 code 不参与路径识别。只看 className 不够:不写语言的围栏和缩进代码块也没有 className,
150
+ // 只有一行路径时整个代码块会变成按钮。代码块的文本总以换行结尾,行内代码不会
151
+ const block = Boolean(className) || raw.endsWith("\n");
152
+ if (actions.openFile && !block && looksLikeFilePath(path)) {
153
+ return (
154
+ <button type="button" className="md-file-path" title={path} onClick={() => actions.openFile!(path)}>
155
+ {children}
156
+ </button>
157
+ );
158
+ }
159
+ return <code className={className}>{children}</code>;
160
+ },
126
161
  }}
127
162
  >
128
163
  {text}
@@ -298,103 +333,43 @@ export function splitStreamingMarkdown(text: string): StreamingSplit {
298
333
  return createStreamingSplitter().update(text);
299
334
  }
300
335
 
301
- /** 流式偏好:设置面板一改,这里立刻换样式(正在流的那条回答不用重开) */
302
- function useStreamSettings(): StreamSettings {
303
- return useSyncExternalStore(subscribeStreamSettings, getStreamSettings, getServerStreamSettings);
304
- }
305
-
306
336
  interface StreamView extends StreamingSplit {
307
337
  /** 当前显示出来的部分(始终是目标文本的前缀) */
308
338
  shown: string;
309
339
  }
310
340
 
311
341
  /**
312
- * live:回答还在流。结束后(live 变 false)组件不急着换掉——先按原节奏把还没放出来的字放完,
313
- * 追平后再渲染成整段 Markdown(和历史消息同一种渲染,脚注、引用式链接这类跨块语法也就对了)。
314
- * 一结束就整段替换的话,打字机这类慢样式积压的一两百字会一口吐出来。
342
+ * live:回答还在流。结束后(live 变 false)换成整段 Markdown 渲染(和历史消息同一种渲染,
343
+ * 脚注、引用式链接这类跨块语法也就对了)。
344
+ *
345
+ * 文本到达即整段显示,不做逐帧平滑:模型吐字的速度由上游与网络决定,本地再加一层追赶节奏
346
+ * 只会让显示落后于实际到达的内容。切分器仍然保留——它让「已稳定的完整块」按 key 记忆化,
347
+ * 每次新增只重解析变化的那一块,而不是整篇重扫。
315
348
  */
316
349
  export const StreamingMarkdownBlock = memo(function StreamingMarkdownBlock({ text, live = true }: { text: string; live?: boolean }) {
317
- const settings = useStreamSettings();
318
350
  const scroll = useContext(ChatScrollContext);
319
351
  const splitterRef = useRef<{ update: (text: string) => StreamingSplit } | null>(null);
320
352
  if (!splitterRef.current) splitterRef.current = createStreamingSplitter();
321
353
  const [view, setView] = useState<StreamView>(() => ({ shown: text, ...splitterRef.current!.update(text) }));
322
- const viewRef = useRef(view);
323
- const targetRef = useRef(text);
324
- const settingsRef = useRef(settings);
325
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
326
-
327
- const commit = (shown: string) => {
328
- const next: StreamView = { shown, ...splitterRef.current!.update(shown) };
329
- viewRef.current = next;
330
- setView(next);
331
- };
332
-
333
- const stopTimer = () => {
334
- if (timerRef.current !== null) clearTimeout(timerRef.current);
335
- timerRef.current = null;
336
- };
337
-
338
- /** 一帧:只把显示进度往前推一点。用 setTimeout 而非 rAF:后台标签页 / 测试环境同样确定。 */
339
- const step = () => {
340
- timerRef.current = null;
341
- const target = targetRef.current;
342
- const shown = viewRef.current.shown;
343
- if (shown === target) return; // 追平,收工(下次文本变化再启动)
344
- if (!target.startsWith(shown)) {
345
- commit(target); // 目标被整体替换(编辑重发):直接跟上,不再逐帧
346
- return;
347
- }
348
- const current = settingsRef.current;
349
- commit(target.slice(0, nextRevealEnd(target, shown.length, current)));
350
- timerRef.current = setTimeout(step, streamStepMs(current.speed));
351
- };
352
354
 
353
- /** 起节拍(已经在跑就不动);「即时」不排队,直接追平 */
354
- const start = () => {
355
- if (timerRef.current !== null) return;
356
- const current = settingsRef.current;
357
- if (current.style === "instant") {
358
- if (viewRef.current.shown !== targetRef.current) commit(targetRef.current);
359
- return;
360
- }
361
- timerRef.current = setTimeout(step, streamStepMs(current.speed));
362
- };
363
-
364
- // 目标变化:更新目标,节拍没在跑就起一个。已有节拍时不重置计时器(防抖会让正文冻住)
355
+ // 目标变化:整段跟上,不再排队
365
356
  useEffect(() => {
366
- targetRef.current = text;
367
- if (text !== viewRef.current.shown) start();
357
+ const next: StreamView = { shown: text, ...splitterRef.current!.update(text) };
358
+ setView(next);
368
359
  }, [text]);
369
360
 
370
- // 偏好变化即时生效:切成「即时」就停表追平,切回别的样式就继续起节拍
371
- useEffect(() => {
372
- settingsRef.current = settings;
373
- if (viewRef.current.shown === targetRef.current) return;
374
- if (settings.style === "instant") {
375
- stopTimer();
376
- commit(targetRef.current);
377
- return;
378
- }
379
- start();
380
- }, [settings]);
381
-
382
- useEffect(() => () => stopTimer(), []);
383
-
384
361
  const settled = !live && view.shown === text;
385
362
 
386
- // 逐帧推进会让内容往下长:提交后(绘制前)跟着贴底,界面不再跳动。
387
- // 贴不贴听对话区的(往上翻过、大纲正在跳转都会松开),这里不另量距离、不另设阈值;
388
- // 追平那一帧和换成整段渲染那一帧同样会变高,也要贴。
363
+ // 内容变高会让正文往下长:提交后(绘制前)跟着贴底,界面不再跳动。
364
+ // 贴不贴听对话区的(往上翻过、大纲正在跳转都会松开),这里不另量距离、不另设阈值。
389
365
  useLayoutEffect(() => {
390
366
  if (scroll?.shouldPin()) scroll.pin();
391
367
  }, [view, settled, scroll]);
392
368
 
393
369
  if (settled) return <MarkdownBlock text={text} />;
394
370
 
395
- const { shown, blocks, tail, tailInFence } = view;
396
- // 尾巴常驻同一个 key 的容器:块升级时尾巴原地更新而不是整棵重挂——
397
- // 重挂会让渐显动画在「已经显示过的内容」上重播一遍,正是输出时闪烁几下的来源。
371
+ const { blocks, tail, tailInFence } = view;
372
+ // 尾巴常驻同一个 key 的容器:块升级时尾巴原地更新而不是整棵重挂。
398
373
  // 围栏未闭合时用与最终渲染相同的结构(md-code-block > pre > code,含复制按钮,同样不折行),
399
374
  // 闭合升级只有语法着色的差别,没有布局跳变;开头的 ``` 行不进代码。
400
375
  let tailNode: React.ReactNode;
@@ -414,11 +389,7 @@ export const StreamingMarkdownBlock = memo(function StreamingMarkdownBlock({ tex
414
389
  }
415
390
 
416
391
  return (
417
- <div
418
- className={settings.caret ? "md pw-stream pw-stream-caret" : "md pw-stream"}
419
- data-stream-style={settings.style}
420
- data-stream-pending={shown === text ? undefined : ""}
421
- >
392
+ <div className="md pw-stream">
422
393
  {blocks.map((block, i) => <MarkdownContent key={i} text={block} />)}
423
394
  {tail
424
395
  ? <div className="pw-stream-tail" key="tail">{tailNode}</div>
@@ -158,13 +158,11 @@ export function ModelSelector({
158
158
  <div className="py-1">
159
159
  <button
160
160
  type="button"
161
- className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors"
161
+ className="isx-menu-item flex w-full items-center gap-2 px-3 py-2 text-left"
162
162
  style={{ color: "var(--dsw-label-secondary)" }}
163
- onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
164
- onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
165
163
  onClick={() => setPane("model")}
166
164
  >
167
- <IconChevronLeft14 size={14} /> {t.thinking}
165
+ <IconChevronLeft14 size={14} /> <span className="isx-menu-shift">{t.thinking}</span>
168
166
  </button>
169
167
  {thinkingLevels.length === 0 && (
170
168
  <div className="px-4 py-3" style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>
@@ -179,21 +177,16 @@ export function ModelSelector({
179
177
  type="button"
180
178
  role="menuitemradio"
181
179
  aria-checked={selected}
182
- className="flex w-full items-center justify-between px-4 py-2 text-left transition-colors"
183
- onMouseEnter={(e) => {
184
- if (!selected) e.currentTarget.style.background = "var(--dsw-hover)";
185
- }}
186
- onMouseLeave={(e) => {
187
- if (!selected) e.currentTarget.style.background = "transparent";
188
- }}
180
+ data-active={selected ? "true" : undefined}
181
+ className="isx-menu-item flex w-full items-center justify-between px-4 py-2 text-left"
189
182
  onClick={() => {
190
183
  onSelectLevel(lv);
191
184
  setOpen(false);
192
185
  }}
193
186
  >
194
- <span style={{ fontSize: 14, color: "var(--dsw-label-primary)" }}>{lv}</span>
187
+ <span className="isx-menu-shift" style={{ fontSize: 14 }}>{lv}</span>
195
188
  {selected && (
196
- <span style={{ display: "inline-flex", color: "var(--dsw-label-primary)" }}>
189
+ <span className="pr-2" style={{ display: "inline-flex" }}>
197
190
  <IconCheckOutline14 size={14} />
198
191
  </span>
199
192
  )}
@@ -205,13 +198,23 @@ export function ModelSelector({
205
198
 
206
199
  {pane === "model" && (
207
200
  <div className="flex min-h-0 flex-col">
208
- <div ref={listRef} className="max-h-[340px] overflow-y-auto py-1">
201
+ {/* overflow-y-auto 会把 overflow-x 一并提升成 auto:Iconsax 悬停位移(.isx-menu-shift
202
+ translateX(4px))加上全局 10px 高的横向滚动条,会在列表底部露出一条横杠。
203
+ 显式锁掉横向,去掉那条横条。 */}
204
+ <div ref={listRef} className="max-h-[340px] overflow-x-hidden overflow-y-auto py-1">
209
205
  {defaultOption && (
210
- <button type="button" role="menuitemradio" aria-checked={defaultOption.selected} data-selected={defaultOption.selected}
211
- className="flex min-h-10 w-full items-center justify-between gap-3 rounded-[10px] px-3 py-2 text-left"
212
- style={{ fontSize: 13, background: defaultOption.selected ? "var(--dsw-active)" : undefined }}
213
- onClick={() => { defaultOption.onSelect(); setOpen(false); }}>
214
- {defaultOption.label}{defaultOption.selected && <IconCheckOutline14 size={14} />}
206
+ <button
207
+ type="button"
208
+ role="menuitemradio"
209
+ aria-checked={defaultOption.selected}
210
+ data-selected={defaultOption.selected}
211
+ data-active={defaultOption.selected ? "true" : undefined}
212
+ className="isx-menu-item flex min-h-10 w-full items-center justify-between gap-3 rounded-[12px] px-3 py-2 text-left"
213
+ style={{ fontSize: 13 }}
214
+ onClick={() => { defaultOption.onSelect(); setOpen(false); }}
215
+ >
216
+ <span className="isx-menu-shift">{defaultOption.label}</span>
217
+ {defaultOption.selected && <span className="pr-2" style={{ display: "inline-flex" }}><IconCheckOutline14 size={14} /></span>}
215
218
  </button>
216
219
  )}
217
220
  {groups.length === 0 && (
@@ -223,8 +226,9 @@ export function ModelSelector({
223
226
  <section key={g.provider} role="group" aria-label={g.label}>
224
227
  {/* 提供商名不吸顶:吸顶会在滚动时钉住并被下一组顶出成半截文字("卡住"观感) */}
225
228
  <div
226
- className="px-3 pb-1 pt-2"
229
+ className="truncate px-3 pb-1 pt-2"
227
230
  style={{ fontSize: 12, fontWeight: 500, color: "var(--dsw-label-caption)" }}
231
+ title={g.label}
228
232
  >
229
233
  {g.label}
230
234
  </div>
@@ -235,26 +239,21 @@ export function ModelSelector({
235
239
  key={`${m.provider}/${m.id}`}
236
240
  type="button"
237
241
  role="menuitemradio"
238
- aria-checked={selected}
239
- data-selected={selected}
240
- className="flex min-h-10 w-full items-center justify-between gap-3 rounded-[10px] px-3 py-2 text-left transition-colors"
241
- style={{ background: selected ? "var(--dsw-active)" : "transparent" }}
242
- onMouseEnter={(e) => {
243
- if (!selected) e.currentTarget.style.background = "var(--dsw-hover)";
244
- }}
245
- onMouseLeave={(e) => {
246
- if (!selected) e.currentTarget.style.background = "transparent";
247
- }}
242
+ aria-checked={selected}
243
+ data-selected={selected}
244
+ data-active={selected ? "true" : undefined}
245
+ className="isx-menu-item flex min-h-10 w-full items-center justify-between gap-3 rounded-[12px] px-3 py-2 text-left"
246
+ title={m.name || m.id}
248
247
  onClick={() => {
249
248
  onSelectModel(m.provider, m.id);
250
249
  setOpen(false);
251
250
  }}
252
251
  >
253
- <span className="min-w-0 flex-1 truncate" style={{ fontSize: 14, fontWeight: 500, color: "var(--dsw-label-primary)" }}>
252
+ <span className="isx-menu-shift min-w-0 flex-1 truncate" style={{ fontSize: 14, fontWeight: 500 }}>
254
253
  {m.name}
255
254
  </span>
256
255
  {selected && (
257
- <span style={{ display: "inline-flex", color: "var(--dsw-label-primary)", flex: "none" }}>
256
+ <span className="pr-2" style={{ display: "inline-flex", flex: "none" }}>
258
257
  <IconCheckOutline14 size={16} />
259
258
  </span>
260
259
  )}
@@ -269,18 +268,16 @@ export function ModelSelector({
269
268
  {hasModel && !modelOnly && (thinkingLevels.length > 1 ? (
270
269
  <button
271
270
  type="button"
272
- className="mt-1 flex w-full items-center justify-between gap-3 border-t px-3 pb-1 pt-2 text-left transition-colors"
273
- style={{ borderColor: "var(--dsw-border-l1)" }}
274
- onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
275
- onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
271
+ className="isx-menu-item mt-1 flex w-full items-center justify-between gap-3 border-t px-3 pb-1.5 pt-2 text-left"
272
+ style={{ borderColor: "var(--dsw-border-l2)" }}
276
273
  onClick={() => setPane("effort")}
277
274
  >
278
- <span style={{ fontSize: 13.5, color: "var(--dsw-label-secondary)" }}>{t.thinking}</span>
279
- <span className="flex min-w-0 items-center gap-1">
275
+ <span className="isx-menu-shift" style={{ fontSize: 13.5 }}>{t.thinking}</span>
276
+ <span className="isx-menu-sub flex min-w-0 items-center gap-1 pr-2">
280
277
  <span className="truncate" style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>
281
278
  {thinkingLevel ?? t.thinkingOff}
282
279
  </span>
283
- <IconChevronRight14 size={14} style={{ flex: "none", color: "var(--dsw-label-tertiary)" }} />
280
+ <IconChevronRight14 size={14} style={{ flex: "none" }} />
284
281
  </span>
285
282
  </button>
286
283
  ) : (
@@ -1,10 +1,10 @@
1
1
  /**
2
- * 官方 π mark(取自 pi.dev favicon,currentColor 化)+ dsh BrandWordmark 式品牌簇。
2
+ * Iconsax 风格 PiMark(微曲圆角矢量 π 徽标)+ 现代品牌簇(PiWeb)
3
3
  */
4
4
  "use client";
5
5
 
6
6
  export function PiMark({
7
- size = 18,
7
+ size = 20,
8
8
  className,
9
9
  style,
10
10
  }: {
@@ -16,64 +16,99 @@ export function PiMark({
16
16
  <svg
17
17
  width={size}
18
18
  height={size}
19
- viewBox="165.29 165.29 469.43 469.43"
19
+ viewBox="0 0 24 24"
20
+ fill="none"
20
21
  aria-hidden
21
22
  className={className}
22
- style={{ color: "var(--dsw-label-primary)", display: "block", flex: "none", ...style }}
23
+ style={{ display: "block", flex: "none", ...style }}
23
24
  >
25
+ <defs>
26
+ <linearGradient id="isx-pi-grad" x1="0%" y1="0%" x2="100%" y2="100%">
27
+ <stop offset="0%" stopColor="#02defc" />
28
+ <stop offset="50%" stopColor="#7ffe00" />
29
+ <stop offset="100%" stopColor="#fc0197" />
30
+ </linearGradient>
31
+ </defs>
32
+ {/* 现代圆角 π 字符造型:水平顶梁与双流线支腿 */}
24
33
  <path
34
+ d="M3.75 6.75C3.75 5.92 4.42 5.25 5.25 5.25H18.75C19.58 5.25 20.25 5.92 20.25 6.75C20.25 7.58 19.58 8.25 18.75 8.25H5.25C4.42 8.25 3.75 7.58 3.75 6.75Z"
35
+ fill="currentColor"
36
+ />
37
+ <path
38
+ d="M8.25 7.5V15.5C8.25 17.16 6.91 18.5 5.25 18.5C4.42 18.5 3.75 17.83 3.75 17C3.75 16.17 4.42 15.5 5.25 15.5C5.66 15.5 6 15.16 6 14.75V7.5H8.25Z"
39
+ fill="currentColor"
40
+ />
41
+ <path
42
+ d="M15 7.5V14.5C15 15.88 16.12 17 17.5 17C18.33 17 19 17.67 19 18.5C19 19.33 18.33 20 17.5 20C14.46 20 12.75 17.54 12.75 14.5V7.5H15Z"
25
43
  fill="currentColor"
26
- fillRule="evenodd"
27
- d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
28
44
  />
29
- <path fill="currentColor" d="M517.36 400 H634.72 V634.72 H517.36 Z" />
30
45
  </svg>
31
46
  );
32
47
  }
33
48
 
34
- /** 品牌簇:对齐 dsh 品牌模板 [Logo] [Name] [BADGE] */
49
+ /** 品牌簇:Iconsax 风格 Squircle 徽标 + 渐变品牌字 */
35
50
  export function BrandPi({ compact = false }: { compact?: boolean }) {
36
51
  if (compact) {
37
- return <PiMark size={18} />;
52
+ return (
53
+ <div
54
+ className="flex items-center justify-center rounded-[10px] border border-white/10 transition-transform duration-200 hover:scale-105"
55
+ style={{
56
+ width: 32,
57
+ height: 32,
58
+ background: "var(--dsw-bg-elevated)",
59
+ boxShadow: "0 2px 8px rgba(0,0,0,0.18)",
60
+ }}
61
+ >
62
+ <PiMark size={18} style={{ color: "var(--dsw-label-primary)" }} />
63
+ </div>
64
+ );
38
65
  }
39
66
  return (
40
67
  <div
41
- className="inline-flex items-center select-none"
42
- style={{
43
- color: "var(--dsw-label-primary)",
44
- gap: 6,
45
- }}
68
+ className="group inline-flex cursor-pointer select-none items-center gap-2.5 transition-transform duration-200"
69
+ style={{ color: "var(--dsw-label-primary)" }}
46
70
  >
47
- <PiMark size={17} />
48
- <span
49
- className="inline-flex items-center"
50
- style={{
51
- fontSize: 16.5,
52
- fontWeight: 600,
53
- letterSpacing: "-0.02em",
54
- color: "var(--dsw-label-primary)",
55
- lineHeight: 1,
56
- }}
57
- >
58
- pi
59
- </span>
60
- <span
61
- className="inline-flex items-center justify-center font-bold"
71
+ {/* Iconsax 风格圆角微光徽标盒 */}
72
+ <div
73
+ className="relative flex items-center justify-center rounded-[9px] border transition-all duration-300 group-hover:scale-105"
62
74
  style={{
63
- fontSize: 9.5,
64
- letterSpacing: "0.06em",
65
- height: 15,
66
- paddingLeft: 4.5,
67
- paddingRight: 4.5,
68
- borderRadius: 4,
69
- background: "var(--dsw-label-primary)",
70
- color: "var(--dsw-bg-base)",
71
- lineHeight: 1,
72
- textTransform: "uppercase",
75
+ width: 28,
76
+ height: 28,
77
+ background: "var(--dsw-bg-elevated)",
78
+ borderColor: "var(--dsw-border-l3)",
79
+ boxShadow: "0 2px 10px rgba(0,0,0,0.12)",
73
80
  }}
74
81
  >
75
- HARNESS
76
- </span>
82
+ <PiMark size={16} style={{ color: "var(--dsw-label-primary)" }} />
83
+ </div>
84
+
85
+ {/* 品牌名称:Pi (高反差主色) + Web (Iconsax 彩虹渐变字) */}
86
+ <div className="flex items-baseline gap-0.5">
87
+ <span
88
+ style={{
89
+ fontSize: 16.5,
90
+ fontWeight: 700,
91
+ letterSpacing: "-0.03em",
92
+ color: "var(--dsw-label-primary)",
93
+ lineHeight: 1,
94
+ }}
95
+ >
96
+ Pi
97
+ </span>
98
+ <span
99
+ style={{
100
+ fontSize: 16.5,
101
+ fontWeight: 600,
102
+ letterSpacing: "-0.02em",
103
+ background: "var(--isx-line-gradient)",
104
+ WebkitBackgroundClip: "text",
105
+ WebkitTextFillColor: "transparent",
106
+ lineHeight: 1,
107
+ }}
108
+ >
109
+ Web
110
+ </span>
111
+ </div>
77
112
  </div>
78
113
  );
79
114
  }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import { useState, type CSSProperties } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { ImageViewer, imageSource, type PreviewImageSource } from "@/components/ImageViewer";
6
+ import { useI18n } from "@/i18n";
7
+
8
+ /** 聊天/正文里的图片点开只读图集:不改动、不删除已经落盘的消息内容 */
9
+ export function PreviewImage({ image, images = [image], index = 0, alt = "", title, className, style }: {
10
+ image: PreviewImageSource;
11
+ images?: PreviewImageSource[];
12
+ index?: number;
13
+ alt?: string;
14
+ title?: string;
15
+ className?: string;
16
+ style?: CSSProperties;
17
+ }) {
18
+ const { t } = useI18n();
19
+ const [preview, setPreview] = useState<number | null>(null);
20
+ return <>
21
+ <button type="button" className="pw-chat-image-preview" title={title ?? t.imagePreview} aria-label={alt ? `${t.imagePreview}: ${alt}` : t.imagePreview} onClick={(event) => {
22
+ event.preventDefault();
23
+ event.stopPropagation();
24
+ setPreview(index);
25
+ }}>
26
+ <img src={imageSource(image)} alt={alt} title={title} className={className} style={style} draggable={false} />
27
+ </button>
28
+ {preview !== null && images[preview] && createPortal(
29
+ <ImageViewer images={images} index={preview} onIndex={setPreview} onClose={() => setPreview(null)} />,
30
+ document.body,
31
+ )}
32
+ </>;
33
+ }
@@ -71,7 +71,9 @@ export function GrowthConsentDialog({ request, onAnswer }: { request: GrowthCons
71
71
  export function roundTitle(round: GrowthEntry, t: Record<string, string>): string {
72
72
  if (round.kind !== "round") return t.growthKindUser;
73
73
  const label = t.growthRoundLabel.replace("{n}", String(round.n ?? ""));
74
- return round.title ? `${label} · ${round.title}` : label;
74
+ // 修好之前记下的轮次,标题是技能展开的首行 `<skill name="x" location="…">`:显示成 /skill:x
75
+ const title = round.title?.replace(/^<skill\s[^>]*?\bname="([^"]+)"[^>]*>$/, "/skill:$1");
76
+ return title ? `${label} · ${title}` : label;
75
77
  }
76
78
 
77
79
  /** 柱的颜色:你的修改为灰;pi 的一轮按主要改动类型(删除为主红、新增为主绿、否则黄) */
@@ -413,6 +415,7 @@ export function ProjectPanel({
413
415
  data-active={selected?.commit === round.commit || undefined}
414
416
  style={{ height: h, background: roundColor(round) }}
415
417
  title={`${roundTitle(round, tt)} · ${fmtTime(round.ts)} · ${t.growthRoundFiles.replace("{n}", String(round.stats.added + round.stats.modified + round.stats.deleted + round.stats.renamed))} +${round.stats.add} −${round.stats.del}`}
418
+ aria-label={`${roundTitle(round, tt)} · ${t.growthRoundFiles.replace("{n}", String(round.stats.added + round.stats.modified + round.stats.deleted + round.stats.renamed))} +${round.stats.add} −${round.stats.del}`}
416
419
  onClick={() => growth.select(round.commit)}
417
420
  />
418
421
  );
@@ -421,15 +424,16 @@ export function ProjectPanel({
421
424
  </div>
422
425
  )}
423
426
  <div className="flex items-center gap-1 px-2 pb-2 pt-1" style={{ fontSize: 11.5 }}>
424
- <button className="icon-btn" style={{ width: 20, height: 20 }} title={t.growthPrev} onClick={growth.prev} disabled={selectedIdx <= 0}>
427
+ <button className="icon-btn" style={{ width: 20, height: 20 }} title={t.growthPrev} aria-label={t.growthPrev} onClick={growth.prev} disabled={selectedIdx <= 0}>
425
428
  <IconChevronLeft14 size={12} />
426
429
  </button>
427
- <button className="icon-btn" style={{ width: 20, height: 20 }} title={t.growthNext} onClick={growth.next} disabled={selectedIdx < 0 || selectedIdx >= rounds.length - 1}>
430
+ <button className="icon-btn" style={{ width: 20, height: 20 }} title={t.growthNext} aria-label={t.growthNext} onClick={growth.next} disabled={selectedIdx < 0 || selectedIdx >= rounds.length - 1}>
428
431
  <IconChevronRight14 size={12} />
429
432
  </button>
430
433
  {/* 计数可点开轮数列表:单击直接弹列表(不做双击变输入框,避免 250ms 单双击
431
434
  判定拖慢弹出);「输入轮号」是列表里一个固定入口,选中后当场变数字输入框 */}
432
- <div className="relative min-w-0 flex-1">
435
+ {/* ref 包住按钮和列表:按在计数按钮上不算「点在外面」,否则 mousedown 先关、click 又打开,再点一下关不掉 */}
436
+ <div className="relative min-w-0 flex-1" ref={roundPickerRef}>
433
437
  <button
434
438
  type="button"
435
439
  className="w-full truncate rounded px-1 text-left"
@@ -448,7 +452,6 @@ export function ProjectPanel({
448
452
  </button>
449
453
  {roundPickerOpen && (
450
454
  <div
451
- ref={roundPickerRef}
452
455
  className="popover absolute bottom-7 left-0 z-50 flex w-[268px] max-w-[calc(100vw-24px)] flex-col"
453
456
  role="listbox"
454
457
  aria-label={t.growthPickRound}