@rexvane/piweb 0.3.1

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 (535) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/assets/showcase.png +0 -0
  4. package/bin/pi.js +50 -0
  5. package/bin/piweb.js +12 -0
  6. package/next.config.ts +21 -0
  7. package/package.json +88 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/file-icons/3d.svg +1 -0
  10. package/public/file-icons/LICENSE +8 -0
  11. package/public/file-icons/adobe-illustrator.svg +1 -0
  12. package/public/file-icons/adobe-photoshop.svg +1 -0
  13. package/public/file-icons/agent.svg +1 -0
  14. package/public/file-icons/asciidoc.svg +1 -0
  15. package/public/file-icons/assembly.svg +1 -0
  16. package/public/file-icons/astro.svg +1 -0
  17. package/public/file-icons/audio.svg +1 -0
  18. package/public/file-icons/authors.svg +1 -0
  19. package/public/file-icons/babel.svg +1 -0
  20. package/public/file-icons/bibliography.svg +1 -0
  21. package/public/file-icons/bicep.svg +1 -0
  22. package/public/file-icons/blender.svg +1 -0
  23. package/public/file-icons/bun.svg +1 -0
  24. package/public/file-icons/c.svg +1 -0
  25. package/public/file-icons/certificate.svg +1 -0
  26. package/public/file-icons/changelog.svg +1 -0
  27. package/public/file-icons/clojure.svg +1 -0
  28. package/public/file-icons/cmake.svg +1 -0
  29. package/public/file-icons/codeowners.svg +1 -0
  30. package/public/file-icons/commitlint.svg +1 -0
  31. package/public/file-icons/conduct.svg +1 -0
  32. package/public/file-icons/console.svg +1 -0
  33. package/public/file-icons/contributing.svg +1 -0
  34. package/public/file-icons/cpp.svg +1 -0
  35. package/public/file-icons/csharp.svg +1 -0
  36. package/public/file-icons/css.svg +1 -0
  37. package/public/file-icons/cursor.svg +1 -0
  38. package/public/file-icons/dart.svg +1 -0
  39. package/public/file-icons/database.svg +1 -0
  40. package/public/file-icons/deno.svg +1 -0
  41. package/public/file-icons/diff.svg +1 -0
  42. package/public/file-icons/docker.svg +1 -0
  43. package/public/file-icons/document.svg +1 -0
  44. package/public/file-icons/editorconfig.svg +1 -0
  45. package/public/file-icons/ejs.svg +1 -0
  46. package/public/file-icons/elixir.svg +1 -0
  47. package/public/file-icons/elm.svg +1 -0
  48. package/public/file-icons/email.svg +1 -0
  49. package/public/file-icons/erlang.svg +1 -0
  50. package/public/file-icons/eslint.svg +1 -0
  51. package/public/file-icons/favicon.svg +1 -0
  52. package/public/file-icons/file.svg +1 -0
  53. package/public/file-icons/folder-admin-open.svg +1 -0
  54. package/public/file-icons/folder-admin.svg +1 -0
  55. package/public/file-icons/folder-android-open.svg +1 -0
  56. package/public/file-icons/folder-android.svg +1 -0
  57. package/public/file-icons/folder-api-open.svg +1 -0
  58. package/public/file-icons/folder-api.svg +1 -0
  59. package/public/file-icons/folder-app-open.svg +1 -0
  60. package/public/file-icons/folder-app.svg +1 -0
  61. package/public/file-icons/folder-archive-open.svg +1 -0
  62. package/public/file-icons/folder-archive.svg +1 -0
  63. package/public/file-icons/folder-atom-open.svg +1 -0
  64. package/public/file-icons/folder-atom.svg +1 -0
  65. package/public/file-icons/folder-audio-open.svg +1 -0
  66. package/public/file-icons/folder-audio.svg +1 -0
  67. package/public/file-icons/folder-aws-open.svg +1 -0
  68. package/public/file-icons/folder-aws.svg +1 -0
  69. package/public/file-icons/folder-backup-open.svg +1 -0
  70. package/public/file-icons/folder-backup.svg +1 -0
  71. package/public/file-icons/folder-benchmark-open.svg +1 -0
  72. package/public/file-icons/folder-benchmark.svg +1 -0
  73. package/public/file-icons/folder-cart-open.svg +1 -0
  74. package/public/file-icons/folder-cart.svg +1 -0
  75. package/public/file-icons/folder-ci-open.svg +1 -0
  76. package/public/file-icons/folder-ci.svg +1 -0
  77. package/public/file-icons/folder-circleci-open.svg +1 -0
  78. package/public/file-icons/folder-circleci.svg +1 -0
  79. package/public/file-icons/folder-class-open.svg +1 -0
  80. package/public/file-icons/folder-class.svg +1 -0
  81. package/public/file-icons/folder-client-open.svg +1 -0
  82. package/public/file-icons/folder-client.svg +1 -0
  83. package/public/file-icons/folder-command-open.svg +1 -0
  84. package/public/file-icons/folder-command.svg +1 -0
  85. package/public/file-icons/folder-components-open.svg +1 -0
  86. package/public/file-icons/folder-components.svg +1 -0
  87. package/public/file-icons/folder-config-open.svg +1 -0
  88. package/public/file-icons/folder-config.svg +1 -0
  89. package/public/file-icons/folder-console-open.svg +1 -0
  90. package/public/file-icons/folder-console.svg +1 -0
  91. package/public/file-icons/folder-content-open.svg +1 -0
  92. package/public/file-icons/folder-content.svg +1 -0
  93. package/public/file-icons/folder-context-open.svg +1 -0
  94. package/public/file-icons/folder-context.svg +1 -0
  95. package/public/file-icons/folder-contract-open.svg +1 -0
  96. package/public/file-icons/folder-contract.svg +1 -0
  97. package/public/file-icons/folder-controller-open.svg +1 -0
  98. package/public/file-icons/folder-controller.svg +1 -0
  99. package/public/file-icons/folder-core-open.svg +1 -0
  100. package/public/file-icons/folder-core.svg +1 -0
  101. package/public/file-icons/folder-coverage-open.svg +1 -0
  102. package/public/file-icons/folder-coverage.svg +1 -0
  103. package/public/file-icons/folder-css-open.svg +1 -0
  104. package/public/file-icons/folder-css.svg +1 -0
  105. package/public/file-icons/folder-database-open.svg +1 -0
  106. package/public/file-icons/folder-database.svg +1 -0
  107. package/public/file-icons/folder-debug-open.svg +1 -0
  108. package/public/file-icons/folder-debug.svg +1 -0
  109. package/public/file-icons/folder-deprecated-open.svg +1 -0
  110. package/public/file-icons/folder-deprecated.svg +1 -0
  111. package/public/file-icons/folder-desktop-open.svg +1 -0
  112. package/public/file-icons/folder-desktop.svg +1 -0
  113. package/public/file-icons/folder-dist-open.svg +1 -0
  114. package/public/file-icons/folder-dist.svg +1 -0
  115. package/public/file-icons/folder-docker-open.svg +1 -0
  116. package/public/file-icons/folder-docker.svg +1 -0
  117. package/public/file-icons/folder-docs-open.svg +1 -0
  118. package/public/file-icons/folder-docs.svg +1 -0
  119. package/public/file-icons/folder-element-open.svg +1 -0
  120. package/public/file-icons/folder-element.svg +1 -0
  121. package/public/file-icons/folder-environment-open.svg +1 -0
  122. package/public/file-icons/folder-environment.svg +1 -0
  123. package/public/file-icons/folder-event-open.svg +1 -0
  124. package/public/file-icons/folder-event.svg +1 -0
  125. package/public/file-icons/folder-examples-open.svg +1 -0
  126. package/public/file-icons/folder-examples.svg +1 -0
  127. package/public/file-icons/folder-features-open.svg +1 -0
  128. package/public/file-icons/folder-features.svg +1 -0
  129. package/public/file-icons/folder-font-open.svg +1 -0
  130. package/public/file-icons/folder-font.svg +1 -0
  131. package/public/file-icons/folder-functions-open.svg +1 -0
  132. package/public/file-icons/folder-functions.svg +1 -0
  133. package/public/file-icons/folder-generator-open.svg +1 -0
  134. package/public/file-icons/folder-generator.svg +1 -0
  135. package/public/file-icons/folder-git-open.svg +1 -0
  136. package/public/file-icons/folder-git.svg +1 -0
  137. package/public/file-icons/folder-github-open.svg +1 -0
  138. package/public/file-icons/folder-github.svg +1 -0
  139. package/public/file-icons/folder-gitlab-open.svg +1 -0
  140. package/public/file-icons/folder-gitlab.svg +1 -0
  141. package/public/file-icons/folder-graphql-open.svg +1 -0
  142. package/public/file-icons/folder-graphql.svg +1 -0
  143. package/public/file-icons/folder-helper-open.svg +1 -0
  144. package/public/file-icons/folder-helper.svg +1 -0
  145. package/public/file-icons/folder-hook-open.svg +1 -0
  146. package/public/file-icons/folder-hook.svg +1 -0
  147. package/public/file-icons/folder-husky-open.svg +1 -0
  148. package/public/file-icons/folder-husky.svg +1 -0
  149. package/public/file-icons/folder-i18n-open.svg +1 -0
  150. package/public/file-icons/folder-i18n.svg +1 -0
  151. package/public/file-icons/folder-images-open.svg +1 -0
  152. package/public/file-icons/folder-images.svg +1 -0
  153. package/public/file-icons/folder-include-open.svg +1 -0
  154. package/public/file-icons/folder-include.svg +1 -0
  155. package/public/file-icons/folder-intellij-open.svg +1 -0
  156. package/public/file-icons/folder-intellij.svg +1 -0
  157. package/public/file-icons/folder-interface-open.svg +1 -0
  158. package/public/file-icons/folder-interface.svg +1 -0
  159. package/public/file-icons/folder-ios-open.svg +1 -0
  160. package/public/file-icons/folder-ios.svg +1 -0
  161. package/public/file-icons/folder-java-open.svg +1 -0
  162. package/public/file-icons/folder-java.svg +1 -0
  163. package/public/file-icons/folder-job-open.svg +1 -0
  164. package/public/file-icons/folder-job.svg +1 -0
  165. package/public/file-icons/folder-jupyter-open.svg +1 -0
  166. package/public/file-icons/folder-jupyter.svg +1 -0
  167. package/public/file-icons/folder-keys-open.svg +1 -0
  168. package/public/file-icons/folder-keys.svg +1 -0
  169. package/public/file-icons/folder-kubernetes-open.svg +1 -0
  170. package/public/file-icons/folder-kubernetes.svg +1 -0
  171. package/public/file-icons/folder-layout-open.svg +1 -0
  172. package/public/file-icons/folder-layout.svg +1 -0
  173. package/public/file-icons/folder-less-open.svg +1 -0
  174. package/public/file-icons/folder-less.svg +1 -0
  175. package/public/file-icons/folder-lib-open.svg +1 -0
  176. package/public/file-icons/folder-lib.svg +1 -0
  177. package/public/file-icons/folder-log-open.svg +1 -0
  178. package/public/file-icons/folder-log.svg +1 -0
  179. package/public/file-icons/folder-messages-open.svg +1 -0
  180. package/public/file-icons/folder-messages.svg +1 -0
  181. package/public/file-icons/folder-meta-open.svg +1 -0
  182. package/public/file-icons/folder-meta.svg +1 -0
  183. package/public/file-icons/folder-middleware-open.svg +1 -0
  184. package/public/file-icons/folder-middleware.svg +1 -0
  185. package/public/file-icons/folder-migrations-open.svg +1 -0
  186. package/public/file-icons/folder-migrations.svg +1 -0
  187. package/public/file-icons/folder-mobile-open.svg +1 -0
  188. package/public/file-icons/folder-mobile.svg +1 -0
  189. package/public/file-icons/folder-mock-open.svg +1 -0
  190. package/public/file-icons/folder-mock.svg +1 -0
  191. package/public/file-icons/folder-molecule-open.svg +1 -0
  192. package/public/file-icons/folder-molecule.svg +1 -0
  193. package/public/file-icons/folder-next-open.svg +1 -0
  194. package/public/file-icons/folder-next.svg +1 -0
  195. package/public/file-icons/folder-node-open.svg +1 -0
  196. package/public/file-icons/folder-node.svg +1 -0
  197. package/public/file-icons/folder-nuxt-open.svg +1 -0
  198. package/public/file-icons/folder-nuxt.svg +1 -0
  199. package/public/file-icons/folder-open.svg +1 -0
  200. package/public/file-icons/folder-organism-open.svg +1 -0
  201. package/public/file-icons/folder-organism.svg +1 -0
  202. package/public/file-icons/folder-packages-open.svg +1 -0
  203. package/public/file-icons/folder-packages.svg +1 -0
  204. package/public/file-icons/folder-plugin-open.svg +1 -0
  205. package/public/file-icons/folder-plugin.svg +1 -0
  206. package/public/file-icons/folder-prisma-open.svg +1 -0
  207. package/public/file-icons/folder-prisma.svg +1 -0
  208. package/public/file-icons/folder-prompts-open.svg +1 -0
  209. package/public/file-icons/folder-prompts.svg +1 -0
  210. package/public/file-icons/folder-proto-open.svg +1 -0
  211. package/public/file-icons/folder-proto.svg +1 -0
  212. package/public/file-icons/folder-public-open.svg +1 -0
  213. package/public/file-icons/folder-public.svg +1 -0
  214. package/public/file-icons/folder-python-open.svg +1 -0
  215. package/public/file-icons/folder-python.svg +1 -0
  216. package/public/file-icons/folder-queue-open.svg +1 -0
  217. package/public/file-icons/folder-queue.svg +1 -0
  218. package/public/file-icons/folder-resource-open.svg +1 -0
  219. package/public/file-icons/folder-resource.svg +1 -0
  220. package/public/file-icons/folder-review-open.svg +1 -0
  221. package/public/file-icons/folder-review.svg +1 -0
  222. package/public/file-icons/folder-robot-open.svg +1 -0
  223. package/public/file-icons/folder-robot.svg +1 -0
  224. package/public/file-icons/folder-routes-open.svg +1 -0
  225. package/public/file-icons/folder-routes.svg +1 -0
  226. package/public/file-icons/folder-rust-open.svg +1 -0
  227. package/public/file-icons/folder-rust.svg +1 -0
  228. package/public/file-icons/folder-sass-open.svg +1 -0
  229. package/public/file-icons/folder-sass.svg +1 -0
  230. package/public/file-icons/folder-scripts-open.svg +1 -0
  231. package/public/file-icons/folder-scripts.svg +1 -0
  232. package/public/file-icons/folder-secure-open.svg +1 -0
  233. package/public/file-icons/folder-secure.svg +1 -0
  234. package/public/file-icons/folder-seeders-open.svg +1 -0
  235. package/public/file-icons/folder-seeders.svg +1 -0
  236. package/public/file-icons/folder-server-open.svg +1 -0
  237. package/public/file-icons/folder-server.svg +1 -0
  238. package/public/file-icons/folder-shared-open.svg +1 -0
  239. package/public/file-icons/folder-shared.svg +1 -0
  240. package/public/file-icons/folder-skills-open.svg +1 -0
  241. package/public/file-icons/folder-skills.svg +1 -0
  242. package/public/file-icons/folder-src-open.svg +1 -0
  243. package/public/file-icons/folder-src.svg +1 -0
  244. package/public/file-icons/folder-store-open.svg +1 -0
  245. package/public/file-icons/folder-store.svg +1 -0
  246. package/public/file-icons/folder-storybook-open.svg +1 -0
  247. package/public/file-icons/folder-storybook.svg +1 -0
  248. package/public/file-icons/folder-supabase-open.svg +1 -0
  249. package/public/file-icons/folder-supabase.svg +1 -0
  250. package/public/file-icons/folder-svelte-open.svg +1 -0
  251. package/public/file-icons/folder-svelte.svg +1 -0
  252. package/public/file-icons/folder-target-open.svg +1 -0
  253. package/public/file-icons/folder-target.svg +1 -0
  254. package/public/file-icons/folder-tasks-open.svg +1 -0
  255. package/public/file-icons/folder-tasks.svg +1 -0
  256. package/public/file-icons/folder-temp-open.svg +1 -0
  257. package/public/file-icons/folder-temp.svg +1 -0
  258. package/public/file-icons/folder-template-open.svg +1 -0
  259. package/public/file-icons/folder-template.svg +1 -0
  260. package/public/file-icons/folder-terraform-open.svg +1 -0
  261. package/public/file-icons/folder-terraform.svg +1 -0
  262. package/public/file-icons/folder-test-open.svg +1 -0
  263. package/public/file-icons/folder-test.svg +1 -0
  264. package/public/file-icons/folder-theme-open.svg +1 -0
  265. package/public/file-icons/folder-theme.svg +1 -0
  266. package/public/file-icons/folder-tools-open.svg +1 -0
  267. package/public/file-icons/folder-tools.svg +1 -0
  268. package/public/file-icons/folder-turborepo-open.svg +1 -0
  269. package/public/file-icons/folder-turborepo.svg +1 -0
  270. package/public/file-icons/folder-typescript-open.svg +1 -0
  271. package/public/file-icons/folder-typescript.svg +1 -0
  272. package/public/file-icons/folder-ui-open.svg +1 -0
  273. package/public/file-icons/folder-ui.svg +1 -0
  274. package/public/file-icons/folder-utils-open.svg +1 -0
  275. package/public/file-icons/folder-utils.svg +1 -0
  276. package/public/file-icons/folder-video-open.svg +1 -0
  277. package/public/file-icons/folder-video.svg +1 -0
  278. package/public/file-icons/folder-views-open.svg +1 -0
  279. package/public/file-icons/folder-views.svg +1 -0
  280. package/public/file-icons/folder-vscode-open.svg +1 -0
  281. package/public/file-icons/folder-vscode.svg +1 -0
  282. package/public/file-icons/folder.svg +1 -0
  283. package/public/file-icons/font.svg +1 -0
  284. package/public/file-icons/fsharp.svg +1 -0
  285. package/public/file-icons/gemfile.svg +1 -0
  286. package/public/file-icons/git.svg +1 -0
  287. package/public/file-icons/go-mod.svg +1 -0
  288. package/public/file-icons/go.svg +1 -0
  289. package/public/file-icons/gradle.svg +1 -0
  290. package/public/file-icons/graphql.svg +1 -0
  291. package/public/file-icons/groovy.svg +1 -0
  292. package/public/file-icons/h.svg +1 -0
  293. package/public/file-icons/handlebars.svg +1 -0
  294. package/public/file-icons/haskell.svg +1 -0
  295. package/public/file-icons/hcl.svg +1 -0
  296. package/public/file-icons/heroku.svg +1 -0
  297. package/public/file-icons/hpp.svg +1 -0
  298. package/public/file-icons/html.svg +1 -0
  299. package/public/file-icons/husky.svg +1 -0
  300. package/public/file-icons/i18n.svg +1 -0
  301. package/public/file-icons/image.svg +1 -0
  302. package/public/file-icons/java.svg +1 -0
  303. package/public/file-icons/javascript.svg +1 -0
  304. package/public/file-icons/jenkins.svg +1 -0
  305. package/public/file-icons/jest.svg +1 -0
  306. package/public/file-icons/jsconfig.svg +1 -0
  307. package/public/file-icons/json.svg +1 -0
  308. package/public/file-icons/julia.svg +1 -0
  309. package/public/file-icons/jupyter.svg +1 -0
  310. package/public/file-icons/just.svg +1 -0
  311. package/public/file-icons/key.svg +1 -0
  312. package/public/file-icons/kotlin.svg +1 -0
  313. package/public/file-icons/lerna.svg +1 -0
  314. package/public/file-icons/less.svg +1 -0
  315. package/public/file-icons/license.svg +1 -0
  316. package/public/file-icons/lintstaged.svg +1 -0
  317. package/public/file-icons/liquid.svg +1 -0
  318. package/public/file-icons/lock.svg +1 -0
  319. package/public/file-icons/log.svg +1 -0
  320. package/public/file-icons/lua.svg +1 -0
  321. package/public/file-icons/makefile.svg +1 -0
  322. package/public/file-icons/markdown.svg +1 -0
  323. package/public/file-icons/maven.svg +1 -0
  324. package/public/file-icons/mdx.svg +1 -0
  325. package/public/file-icons/netlify.svg +1 -0
  326. package/public/file-icons/next.svg +1 -0
  327. package/public/file-icons/nim.svg +1 -0
  328. package/public/file-icons/nix.svg +1 -0
  329. package/public/file-icons/nodejs.svg +1 -0
  330. package/public/file-icons/nodemon.svg +1 -0
  331. package/public/file-icons/npm.svg +1 -0
  332. package/public/file-icons/nx.svg +1 -0
  333. package/public/file-icons/objective-c.svg +1 -0
  334. package/public/file-icons/objective-cpp.svg +1 -0
  335. package/public/file-icons/ocaml.svg +1 -0
  336. package/public/file-icons/pdf.svg +1 -0
  337. package/public/file-icons/php.svg +1 -0
  338. package/public/file-icons/pm2-ecosystem.svg +1 -0
  339. package/public/file-icons/pnpm.svg +1 -0
  340. package/public/file-icons/poetry.svg +1 -0
  341. package/public/file-icons/postcss.svg +1 -0
  342. package/public/file-icons/powerpoint.svg +1 -0
  343. package/public/file-icons/powershell.svg +1 -0
  344. package/public/file-icons/prettier.svg +1 -0
  345. package/public/file-icons/prisma.svg +1 -0
  346. package/public/file-icons/prolog.svg +1 -0
  347. package/public/file-icons/proto.svg +1 -0
  348. package/public/file-icons/pug.svg +1 -0
  349. package/public/file-icons/python-misc.svg +1 -0
  350. package/public/file-icons/python.svg +1 -0
  351. package/public/file-icons/r.svg +1 -0
  352. package/public/file-icons/razor.svg +1 -0
  353. package/public/file-icons/react.svg +1 -0
  354. package/public/file-icons/react_ts.svg +1 -0
  355. package/public/file-icons/readme.svg +1 -0
  356. package/public/file-icons/renovate.svg +1 -0
  357. package/public/file-icons/robots.svg +1 -0
  358. package/public/file-icons/rollup.svg +1 -0
  359. package/public/file-icons/ruby.svg +1 -0
  360. package/public/file-icons/rust.svg +1 -0
  361. package/public/file-icons/sass.svg +1 -0
  362. package/public/file-icons/scala.svg +1 -0
  363. package/public/file-icons/semantic-release.svg +1 -0
  364. package/public/file-icons/settings.svg +1 -0
  365. package/public/file-icons/shader.svg +1 -0
  366. package/public/file-icons/sml.svg +1 -0
  367. package/public/file-icons/solidity.svg +1 -0
  368. package/public/file-icons/stylus.svg +1 -0
  369. package/public/file-icons/svelte.svg +1 -0
  370. package/public/file-icons/svg.svg +1 -0
  371. package/public/file-icons/swift.svg +1 -0
  372. package/public/file-icons/table.svg +1 -0
  373. package/public/file-icons/tailwindcss.svg +1 -0
  374. package/public/file-icons/taskfile.svg +1 -0
  375. package/public/file-icons/terraform.svg +1 -0
  376. package/public/file-icons/test-js.svg +1 -0
  377. package/public/file-icons/test-jsx.svg +1 -0
  378. package/public/file-icons/test-ts.svg +1 -0
  379. package/public/file-icons/tex.svg +1 -0
  380. package/public/file-icons/toml.svg +1 -0
  381. package/public/file-icons/travis.svg +1 -0
  382. package/public/file-icons/tsconfig.svg +1 -0
  383. package/public/file-icons/tune.svg +1 -0
  384. package/public/file-icons/turborepo.svg +1 -0
  385. package/public/file-icons/typescript-def.svg +1 -0
  386. package/public/file-icons/typescript.svg +1 -0
  387. package/public/file-icons/unity.svg +1 -0
  388. package/public/file-icons/uv.svg +1 -0
  389. package/public/file-icons/vala.svg +1 -0
  390. package/public/file-icons/vercel.svg +1 -0
  391. package/public/file-icons/video.svg +1 -0
  392. package/public/file-icons/vim.svg +1 -0
  393. package/public/file-icons/vite.svg +1 -0
  394. package/public/file-icons/vitest.svg +1 -0
  395. package/public/file-icons/vlang.svg +1 -0
  396. package/public/file-icons/vue.svg +1 -0
  397. package/public/file-icons/webassembly.svg +1 -0
  398. package/public/file-icons/webpack.svg +1 -0
  399. package/public/file-icons/word.svg +1 -0
  400. package/public/file-icons/xml.svg +1 -0
  401. package/public/file-icons/yaml.svg +1 -0
  402. package/public/file-icons/yarn.svg +1 -0
  403. package/public/file-icons/zig.svg +1 -0
  404. package/public/file-icons/zip.svg +1 -0
  405. package/public/provider-icons/antgroup-color.svg +1 -0
  406. package/public/provider-icons/anthropic.svg +1 -0
  407. package/public/provider-icons/azure-color.svg +1 -0
  408. package/public/provider-icons/baseten.svg +1 -0
  409. package/public/provider-icons/bedrock-color.svg +1 -0
  410. package/public/provider-icons/cerebras-color.svg +1 -0
  411. package/public/provider-icons/cloudflare-color.svg +1 -0
  412. package/public/provider-icons/deepseek-color.svg +1 -0
  413. package/public/provider-icons/fireworks-color.svg +1 -0
  414. package/public/provider-icons/githubcopilot.svg +1 -0
  415. package/public/provider-icons/google-color.svg +1 -0
  416. package/public/provider-icons/googlecloud-color.svg +1 -0
  417. package/public/provider-icons/groq.svg +1 -0
  418. package/public/provider-icons/huggingface-color.svg +1 -0
  419. package/public/provider-icons/kimi-color.svg +1 -0
  420. package/public/provider-icons/minimax-color.svg +1 -0
  421. package/public/provider-icons/mistral-color.svg +1 -0
  422. package/public/provider-icons/moonshot.svg +1 -0
  423. package/public/provider-icons/nvidia-color.svg +1 -0
  424. package/public/provider-icons/openai.svg +1 -0
  425. package/public/provider-icons/opencode.svg +1 -0
  426. package/public/provider-icons/openrouter-color.svg +1 -0
  427. package/public/provider-icons/qwen-color.svg +1 -0
  428. package/public/provider-icons/together-color.svg +1 -0
  429. package/public/provider-icons/vercel.svg +1 -0
  430. package/public/provider-icons/xai.svg +1 -0
  431. package/public/provider-icons/xiaomimimo.svg +1 -0
  432. package/public/provider-icons/zai.svg +1 -0
  433. package/scripts/build-output.mjs +131 -0
  434. package/scripts/dev.mjs +18 -0
  435. package/scripts/entrypoint.mjs +8 -0
  436. package/scripts/install-build.mjs +134 -0
  437. package/scripts/launcher.mjs +249 -0
  438. package/scripts/pick-folder.ps1 +110 -0
  439. package/scripts/postinstall.mjs +12 -0
  440. package/scripts/process-runner.mjs +60 -0
  441. package/scripts/release.mjs +172 -0
  442. package/scripts/sync-file-icons.mjs +118 -0
  443. package/scripts/sync-provider-icons.mjs +41 -0
  444. package/src/app/api/agent/[id]/events/route.ts +81 -0
  445. package/src/app/api/agent/[id]/route.ts +24 -0
  446. package/src/app/api/agent/[id]/tree/route.ts +49 -0
  447. package/src/app/api/agent/new/route.ts +19 -0
  448. package/src/app/api/config/route.ts +22 -0
  449. package/src/app/api/files/route.ts +63 -0
  450. package/src/app/api/git/route.ts +32 -0
  451. package/src/app/api/growth/route.ts +50 -0
  452. package/src/app/api/health/route.ts +7 -0
  453. package/src/app/api/models/route.ts +129 -0
  454. package/src/app/api/pi-settings/route.ts +22 -0
  455. package/src/app/api/plugins/route.ts +79 -0
  456. package/src/app/api/security/route.ts +41 -0
  457. package/src/app/api/sessions/[id]/export/route.ts +25 -0
  458. package/src/app/api/sessions/[id]/route.ts +37 -0
  459. package/src/app/api/sessions/route.ts +23 -0
  460. package/src/app/api/skills/route.ts +39 -0
  461. package/src/app/api/update/route.ts +33 -0
  462. package/src/app/api/version/route.ts +8 -0
  463. package/src/app/api/web-auth/route.ts +80 -0
  464. package/src/app/api/workspaces/route.ts +78 -0
  465. package/src/app/globals.css +2039 -0
  466. package/src/app/icon.svg +21 -0
  467. package/src/app/layout.tsx +29 -0
  468. package/src/app/login/page.tsx +111 -0
  469. package/src/app/page.tsx +10 -0
  470. package/src/components/AppShell.tsx +1171 -0
  471. package/src/components/ChatInput.tsx +729 -0
  472. package/src/components/ChatWindow.tsx +1320 -0
  473. package/src/components/ContextMeter.tsx +131 -0
  474. package/src/components/DiffView.tsx +139 -0
  475. package/src/components/ExtensionUI.tsx +181 -0
  476. package/src/components/FileViewer.tsx +555 -0
  477. package/src/components/FullDiff.tsx +220 -0
  478. package/src/components/GitPanel.tsx +357 -0
  479. package/src/components/GrowthTree.tsx +317 -0
  480. package/src/components/ModelSelector.tsx +286 -0
  481. package/src/components/OutlineRail.tsx +166 -0
  482. package/src/components/PiMark.tsx +79 -0
  483. package/src/components/ProjectPanel.tsx +309 -0
  484. package/src/components/ProviderBrand.tsx +28 -0
  485. package/src/components/ProviderSetupModal.module.css +560 -0
  486. package/src/components/ProviderSetupModal.tsx +559 -0
  487. package/src/components/SessionSidebar.tsx +1066 -0
  488. package/src/components/SettingsPanel.tsx +2216 -0
  489. package/src/components/TrajectoryAnalyzer.module.css +225 -0
  490. package/src/components/TrajectoryView.tsx +289 -0
  491. package/src/components/icons.tsx +443 -0
  492. package/src/hooks/useFileViewer.ts +58 -0
  493. package/src/hooks/useGrowth.ts +635 -0
  494. package/src/hooks/usePiWeb.ts +900 -0
  495. package/src/i18n.tsx +1043 -0
  496. package/src/lib/agent-manager.ts +1348 -0
  497. package/src/lib/app-root.ts +4 -0
  498. package/src/lib/auth.ts +51 -0
  499. package/src/lib/command-validation.ts +116 -0
  500. package/src/lib/enter-behavior.ts +40 -0
  501. package/src/lib/extension-ui.ts +134 -0
  502. package/src/lib/file-icons.json +557 -0
  503. package/src/lib/file-icons.ts +39 -0
  504. package/src/lib/files-service.ts +280 -0
  505. package/src/lib/git-service.ts +255 -0
  506. package/src/lib/growth-rounds.ts +84 -0
  507. package/src/lib/growth-service.ts +586 -0
  508. package/src/lib/growth-tracker.ts +328 -0
  509. package/src/lib/growth-tree.ts +245 -0
  510. package/src/lib/growth-turns.ts +23 -0
  511. package/src/lib/highlight.ts +59 -0
  512. package/src/lib/model-draft.ts +103 -0
  513. package/src/lib/models-service.ts +852 -0
  514. package/src/lib/path-security.ts +101 -0
  515. package/src/lib/pi-settings.ts +115 -0
  516. package/src/lib/pi.ts +367 -0
  517. package/src/lib/plugins-service.ts +312 -0
  518. package/src/lib/process-format.ts +261 -0
  519. package/src/lib/provider-display.ts +57 -0
  520. package/src/lib/security-service.ts +52 -0
  521. package/src/lib/semver.ts +39 -0
  522. package/src/lib/session-reader.ts +204 -0
  523. package/src/lib/settings-write-lock.ts +45 -0
  524. package/src/lib/skills-service.ts +97 -0
  525. package/src/lib/text-sanitize.ts +20 -0
  526. package/src/lib/theme.ts +137 -0
  527. package/src/lib/trajectory.ts +294 -0
  528. package/src/lib/types.ts +300 -0
  529. package/src/lib/update-service.ts +129 -0
  530. package/src/lib/version.ts +6 -0
  531. package/src/lib/web-auth-shared.ts +35 -0
  532. package/src/lib/web-auth.ts +53 -0
  533. package/src/lib/workspace-store.ts +261 -0
  534. package/src/proxy.ts +53 -0
  535. package/tsconfig.json +49 -0
@@ -0,0 +1,1320 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 对话视图 —— 过程步骤。
5
+ * 每个回合 = 用户消息 → 过程(叙述句是阶段标题,其下是带图标的步骤列表)→ 最终回答。
6
+ * 进行中的思考 / 命令用闪光渐变的英文状态词;回合结束后思考与命令折成一行摘要,编辑保留 diff。
7
+ */
8
+ import { memo, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
9
+ import ReactMarkdown from "react-markdown";
10
+ import rehypeHighlight from "rehype-highlight";
11
+ import remarkGfm from "remark-gfm";
12
+ import {
13
+ IconBranchOutline16,
14
+ IconBrowseOutline14,
15
+ IconCheckOutline14,
16
+ IconChevronRight14,
17
+ IconClockOutline16,
18
+ IconCloseOutline14,
19
+ IconCopyOutline16,
20
+ IconDataOutline16,
21
+ IconEditOutline16,
22
+ IconFileOutline16,
23
+ IconRefreshOutline14,
24
+ IconSearchOutline16,
25
+ IconTerminalOutline14,
26
+ IconThinkOutline14,
27
+ IconWarningOutline16,
28
+ } from "@/components/icons";
29
+ import { DiffView, type DiffLine, parseUnifiedDiff } from "@/components/DiffView";
30
+ import { languageForPath } from "@/lib/highlight";
31
+ import { OutlineRail } from "@/components/OutlineRail";
32
+ import { useI18n } from "@/i18n";
33
+ import type { ToolCardState } from "@/hooks/usePiWeb";
34
+ import type { ContextResource, TrajEntry, TrajTokens, WebContent, WebMessage, WebStats } from "@/lib/types";
35
+ import {
36
+ classifyModelError,
37
+ cleanCommand,
38
+ displayToolName,
39
+ firstSentence,
40
+ langOfPath,
41
+ previewSide,
42
+ relativizeInText,
43
+ relativizePath,
44
+ splitLines,
45
+ toolKind,
46
+ trimNoiseTail,
47
+ type ModelErrorKind,
48
+ type ToolKind,
49
+ } from "@/lib/process-format";
50
+
51
+ type Dict = Record<string, string>;
52
+ type ToolCallContent = Extract<WebContent, { type: "toolCall" }>;
53
+
54
+ async function copyText(text: string): Promise<boolean> {
55
+ try {
56
+ if (navigator.clipboard?.writeText) {
57
+ await navigator.clipboard.writeText(text);
58
+ return true;
59
+ }
60
+ } catch {
61
+ // Non-secure local-network contexts need the legacy fallback below.
62
+ }
63
+ const field = document.createElement("textarea");
64
+ field.value = text;
65
+ field.style.position = "fixed";
66
+ field.style.opacity = "0";
67
+ document.body.appendChild(field);
68
+ field.select();
69
+ try {
70
+ return document.execCommand("copy");
71
+ } catch {
72
+ return false;
73
+ } finally {
74
+ field.remove();
75
+ }
76
+ }
77
+
78
+ /** dsh message-chrome formatRunDuration:整秒,分钟档秒数补零 */
79
+ function formatRunDuration(ms: number, t: Dict): string {
80
+ const total = Math.max(0, Math.floor(ms / 1000));
81
+ const minutes = Math.floor(total / 60);
82
+ const seconds = total % 60;
83
+ return minutes > 0
84
+ ? t.durationMinutes.replace("{minutes}", String(minutes)).replace("{seconds}", String(seconds).padStart(2, "0"))
85
+ : t.durationSeconds.replace("{seconds}", String(seconds));
86
+ }
87
+
88
+ /** dsh message-chrome formatMessageClock 的月日精度版:恒显示 M月d日,跨年补年 */
89
+ function formatMessageClock(time: number, t: Dict): string {
90
+ const d = new Date(time);
91
+ const n = new Date();
92
+ const clock = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
93
+ const md = d.getFullYear() === n.getFullYear()
94
+ ? t.clockMd.replace("{m}", String(d.getMonth() + 1)).replace("{d}", String(d.getDate()))
95
+ : t.clockYmd.replace("{y}", String(d.getFullYear())).replace("{m}", String(d.getMonth() + 1)).replace("{d}", String(d.getDate()));
96
+ return `${md} ${clock}`;
97
+ }
98
+
99
+ const Markdown = memo(function Markdown({ text }: { text: string }) {
100
+ return (
101
+ <div className="md">
102
+ <ReactMarkdown
103
+ remarkPlugins={[remarkGfm]}
104
+ rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
105
+ components={{ a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}
106
+ >
107
+ {text}
108
+ </ReactMarkdown>
109
+ </div>
110
+ );
111
+ });
112
+
113
+ /** 流式中的最后一段正文:纯文本逐字追加 + 闪烁光标,结束后再切 Markdown(避免每个 delta 重解析整段) */
114
+ function StreamingText({ text }: { text: string }) {
115
+ return (
116
+ <div className="md">
117
+ <p style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0 }}>
118
+ {text}
119
+ <span className="stream-cursor" aria-hidden />
120
+ </p>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ /** dsh TurnTimePanel 的简化版:耗时胶囊常驻,点击弹层看本回合 token 用量 */
126
+ function TurnMetaPill({ durationMs, usage, t }: { durationMs: number; usage?: TrajTokens & { cost?: { total?: number } }; t: Dict }) {
127
+ const [open, setOpen] = useState(false);
128
+ const ref = useRef<HTMLDivElement>(null);
129
+ useEffect(() => {
130
+ if (!open) return;
131
+ const h = (e: MouseEvent) => {
132
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
133
+ };
134
+ document.addEventListener("mousedown", h);
135
+ return () => document.removeEventListener("mousedown", h);
136
+ }, [open]);
137
+ const billed = (usage?.input ?? 0) + (usage?.cacheRead ?? 0) + (usage?.cacheWrite ?? 0);
138
+ const hasUsage = usage != null && (billed > 0 || (usage.output ?? 0) > 0);
139
+ return (
140
+ <div ref={ref} className="relative">
141
+ <button
142
+ type="button"
143
+ className="flex items-center gap-1 rounded-full px-2"
144
+ style={{ height: 22, fontSize: "var(--piweb-chat-font-xs)", color: "var(--dsw-label-caption)" }}
145
+ title={t.turnUsageTitle}
146
+ aria-haspopup="dialog"
147
+ aria-expanded={open}
148
+ onClick={() => setOpen((o) => !o)}
149
+ >
150
+ <IconClockOutline16 size={13} style={{ flex: "none" }} />
151
+ <span style={{ whiteSpace: "nowrap" }}>{t.turnRanFor.replace("{duration}", formatRunDuration(durationMs, t))}</span>
152
+ </button>
153
+ {open && (
154
+ <div className="popover absolute left-0 top-full z-50 mt-1.5 w-44 p-3" role="dialog" aria-label={t.turnUsageTitle}>
155
+ <div className="flex flex-col gap-1" style={{ fontSize: "var(--piweb-chat-font-t)", color: "var(--dsw-label-secondary)" }}>
156
+ <div className="flex items-center justify-between gap-2">
157
+ <span style={{ color: "var(--dsw-label-caption)" }}>{t.duration}</span>
158
+ <span>{formatRunDuration(durationMs, t)}</span>
159
+ </div>
160
+ {hasUsage && (
161
+ <>
162
+ <div className="flex items-center justify-between gap-2">
163
+ <span style={{ color: "var(--dsw-label-caption)" }}>{t.inputTokens}</span>
164
+ <span>{fmtTok(billed)}</span>
165
+ </div>
166
+ <div className="flex items-center justify-between gap-2">
167
+ <span style={{ color: "var(--dsw-label-caption)" }}>{t.outputTokens}</span>
168
+ <span>{fmtTok(usage.output ?? 0)}</span>
169
+ </div>
170
+ {(usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) > 0 && (
171
+ <div className="flex items-center justify-between gap-2">
172
+ <span style={{ color: "var(--dsw-label-caption)" }}>{t.cacheHits}</span>
173
+ <span>{Math.round(((usage.cacheRead ?? 0) / Math.max(billed, 1)) * 100)}%</span>
174
+ </div>
175
+ )}
176
+ </>
177
+ )}
178
+ {usage?.cost?.total != null && usage.cost.total > 0 && (
179
+ <div className="flex items-center justify-between gap-2">
180
+ <span style={{ color: "var(--dsw-label-caption)" }}>{t.cost}</span>
181
+ <span>${usage.cost.total.toFixed(4)}</span>
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ )}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ function MessageActions({
192
+ text,
193
+ onFork,
194
+ durationMs,
195
+ usage,
196
+ time,
197
+ clockStart = false,
198
+ }: {
199
+ text: string;
200
+ onFork?: () => void | Promise<unknown>;
201
+ /** 回合墙钟耗时(最终回答时间戳 − 回合首条用户消息时间戳),dsh runMs 的对应物 */
202
+ durationMs?: number;
203
+ usage?: TrajTokens & { cost?: { total?: number } };
204
+ /** 消息时间戳,驱动时钟;undefined 时不显示时钟 */
205
+ time?: number;
206
+ /** 用户消息时钟在图标左侧(dsh clock=start),助手在行尾(clock=end) */
207
+ clockStart?: boolean;
208
+ }) {
209
+ const [copied, setCopied] = useState(false);
210
+ const [copyFailed, setCopyFailed] = useState(false);
211
+ const [forking, setForking] = useState(false);
212
+ const { t } = useI18n();
213
+ const clock = time === undefined ? null : (
214
+ <span className={`msg-clock ${clockStart ? "msg-clock-start" : ""}`} style={{ whiteSpace: "nowrap" }} suppressHydrationWarning>
215
+ {formatMessageClock(time, t)}
216
+ </span>
217
+ );
218
+ return (
219
+ <div className="msg-actions">
220
+ {clockStart && clock}
221
+ <button
222
+ className="icon-btn"
223
+ title={copyFailed ? t.copyFailed : t.copy}
224
+ aria-label={t.copy}
225
+ onClick={() => {
226
+ void copyText(text).then((ok) => {
227
+ setCopied(ok);
228
+ setCopyFailed(!ok);
229
+ setTimeout(() => { setCopied(false); setCopyFailed(false); }, 1200);
230
+ });
231
+ }}
232
+ >
233
+ {copied ? <IconCheckOutline14 size={14} /> : <IconCopyOutline16 size={14} />}
234
+ </button>
235
+ {copyFailed && <span role="alert" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{t.copyFailed}</span>}
236
+ {onFork && (
237
+ <button
238
+ className="icon-btn"
239
+ title={t.forkSession}
240
+ aria-label={t.forkSession}
241
+ disabled={forking}
242
+ style={{ opacity: forking ? 0.6 : undefined }}
243
+ onClick={() => {
244
+ if (forking) return;
245
+ // 分支要等服务端建新会话再切换,进行中旋转反馈并防重复点击
246
+ setForking(true);
247
+ void Promise.resolve(onFork()).finally(() => setForking(false));
248
+ }}
249
+ >
250
+ <span className={forking ? "piweb-spin" : undefined} style={{ display: "inline-flex" }}>
251
+ <IconBranchOutline16 size={14} />
252
+ </span>
253
+ </button>
254
+ )}
255
+ {durationMs !== undefined && <TurnMetaPill durationMs={durationMs} usage={usage} t={t} />}
256
+ {!clockStart && clock}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ // ---------- 过程步骤(Pi 版)----------
262
+ // 叙述句是阶段标题(基线对齐,主色),其下是带图标的步骤列表(Grok 的图标步骤 + Codex 的一行一命令);
263
+ // 进行中的思考 / 命令用闪光渐变的英文状态词;回合结束后每个阶段下的思考与命令折成一行
264
+ // 「思考了 1m 12s,运行了 6 条命令」(Claude Code 的折叠摘要),编辑文件保留 diff;末尾 π 记号做工作指示。没有连线。
265
+
266
+ const MONO = "var(--font-mono)";
267
+ const RAIL = "⎿";
268
+ /** 命令输出默认露出的行数 */
269
+ const PREVIEW_LINES = 3;
270
+ /** 写入 / 编辑默认露出的 diff 行数 */
271
+ const DIFF_PREVIEW = 12;
272
+ /** 进行中的状态词:Claude Code 同款用法,闪光渐变,按用户要求用英文(两种界面语言都一样) */
273
+ const WORKING_VERBS = ["Thinking", "Pondering", "Cogitating", "Mulling", "Percolating", "Brewing", "Simmering", "Ruminating", "Synthesizing", "Noodling", "Marinating", "Crafting"];
274
+ /** 有步骤在闪光时 π 行只报时长;这行和状态词一样固定英文,不随界面语言变 */
275
+ const WORKING_FOR = "Working for {d}";
276
+
277
+ /** 每秒刷新一次的已用时长(仅在 active 时计时) */
278
+ function useElapsed(startedAt: number | undefined, active: boolean): number {
279
+ const [now, setNow] = useState(() => Date.now());
280
+ useEffect(() => {
281
+ if (!active) return;
282
+ setNow(Date.now());
283
+ const timer = setInterval(() => setNow(Date.now()), 1000);
284
+ return () => clearInterval(timer);
285
+ }, [active]);
286
+ return startedAt ? Math.max(0, now - startedAt) : 0;
287
+ }
288
+
289
+ /** 58s / 6m 8s / 2h 2m 3s */
290
+ function fmtSpan(ms: number): string {
291
+ const s = Math.max(0, Math.round(ms / 1000));
292
+ if (s < 60) return `${s}s`;
293
+ const m = Math.floor(s / 60);
294
+ if (m < 60) return `${m}m ${s % 60}s`;
295
+ return `${Math.floor(m / 60)}h ${m % 60}m ${s % 60}s`;
296
+ }
297
+
298
+ type StepTone = "dim" | "live" | "fail" | "warn";
299
+ type StepKind = ToolKind | "think" | "summary" | "context" | "error" | "abort" | "retry" | "work";
300
+
301
+ function KindIcon({ kind }: { kind: StepKind }) {
302
+ const p = { size: 14, style: { flex: "none" as const } };
303
+ switch (kind) {
304
+ case "cmd":
305
+ return <IconTerminalOutline14 {...p} />;
306
+ case "read":
307
+ return <IconBrowseOutline14 {...p} />;
308
+ case "search":
309
+ return <IconSearchOutline16 {...p} />;
310
+ case "write":
311
+ return <IconEditOutline16 {...p} />;
312
+ case "think":
313
+ case "summary":
314
+ return <IconThinkOutline14 {...p} />;
315
+ case "context":
316
+ return <IconFileOutline16 {...p} />;
317
+ case "error":
318
+ return <IconWarningOutline16 {...p} />;
319
+ case "abort":
320
+ return <IconCloseOutline14 {...p} />;
321
+ case "retry":
322
+ return <IconRefreshOutline14 {...p} />;
323
+ case "work":
324
+ return <span className="pw-pi" aria-hidden>π</span>;
325
+ default:
326
+ return <IconDataOutline16 {...p} />;
327
+ }
328
+ }
329
+
330
+ function Step({ kind, tone = "dim", live, className, children }: { kind: StepKind; tone?: StepTone; live?: boolean; className?: string; children: ReactNode }) {
331
+ return (
332
+ <div className={className ? `pw-step ${className}` : "pw-step"} data-kind={kind} data-tone={tone} data-live={live || undefined}>
333
+ <span className="pw-icon" aria-hidden>
334
+ <KindIcon kind={kind} />
335
+ </span>
336
+ <div className="pw-body">{children}</div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ /** 把模板里的 {n}/{d} 占位换成加粗的值(摘要行里数字加粗,像 Claude Code) */
342
+ function fillBold(template: string, values: Record<string, string>): ReactNode[] {
343
+ const out: ReactNode[] = [];
344
+ const re = /\{(\w+)\}/g;
345
+ let last = 0;
346
+ let m: RegExpExecArray | null;
347
+ let i = 0;
348
+ while ((m = re.exec(template))) {
349
+ if (m.index > last) out.push(template.slice(last, m.index));
350
+ out.push(<strong key={i++}>{values[m[1]] ?? ""}</strong>);
351
+ last = m.index + m[0].length;
352
+ }
353
+ if (last < template.length) out.push(template.slice(last));
354
+ return out;
355
+ }
356
+
357
+ // ---------- 工具步骤 ----------
358
+
359
+ const str = (v: unknown) => (typeof v === "string" ? v : undefined);
360
+
361
+ /** 工具参数摘要:命令去掉 cd 工作区前缀与分隔用的 echo、路径相对于工作区 */
362
+ function toolSummary(name: string, args: unknown, cwd?: string): { text: string; scriptLines: number } {
363
+ const a = (args ?? {}) as Record<string, unknown>;
364
+ const n = name.toLowerCase();
365
+ const kind = toolKind(name);
366
+ const rel = (v: unknown) => {
367
+ const s = str(v);
368
+ return s ? relativizePath(s, cwd) : "";
369
+ };
370
+ if (kind === "cmd") {
371
+ const c = cleanCommand(str(a.command) ?? str(a.cmd) ?? str(a.script) ?? "", cwd);
372
+ return { text: c.text, scriptLines: c.lines };
373
+ }
374
+ if (n === "grep" || n === "glob" || n === "find") return { text: [str(a.pattern), rel(a.path)].filter(Boolean).join(" "), scriptLines: 0 };
375
+ if (kind === "read" || kind === "write" || n === "ls") return { text: rel(a.path ?? a.file_path), scriptLines: 0 };
376
+ const first = Object.values(a).find((v) => typeof v === "string") as string | undefined;
377
+ return { text: (first ?? "").replace(/\s+/g, " ").slice(0, 160), scriptLines: 0 };
378
+ }
379
+
380
+ type Preview = { head?: string; lines: string[]; hidden: number; side?: "head" | "tail" };
381
+
382
+ /** ⎿ 结果预览:查看类命令看开头、构建测试类看结尾,搜索给条数,读取给行数 */
383
+ function toolPreview(kind: ToolKind, state: ToolCardState, t: Dict, cwd?: string): Preview {
384
+ const running = state.state === "running";
385
+ const raw = (state.result ?? state.partialResult ?? "").replace(/\r/g, "");
386
+ const exitMatch = raw.match(/Command exited with code (\d+)/);
387
+ const exitCode = exitMatch ? Number(exitMatch[1]) : undefined;
388
+ const body = splitLines(raw).filter((l) => !/^Command exited with code \d+/.test(l));
389
+ const count = (k: string, v: number) => t[k].replace("{n}", String(v));
390
+ const rel = (lines: string[]) => (cwd ? lines.map((l) => relativizeInText(l, cwd)) : lines);
391
+ const tailBody = trimNoiseTail(body);
392
+ const tail = (n: number): Preview => ({ lines: rel(tailBody.slice(-n)), hidden: Math.max(0, body.length - Math.min(n, tailBody.length)), side: "tail" });
393
+ const head = (n: number): Preview => ({ lines: rel(body.slice(0, n)), hidden: Math.max(0, body.length - n), side: "head" });
394
+ const none: Preview = { lines: [], hidden: 0 };
395
+ const a = state.args as Record<string, unknown> | undefined;
396
+ const command = kind === "cmd" ? (str(a?.command) ?? str(a?.cmd) ?? str(a?.script) ?? "") : "";
397
+ const byCmd = (n: number) => (previewSide(cleanCommand(command, cwd).text) === "head" ? head(n) : tail(n));
398
+ if (running) return kind === "cmd" && body.length ? tail(PREVIEW_LINES) : none;
399
+ if (state.isError) {
400
+ const h = exitCode !== undefined && exitCode !== 0 ? `${t.toolFailed} · ${t.toolExit.replace("{code}", String(exitCode))}` : t.toolFailed;
401
+ return { head: h, ...(kind === "cmd" ? tail(4) : head(4)) };
402
+ }
403
+ switch (kind) {
404
+ case "read": {
405
+ const lang = langOfPath(str(a?.path ?? a?.file_path) ?? "");
406
+ return { head: body.length ? `${count("resultRead", body.length)}${lang ? ` · ${lang}` : ""}` : t.toolNoOutput, lines: [], hidden: 0 };
407
+ }
408
+ case "search":
409
+ return body.length ? { head: count("resultFound", body.length), ...head(PREVIEW_LINES) } : { head: t.toolNoOutput, lines: [], hidden: 0 };
410
+ case "cmd": {
411
+ const h = exitCode !== undefined && exitCode !== 0 ? t.toolExit.replace("{code}", String(exitCode)) : undefined;
412
+ if (!body.length) return { head: h ?? t.toolNoOutput, lines: [], hidden: 0 };
413
+ return { head: h, ...byCmd(PREVIEW_LINES) };
414
+ }
415
+ case "write":
416
+ return none;
417
+ default:
418
+ return body.length ? head(2) : { head: t.toolNoOutput, lines: [], hidden: 0 };
419
+ }
420
+ }
421
+
422
+ function stepVerb(t: Dict, kind: ToolKind, name: string): string {
423
+ const n = name.toLowerCase();
424
+ if (kind === "cmd") return t.stepRan;
425
+ if (kind === "read") return t.stepRead;
426
+ if (kind === "search") return t.stepSearched;
427
+ if (kind === "write") return n === "write" ? t.stepWrote : t.stepEdited;
428
+ return displayToolName(name);
429
+ }
430
+
431
+ const ToolStep = memo(function ToolStep({
432
+ id,
433
+ name,
434
+ args,
435
+ state,
436
+ cwd,
437
+ onInspectTool,
438
+ onOpenFile,
439
+ }: {
440
+ id: string;
441
+ name: string;
442
+ args: unknown;
443
+ state?: ToolCardState;
444
+ cwd?: string;
445
+ onInspectTool?: (toolCallId: string) => void;
446
+ onOpenFile?: (path: string) => void;
447
+ }) {
448
+ const [open, setOpen] = useState(false);
449
+ const { t } = useI18n();
450
+ const tt = t as unknown as Dict;
451
+ const kind = toolKind(name);
452
+ const running = !state || state.state === "running";
453
+ const elapsed = useElapsed(state?.startedAt, running);
454
+ const output = state?.result ?? state?.partialResult ?? "";
455
+ const isWriteTool = name.toLowerCase() === "write";
456
+ const diff = useMemo(() => {
457
+ if (kind !== "write" || !state || state.state !== "done" || state.isError) return null;
458
+ const parsed = parseUnifiedDiff(state.patch || output);
459
+ if (parsed) return parsed;
460
+ // pi 的 write 工具不带 patch:新文件按带行号的正文展示(中性色,不标 +)
461
+ if (isWriteTool) {
462
+ const content = str((args as { content?: unknown } | undefined)?.content);
463
+ if (content) return content.replace(/\r/g, "").replace(/\n$/, "").split("\n").map((text, i): DiffLine => ({ kind: "ctx", old: i + 1, new: i + 1, text }));
464
+ }
465
+ return null;
466
+ }, [args, isWriteTool, kind, output, state]);
467
+ const failed = Boolean(state?.isError) && !running;
468
+ const summary = toolSummary(name, args, cwd);
469
+ let argsTitle = summary.text;
470
+ try {
471
+ argsTitle = JSON.stringify(args, null, 2).slice(0, 2000);
472
+ } catch {
473
+ /* keep summary */
474
+ }
475
+ const argPath = (() => {
476
+ if (kind !== "read" && kind !== "write") return "";
477
+ const a = args as { path?: unknown; file_path?: unknown } | undefined;
478
+ return str(a?.path ?? a?.file_path) ?? "";
479
+ })();
480
+ const fullCommand = kind === "cmd" ? (str((args as Record<string, unknown>)?.command) ?? str((args as Record<string, unknown>)?.cmd) ?? str((args as Record<string, unknown>)?.script) ?? "") : "";
481
+ const preview = state ? toolPreview(kind, state, tt, cwd) : { lines: [], hidden: 0 };
482
+ const hasDiff = Boolean(diff && diff.length) && !failed;
483
+ const hasOutput = Boolean(output || state?.encodingLoss);
484
+ const canExpand = hasOutput && !hasDiff && !running;
485
+ const duration = state?.startedAt && state?.endedAt ? state.endedAt - state.startedAt : 0;
486
+ const showPreview = Boolean(preview.head) || preview.lines.length > 0;
487
+ const diffLines = hasDiff && diff ? (open ? diff : diff.slice(0, DIFF_PREVIEW)) : null;
488
+ const diffHidden = diff && diffLines && !open ? diff.length - diffLines.length : 0;
489
+ const diffStat = hasDiff && diff
490
+ ? isWriteTool && !state?.patch
491
+ ? t.resultWrote.replace("{n}", String(diff.length))
492
+ : t.diffSummary.replace("{add}", String(diff.filter((l) => l.kind === "add").length)).replace("{del}", String(diff.filter((l) => l.kind === "del").length))
493
+ : "";
494
+ const toggle = () => setOpen((o) => !o);
495
+ const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
496
+ if (e.key === "Enter" || e.key === " ") {
497
+ e.preventDefault();
498
+ toggle();
499
+ }
500
+ };
501
+ return (
502
+ <Step kind={kind} tone={running ? "live" : failed ? "fail" : "dim"} live={running} className="group/tool">
503
+ <div className="pw-line">
504
+ {running ? <span className="pw-shimmer">{t.stepRunning}</span> : <span className="pw-verb" data-failed={failed || undefined}>{stepVerb(tt, kind, name)}</span>}
505
+ {summary.text ? (
506
+ <span className="pw-arg" title={argsTitle}>{summary.text}</span>
507
+ ) : (
508
+ <span className="pw-arg" style={{ color: "var(--dsw-label-caption)" }}>{displayToolName(name)}</span>
509
+ )}
510
+ {diffStat && <span className="pw-meta">{diffStat}</span>}
511
+ {summary.scriptLines > 1 && <span className="pw-meta">{t.commandLines.replace("{n}", String(summary.scriptLines))}</span>}
512
+ {running && <span className="pw-meta">{fmtSpan(elapsed)}</span>}
513
+ {!running && duration >= 1000 && <span className="pw-meta">{fmtSpan(duration)}</span>}
514
+ {onOpenFile && argPath && (
515
+ <button
516
+ type="button"
517
+ className="icon-btn opacity-0 group-hover/tool:opacity-100 focus-visible:opacity-100"
518
+ style={{ width: 22, height: 22, flex: "none" }}
519
+ title={t.fileOpenEditor}
520
+ aria-label={t.fileOpenEditor}
521
+ onClick={(e) => {
522
+ e.stopPropagation();
523
+ onOpenFile(argPath);
524
+ }}
525
+ >
526
+ <IconFileOutline16 size={13} />
527
+ </button>
528
+ )}
529
+ {onInspectTool && (
530
+ <button
531
+ type="button"
532
+ className="icon-btn opacity-0 group-hover/tool:opacity-100 focus-visible:opacity-100"
533
+ style={{ width: 22, height: 22, flex: "none" }}
534
+ title={t.viewInTrajectory}
535
+ aria-label={t.viewInTrajectory}
536
+ onClick={(e) => {
537
+ e.stopPropagation();
538
+ onInspectTool(id);
539
+ }}
540
+ >
541
+ <IconDataOutline16 size={13} />
542
+ </button>
543
+ )}
544
+ </div>
545
+ {showPreview && (
546
+ <div
547
+ className="pw-result"
548
+ data-failed={failed || undefined}
549
+ role={canExpand ? "button" : undefined}
550
+ tabIndex={canExpand ? 0 : undefined}
551
+ onClick={canExpand ? toggle : undefined}
552
+ onKeyDown={canExpand ? onKey : undefined}
553
+ title={canExpand ? (open ? t.collapseOutput : t.expandOutput) : undefined}
554
+ >
555
+ <span className="pw-rail">{RAIL}</span>
556
+ <span className="pw-lines">
557
+ {preview.head && <div className="pw-head">{preview.head}</div>}
558
+ {!open && preview.side === "tail" && preview.hidden > 0 && <div className="pw-more">… {t.moreLines.replace("{n}", String(preview.hidden))}</div>}
559
+ {!open && preview.lines.map((l, i) => <div key={i}>{l}</div>)}
560
+ {!open && preview.side !== "tail" && preview.hidden > 0 && <div className="pw-more">… {t.moreLines.replace("{n}", String(preview.hidden))}</div>}
561
+ {open && canExpand && <div className="pw-more">{t.collapseOutput}</div>}
562
+ </span>
563
+ </div>
564
+ )}
565
+ {diffLines && diffLines.length > 0 && (
566
+ <div className="pw-diff">
567
+ <DiffView lines={diffLines} language={languageForPath(argPath)} />
568
+ {diffHidden > 0 && (
569
+ <button type="button" className="pw-more" onClick={() => setOpen(true)}>
570
+ … {t.moreLines.replace("{n}", String(diffHidden))}
571
+ </button>
572
+ )}
573
+ {open && diff && diff.length > DIFF_PREVIEW && (
574
+ <button type="button" className="pw-more" onClick={() => setOpen(false)}>
575
+ {t.collapseOutput}
576
+ </button>
577
+ )}
578
+ </div>
579
+ )}
580
+ {open && canExpand && (
581
+ <div className="pw-output">
582
+ {fullCommand && <div className="cmdline">{fullCommand}</div>}
583
+ {state?.encodingLoss && <p className="mb-2 whitespace-normal" style={{ color: "var(--dsw-warn)" }}>{t.encodingLossWarning}</p>}
584
+ {output && <pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "inherit" }}>{output}</pre>}
585
+ </div>
586
+ )}
587
+ </Step>
588
+ );
589
+ });
590
+
591
+ // ---------- 思考步骤:进行中闪光「Thinking…」+ 流动的两行尾巴;完成后收成首句 ----------
592
+
593
+ const ThinkStep = memo(function ThinkStep({ text, live, startedAt }: { text: string; live?: boolean; startedAt?: number }) {
594
+ const [open, setOpen] = useState(false);
595
+ const { t } = useI18n();
596
+ const elapsed = useElapsed(startedAt, Boolean(live));
597
+ if (live) {
598
+ const one = text.replace(/\s+/g, " ").trim();
599
+ const tail = one.length > 220 ? `…${one.slice(-220)}` : one;
600
+ return (
601
+ <Step kind="think" tone="live" live>
602
+ <div className="pw-line">
603
+ <span className="pw-shimmer">{t.thinkingLive}…</span>
604
+ {startedAt && elapsed >= 3000 ? <span className="pw-meta">{fmtSpan(elapsed)}</span> : null}
605
+ </div>
606
+ {tail && <div className="pw-tail">{tail}</div>}
607
+ </Step>
608
+ );
609
+ }
610
+ return (
611
+ <Step kind="think">
612
+ <button type="button" className="pw-line pw-clickable" onClick={() => setOpen((o) => !o)} aria-expanded={open} title={t.thinkLabel}>
613
+ <span className="pw-gist">{firstSentence(text, 140)}</span>
614
+ </button>
615
+ {open && <pre className="pw-output pw-think-full">{text}</pre>}
616
+ </Step>
617
+ );
618
+ });
619
+
620
+ // ---------- 叙述:模型在工具之间说的话,作为阶段标题(基线对齐,主色) ----------
621
+
622
+ const NarrationBlock = memo(function NarrationBlock({ text, live, error }: { text: string; live?: boolean; error?: boolean }) {
623
+ return (
624
+ <div className="pw-narr" data-error={error || undefined}>
625
+ {live ? <StreamingText text={text} /> : <Markdown text={text} />}
626
+ </div>
627
+ );
628
+ });
629
+
630
+ // ---------- 模型错误 / 中止 / 自动重试 / 上下文注入 ----------
631
+
632
+ const ERROR_HINT_KEY: Record<ModelErrorKind, string> = {
633
+ rateLimit: "modelErrorRateLimit",
634
+ billing: "modelErrorBilling",
635
+ auth: "modelErrorAuth",
636
+ notFound: "modelErrorNotFound",
637
+ context: "modelErrorContext",
638
+ timeout: "modelErrorTimeout",
639
+ server: "modelErrorServer",
640
+ network: "modelErrorNetwork",
641
+ };
642
+
643
+ function ErrorStep({ message, count, model }: { message: string; count: number; model?: string }) {
644
+ const { t } = useI18n();
645
+ const tt = t as unknown as Dict;
646
+ const kind = classifyModelError(message);
647
+ const hint = kind ? tt[ERROR_HINT_KEY[kind]] : "";
648
+ return (
649
+ <Step kind="error" tone="warn">
650
+ <div className="pw-line">
651
+ <span className="pw-warn-text" title={message}>
652
+ {t.modelError}: {message}
653
+ {hint ? ` · ${hint}` : ""}
654
+ </span>
655
+ {count > 1 && <span className="pw-meta">{t.modelErrorTimes.replace("{n}", String(count))}</span>}
656
+ {model && <span className="pw-meta">{model}</span>}
657
+ </div>
658
+ </Step>
659
+ );
660
+ }
661
+
662
+ function AbortStep() {
663
+ const { t } = useI18n();
664
+ return (
665
+ <Step kind="abort">
666
+ <div className="pw-line">{t.turnAborted}</div>
667
+ </Step>
668
+ );
669
+ }
670
+
671
+ function RetryStep({ text }: { text: string }) {
672
+ const [open, setOpen] = useState(false);
673
+ const oneLine = text.replace(/\s+/g, " ").trim();
674
+ const [label, ...rest] = oneLine.split(":");
675
+ return (
676
+ <Step kind="retry" tone="warn" live>
677
+ <button type="button" className="pw-line pw-clickable" onClick={() => setOpen((o) => !o)} aria-expanded={open}>
678
+ <span className="pw-verb" style={{ color: "var(--dsw-warn)" }}>{label}</span>
679
+ {!open && rest.length > 0 && <span className="pw-gist">{rest.join(":")}</span>}
680
+ </button>
681
+ {open && <pre className="pw-output pw-think-full">{text}</pre>}
682
+ </Step>
683
+ );
684
+ }
685
+
686
+ function ContextStep({ resource, cwd }: { resource: ContextResource | string; cwd?: string }) {
687
+ const [open, setOpen] = useState(false);
688
+ const { t } = useI18n();
689
+ const normalized = typeof resource === "string" ? { path: resource, content: "", source: "project" as const } : resource;
690
+ return (
691
+ <Step kind="context">
692
+ <button type="button" className="pw-line pw-clickable" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
693
+ <span className="pw-verb">{t.contextInject}</span>
694
+ <span className="pw-arg" title={normalized.path}>{relativizePath(normalized.path, cwd)}</span>
695
+ </button>
696
+ {open && normalized.content && <pre className="pw-output" style={{ maxHeight: 160 }}>{normalized.content}</pre>}
697
+ </Step>
698
+ );
699
+ }
700
+
701
+ // ---------- 工作指示:π 记号 + 英文状态词闪光(没有正在进行的步骤时)或「Working for 12s」;整行固定英文 ----------
702
+
703
+ function WorkingStep({ startedAt, outputTokens, workingMessage, hasLiveStep }: { startedAt: number; outputTokens?: number; workingMessage?: string | null; hasLiveStep: boolean }) {
704
+ const elapsed = useElapsed(startedAt, true);
705
+ const verb = WORKING_VERBS[Math.floor(elapsed / 3000) % WORKING_VERBS.length];
706
+ // 扩展可以用 setWorkingMessage 覆盖这行文案(pi 终端同款能力);有步骤在闪光时这里只报时长
707
+ const label = workingMessage || (hasLiveStep ? null : `${verb}…`);
708
+ return (
709
+ <Step kind="work" tone="live" live>
710
+ <div className="pw-line">
711
+ {label ? <span className="pw-shimmer">{label}</span> : <span className="pw-verb">{fillBold(WORKING_FOR, { d: fmtSpan(elapsed) })}</span>}
712
+ {label && <span className="pw-meta">{fmtSpan(elapsed)}</span>}
713
+ {outputTokens ? <span className="pw-meta">↓ {fmtTok(outputTokens)} tokens</span> : null}
714
+ </div>
715
+ </Step>
716
+ );
717
+ }
718
+
719
+ // ---------- 回合内容 → 步骤项;已完成回合把连续的思考 / 命令折成摘要行 ----------
720
+
721
+ type Item =
722
+ | { kind: "think"; key: string; msgIndex: number; text: string; live: boolean; startedAt?: number }
723
+ | { kind: "narr"; key: string; msgIndex: number; text: string; live: boolean; error: boolean }
724
+ | { kind: "image"; key: string; msgIndex: number; data: string; mimeType: string }
725
+ | { kind: "tool"; key: string; msgIndex: number; call: ToolCallContent; state?: ToolCardState }
726
+ | { kind: "error"; key: string; msgIndex: number; message: string; count: number; model?: string }
727
+ | { kind: "abort"; key: string; msgIndex: number };
728
+
729
+ type Run = { kind: "run"; key: string; items: Item[]; hasThink: boolean; cmds: number; reads: number; searches: number; durationMs?: number };
730
+ type Block = { kind: "item"; item: Item } | Run;
731
+
732
+ function buildItems(messages: WebMessage[], k: number, tools: Record<string, ToolCardState>, live: boolean, omitText: boolean, errorCount: number | undefined): Item[] {
733
+ const message = messages[k];
734
+ const lastIndex = message.content.length - 1;
735
+ const isError = message.stopReason === "error";
736
+ const items: Item[] = [];
737
+ message.content.forEach((c, i) => {
738
+ const key = `${k}-${i}`;
739
+ if (c.type === "thinking" && c.thinking.trim()) items.push({ kind: "think", key, msgIndex: k, text: c.thinking, live: live && i === lastIndex, startedAt: message.timestamp });
740
+ else if (c.type === "toolCall") items.push({ kind: "tool", key: `${key}-${c.id}`, msgIndex: k, call: c, state: tools[c.id] });
741
+ else if (c.type === "text" && !omitText && c.text.trim()) items.push({ kind: "narr", key, msgIndex: k, text: c.text, live: live && i === lastIndex, error: isError });
742
+ else if (c.type === "image" && !omitText) items.push({ kind: "image", key, msgIndex: k, data: c.data, mimeType: c.mimeType });
743
+ });
744
+ if (isError && (errorCount ?? 1) > 0) items.push({ kind: "error", key: `${k}-err`, msgIndex: k, message: message.errorMessage || message.stopReason || "error", count: errorCount ?? 1, model: message.model });
745
+ if (message.stopReason === "aborted") items.push({ kind: "abort", key: `${k}-abort`, msgIndex: k });
746
+ return items;
747
+ }
748
+
749
+ /** 可折进摘要的步骤:完成的思考,以及成功完成的命令 / 读取 / 搜索(编辑与写入永远单独可见) */
750
+ function isCollapsible(it: Item, finalIdx: number): boolean {
751
+ if (it.msgIndex === finalIdx) return false;
752
+ if (it.kind === "think") return !it.live;
753
+ if (it.kind === "tool") {
754
+ const s = it.state;
755
+ if (!s || s.state !== "done" || s.isError) return false;
756
+ const k = toolKind(it.call.name);
757
+ return k === "cmd" || k === "read" || k === "search";
758
+ }
759
+ return false;
760
+ }
761
+
762
+ function collapseRuns(items: Item[], finalIdx: number, spanOf: (a: number, b: number) => number | undefined): Block[] {
763
+ const blocks: Block[] = [];
764
+ let i = 0;
765
+ while (i < items.length) {
766
+ if (!isCollapsible(items[i], finalIdx)) {
767
+ blocks.push({ kind: "item", item: items[i] });
768
+ i += 1;
769
+ continue;
770
+ }
771
+ let j = i;
772
+ while (j < items.length && isCollapsible(items[j], finalIdx)) j += 1;
773
+ const run = items.slice(i, j);
774
+ let cmds = 0;
775
+ let reads = 0;
776
+ let searches = 0;
777
+ let hasThink = false;
778
+ for (const it of run) {
779
+ if (it.kind === "think") hasThink = true;
780
+ else if (it.kind === "tool") {
781
+ const k = toolKind(it.call.name);
782
+ if (k === "cmd") cmds += 1;
783
+ else if (k === "read") reads += 1;
784
+ else searches += 1;
785
+ }
786
+ }
787
+ const durationMs = spanOf(run[0].msgIndex, run[run.length - 1].msgIndex);
788
+ if (cmds + reads + searches === 0 && durationMs === undefined) {
789
+ for (const it of run) blocks.push({ kind: "item", item: it });
790
+ } else {
791
+ blocks.push({ kind: "run", key: `run-${run[0].key}`, items: run, hasThink, cmds, reads, searches, durationMs });
792
+ }
793
+ i = j;
794
+ }
795
+ return blocks;
796
+ }
797
+
798
+ function SummaryStep({ run, expanded, onToggle }: { run: Run; expanded: boolean; onToggle: () => void }) {
799
+ const { t } = useI18n();
800
+ const parts: ReactNode[][] = [];
801
+ if (run.hasThink && run.durationMs !== undefined) parts.push(fillBold(t.sumThought, { d: fmtSpan(run.durationMs) }));
802
+ if (run.cmds) parts.push(fillBold(run.cmds === 1 ? t.sumRan1 : t.sumRan, { n: String(run.cmds) }));
803
+ if (run.reads) parts.push(fillBold(run.reads === 1 ? t.sumRead1 : t.sumRead, { n: String(run.reads) }));
804
+ if (run.searches) parts.push(fillBold(run.searches === 1 ? t.sumSearched1 : t.sumSearched, { n: String(run.searches) }));
805
+ // 英文时首字母大写(模板里只有第一段是大写开头)
806
+ if (parts.length && typeof parts[0][0] === "string") parts[0][0] = (parts[0][0] as string).replace(/^[a-z]/, (ch) => ch.toUpperCase());
807
+ return (
808
+ <Step kind={run.hasThink ? "summary" : run.cmds ? "cmd" : run.reads ? "read" : "search"}>
809
+ <button type="button" className="pw-line pw-clickable pw-summary" aria-expanded={expanded} onClick={onToggle}>
810
+ <span className="pw-gist">
811
+ {parts.map((p, i) => (
812
+ <span key={i}>
813
+ {i > 0 ? t.sumJoin : ""}
814
+ {p}
815
+ </span>
816
+ ))}
817
+ </span>
818
+ <IconChevronRight14 size={12} className="pw-chev" />
819
+ </button>
820
+ </Step>
821
+ );
822
+ }
823
+
824
+ function ItemView({ item, cwd, onInspectTool, onOpenFile }: { item: Item; cwd?: string; onInspectTool?: (toolCallId: string) => void; onOpenFile?: (path: string) => void }) {
825
+ switch (item.kind) {
826
+ case "think":
827
+ return <ThinkStep text={item.text} live={item.live} startedAt={item.startedAt} />;
828
+ case "narr":
829
+ return <NarrationBlock text={item.text} live={item.live} error={item.error} />;
830
+ case "image":
831
+ return (
832
+ <div className="pw-narr">
833
+ <img src={`data:${item.mimeType};base64,${item.data}`} alt="" className="my-1 max-h-96 max-w-full rounded-2xl object-contain" />
834
+ </div>
835
+ );
836
+ case "tool":
837
+ return <ToolStep id={item.call.id} name={item.call.name} args={item.call.arguments} state={item.state} cwd={cwd} onInspectTool={onInspectTool} onOpenFile={onOpenFile} />;
838
+ case "error":
839
+ return <ErrorStep message={item.message} count={item.count} model={item.model} />;
840
+ case "abort":
841
+ return <AbortStep />;
842
+ default:
843
+ return null;
844
+ }
845
+ }
846
+
847
+ // ---------- 最终回答:过程之外、完整 Markdown、带操作行 ----------
848
+
849
+ const FinalAnswer = memo(function FinalAnswer({
850
+ message,
851
+ onFork,
852
+ showActions,
853
+ turnStartMs,
854
+ }: {
855
+ message: WebMessage;
856
+ onFork?: (entryId: string) => void;
857
+ showActions: boolean;
858
+ turnStartMs?: number;
859
+ }) {
860
+ const textAll = message.content
861
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
862
+ .map((c) => c.text)
863
+ .join("");
864
+ return (
865
+ <div className="group w-full" style={{ marginTop: 14 }} data-role="assistant">
866
+ {message.content.map((c, i) => {
867
+ if (c.type === "text" && c.text.trim()) return <Markdown key={i} text={c.text} />;
868
+ if (c.type === "image") return <img key={i} src={`data:${c.mimeType};base64,${c.data}`} alt="" className="my-2 max-h-96 max-w-full rounded-2xl object-contain" />;
869
+ return null;
870
+ })}
871
+ {showActions && (
872
+ <MessageActions
873
+ text={textAll.trim()}
874
+ onFork={message.id && onFork ? () => onFork(message.id!) : undefined}
875
+ durationMs={(message.endedAt ?? message.timestamp) != null && turnStartMs != null ? Math.max(0, (message.endedAt ?? message.timestamp)! - turnStartMs) : undefined}
876
+ usage={message.usage}
877
+ time={message.timestamp}
878
+ />
879
+ )}
880
+ </div>
881
+ );
882
+ });
883
+
884
+ /** 行级 memo:流式期间只有最后一条消息变化,历史行全部跳过重渲染 */
885
+ const UserMessage = memo(function UserMessage({ message }: { message: WebMessage }) {
886
+ const text = message.content
887
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
888
+ .map((c) => c.text)
889
+ .join("\n");
890
+ const images = message.content.filter((content) => content.type === "image");
891
+ return (
892
+ // 回合边界:用户消息前留 28px(比回合内 8px 大得多),长对话里一眼找到“这一轮从哪开始”
893
+ <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
894
+ {images.length > 0 && (
895
+ <div className="mb-2 flex max-w-[85%] flex-wrap justify-end gap-2">
896
+ {images.map((image, index) => (
897
+ <img
898
+ key={index}
899
+ src={`data:${image.mimeType};base64,${image.data}`}
900
+ alt=""
901
+ className="max-h-72 max-w-full rounded-2xl object-contain"
902
+ style={{ border: "0.5px solid var(--dsw-border-l2)" }}
903
+ />
904
+ ))}
905
+ </div>
906
+ )}
907
+ {/* 用户消息也走 Markdown:贴进来的代码块/列表不再是一坨纯文本 */}
908
+ {text && <div className="msg-user-bubble"><Markdown text={text} /></div>}
909
+ {/* 用户消息只有复制操作,不提供分支;时钟在图标左侧(dsh clock=start) */}
910
+ {text && <MessageActions text={text} clockStart={message.timestamp !== undefined} time={message.timestamp} />}
911
+ </div>
912
+ );
913
+ });
914
+
915
+ // ---------- 回合:用户消息 → 过程步骤 → 最终回答 ----------
916
+
917
+ type Turn = { key: string; userIndex: number; assistantIndexes: number[] };
918
+
919
+ function buildTurns(messages: WebMessage[]): Turn[] {
920
+ const out: Turn[] = [];
921
+ let cur: Turn | null = null;
922
+ messages.forEach((m, i) => {
923
+ if (m.role === "user") {
924
+ cur = { key: `u${i}`, userIndex: i, assistantIndexes: [] };
925
+ out.push(cur);
926
+ } else if (m.role === "assistant") {
927
+ if (!cur) {
928
+ cur = { key: `a${i}`, userIndex: -1, assistantIndexes: [] };
929
+ out.push(cur);
930
+ }
931
+ cur.assistantIndexes.push(i);
932
+ }
933
+ });
934
+ return out;
935
+ }
936
+
937
+ /** 回合的最终回答:有正文、没有工具调用、且不是中途状态(toolUse / pending / error / aborted) */
938
+ function isFinalAnswer(m: WebMessage): boolean {
939
+ const hasText = m.content.some((c) => c.type === "text" && c.text.trim());
940
+ const hasTool = m.content.some((c) => c.type === "toolCall");
941
+ const sr = m.stopReason ?? "stop";
942
+ return hasText && !hasTool && sr !== "toolUse" && sr !== "pending" && sr !== "error" && sr !== "aborted";
943
+ }
944
+
945
+ function TurnBlock({
946
+ turn,
947
+ messages,
948
+ tools,
949
+ cwd,
950
+ isStreaming,
951
+ isLast,
952
+ lastAssistantIndex,
953
+ contextFiles,
954
+ retryNotice,
955
+ streamStartedAt,
956
+ runningTool,
957
+ outputTokens,
958
+ workingMessage,
959
+ onFork,
960
+ onInspectTool,
961
+ onOpenFile,
962
+ }: {
963
+ turn: Turn;
964
+ messages: WebMessage[];
965
+ tools: Record<string, ToolCardState>;
966
+ cwd?: string;
967
+ isStreaming: boolean;
968
+ isLast: boolean;
969
+ lastAssistantIndex: number;
970
+ contextFiles?: Array<ContextResource | string>;
971
+ retryNotice?: string | null;
972
+ streamStartedAt: number | null;
973
+ runningTool?: string;
974
+ outputTokens?: number;
975
+ workingMessage?: string | null;
976
+ onFork?: (entryId: string) => void;
977
+ onInspectTool?: (toolCallId: string) => void;
978
+ onOpenFile?: (path: string) => void;
979
+ }) {
980
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
981
+ const user = turn.userIndex >= 0 ? messages[turn.userIndex] : undefined;
982
+ const assistants = turn.assistantIndexes;
983
+ const lastIdx = assistants[assistants.length - 1];
984
+ const turnLive = isStreaming && isLast;
985
+ // 正在流式的那条永远先在过程里,回合结束后才作为最终回答"走出"过程
986
+ const finalIdx = lastIdx !== undefined && isFinalAnswer(messages[lastIdx]) && !(isStreaming && lastIdx === lastAssistantIndex) ? lastIdx : -1;
987
+
988
+ // 连续相同的模型错误(内容为空的错误消息)合并成一行,标连续次数
989
+ const errorCounts = new Map<number, number>();
990
+ {
991
+ let runStart = -1;
992
+ let runMsg = "";
993
+ for (const k of assistants) {
994
+ const m = messages[k];
995
+ const bare = m.stopReason === "error" && !m.content.some((c) => (c.type === "text" && c.text.trim()) || c.type === "toolCall");
996
+ if (bare && runStart >= 0 && (m.errorMessage ?? "") === runMsg) {
997
+ errorCounts.set(k, 0);
998
+ errorCounts.set(runStart, (errorCounts.get(runStart) ?? 1) + 1);
999
+ continue;
1000
+ }
1001
+ if (bare) {
1002
+ runStart = k;
1003
+ runMsg = m.errorMessage ?? "";
1004
+ errorCounts.set(k, 1);
1005
+ continue;
1006
+ }
1007
+ runStart = -1;
1008
+ }
1009
+ }
1010
+
1011
+ // 过程里要渲染的消息:最终回答只在有思考块时进过程(只出思考)
1012
+ const processIdx = assistants.filter((k) => k !== finalIdx || messages[k].content.some((c) => c.type === "thinking" && c.thinking.trim()));
1013
+ const items = processIdx.flatMap((k) => buildItems(messages, k, tools, isStreaming && k === lastAssistantIndex, k === finalIdx, errorCounts.get(k)));
1014
+ // 一段思考 + 命令的墙钟:从这段首条消息开始生成,到下一条助手消息开始生成(工具结果回来后)
1015
+ const spanOf = (a: number, b: number): number | undefined => {
1016
+ const start = messages[a]?.timestamp;
1017
+ const next = assistants.find((k) => k > b);
1018
+ const end = next !== undefined ? messages[next]?.timestamp : messages[b]?.endedAt;
1019
+ return typeof start === "number" && typeof end === "number" && end >= start ? end - start : undefined;
1020
+ };
1021
+ const blocks: Block[] = turnLive ? items.map((item) => ({ kind: "item", item })) : collapseRuns(items, finalIdx, spanOf);
1022
+ const last = items[items.length - 1];
1023
+ const hasLiveStep = Boolean(runningTool) || (last?.kind === "think" && last.live) || (last?.kind === "narr" && last.live);
1024
+ const showProcess = items.length > 0 || Boolean(contextFiles?.length) || turnLive || (isLast && Boolean(retryNotice));
1025
+
1026
+ return (
1027
+ <>
1028
+ {user && <UserMessage message={user} />}
1029
+ {showProcess && (
1030
+ <div className="pw-turn">
1031
+ {contextFiles?.map((resource, i) => (
1032
+ <ContextStep key={`ctx-${typeof resource === "string" ? resource : `${resource.source}-${resource.path}`}-${i}`} resource={resource} cwd={cwd} />
1033
+ ))}
1034
+ {blocks.map((b) => {
1035
+ if (b.kind === "item") return <ItemView key={b.item.key} item={b.item} cwd={cwd} onInspectTool={onInspectTool} onOpenFile={onOpenFile} />;
1036
+ const open = expanded.has(b.key);
1037
+ return (
1038
+ <div key={b.key} className="pw-run" data-open={open || undefined}>
1039
+ <SummaryStep
1040
+ run={b}
1041
+ expanded={open}
1042
+ onToggle={() => setExpanded((prev) => {
1043
+ const next = new Set(prev);
1044
+ if (next.has(b.key)) next.delete(b.key);
1045
+ else next.add(b.key);
1046
+ return next;
1047
+ })}
1048
+ />
1049
+ {open && b.items.map((item) => <ItemView key={item.key} item={item} cwd={cwd} onInspectTool={onInspectTool} onOpenFile={onOpenFile} />)}
1050
+ </div>
1051
+ );
1052
+ })}
1053
+ {isLast && retryNotice && <RetryStep text={retryNotice} />}
1054
+ {turnLive && streamStartedAt && <WorkingStep startedAt={streamStartedAt} outputTokens={outputTokens} workingMessage={workingMessage} hasLiveStep={hasLiveStep} />}
1055
+ </div>
1056
+ )}
1057
+ {finalIdx >= 0 && <FinalAnswer message={messages[finalIdx]} onFork={onFork} showActions={!isStreaming || !isLast} turnStartMs={user?.timestamp} />}
1058
+ </>
1059
+ );
1060
+ }
1061
+
1062
+ function fmtTok(n: number): string {
1063
+ const scaled = (value: number) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
1064
+ return n >= 1_000_000 ? `${scaled(n / 1_000_000)}M` : n >= 1000 ? `${scaled(n / 1000)}K` : String(n);
1065
+ }
1066
+
1067
+ function fmtDuration(ms: number): string {
1068
+ const seconds = ms / 1000;
1069
+ if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
1070
+ const whole = Math.round(seconds);
1071
+ return `${Math.floor(whole / 60)}m${whole % 60}s`;
1072
+ }
1073
+
1074
+ function fmtThroughput(tokensPerSecond: number): string {
1075
+ return tokensPerSecond >= 10 ? String(Math.round(tokensPerSecond)) : String(Math.round(tokensPerSecond * 10) / 10);
1076
+ }
1077
+
1078
+ export function SessionStatsBar({
1079
+ stats,
1080
+ }: {
1081
+ stats: WebStats | null;
1082
+ }) {
1083
+ const { t } = useI18n();
1084
+ if (!stats || stats.assistantMessages === 0) return null;
1085
+ const groups: string[] = [
1086
+ t.statsCounts
1087
+ .replace("{turns}", String(stats.userMessages))
1088
+ .replace("{steps}", String(stats.assistantMessages)),
1089
+ ];
1090
+ const durations: string[] = [];
1091
+ if (stats.llmMs > 0) durations.push(`LLM ${fmtDuration(stats.llmMs)}`);
1092
+ if (stats.toolMs > 0) durations.push(`${t.toolCalls} ${fmtDuration(stats.toolMs)}`);
1093
+ if (durations.length) groups.push(durations.join(" · "));
1094
+ const speeds: string[] = [];
1095
+ if (stats.ttftSteps > 0) speeds.push(`${t.ttftAverage} ${fmtDuration(stats.ttftMs / stats.ttftSteps)}`);
1096
+ if (stats.decodeMs > 0) speeds.push(`${fmtThroughput(stats.decodeTokens / (stats.decodeMs / 1000))} tok/s`);
1097
+ if (speeds.length) groups.push(speeds.join(" · "));
1098
+ const billedInput = stats.tokens.input + stats.tokens.cacheRead + stats.tokens.cacheWrite;
1099
+ if (billedInput > 0 || stats.tokens.output > 0) {
1100
+ if (billedInput > 0) groups.push(`${t.cacheHits} ${Math.round((stats.tokens.cacheRead / billedInput) * 100)}%`);
1101
+ groups.push(`${t.inputTokens} ${fmtTok(billedInput)} tok · ${t.outputTokens} ${fmtTok(stats.tokens.output)} tok`);
1102
+ }
1103
+ const line = groups.join(" | ");
1104
+ return (
1105
+ // 悬停显示完整统计:自定义浮层固定在统计条上方(原生 title 位置不可控)
1106
+ <div className="group relative mx-auto mt-1 block w-full px-4 pt-1 text-center">
1107
+ <div
1108
+ className="overflow-hidden text-ellipsis whitespace-nowrap"
1109
+ style={{ fontSize: "var(--dsh-content-font-size-secondary, 13px)", lineHeight: "20px", color: "var(--dsw-label-caption)" }}
1110
+ >
1111
+ {line}
1112
+ </div>
1113
+ <div
1114
+ className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1 hidden w-max max-w-[90vw] -translate-x-1/2 rounded-xl px-3 py-2 text-left group-hover:block"
1115
+ style={{ background: "var(--dsw-glass-popover)", border: "0.5px solid var(--dsw-border-l2)", boxShadow: "var(--dsw-elevation-soft)", fontSize: "var(--dsh-content-font-size-secondary, 13px)", lineHeight: "20px", color: "var(--dsw-label-secondary)" }}
1116
+ >
1117
+ {line}
1118
+ </div>
1119
+ </div>
1120
+ );
1121
+ }
1122
+
1123
+ export function ChatWindow({
1124
+ messages,
1125
+ tools,
1126
+ contextFiles,
1127
+ cwd,
1128
+ onFork,
1129
+ isStreaming = false,
1130
+ error,
1131
+ connected = true,
1132
+ onClearError,
1133
+ retryNotice,
1134
+ compaction,
1135
+ onClearCompaction,
1136
+ workingMessage,
1137
+ stats,
1138
+ onOpenTrajectory,
1139
+ onOpenFile,
1140
+ }: {
1141
+ messages: WebMessage[];
1142
+ tools: Record<string, ToolCardState>;
1143
+ contextFiles?: Array<ContextResource | string>;
1144
+ /** 工作区路径:工具行里的命令去掉 cd 前缀、文件路径显示为相对路径 */
1145
+ cwd?: string;
1146
+ onFork?: (entryId: string) => void;
1147
+ isStreaming?: boolean;
1148
+ /** 行内错误提示(显示在消息流末尾,替代右下角弹窗) */
1149
+ error?: string | null;
1150
+ connected?: boolean;
1151
+ onClearError?: () => void;
1152
+ /** 自动重试通知(轨道内一行,点击展开详情) */
1153
+ retryNotice?: string | null;
1154
+ compaction?: { phase: "start" | "end"; errorMessage?: string; ts: number } | null;
1155
+ onClearCompaction?: () => void;
1156
+ /** 扩展覆盖的工作中文案(setWorkingMessage) */
1157
+ workingMessage?: string | null;
1158
+ /** 会话统计:工作指示里显示本轮输出 token */
1159
+ stats?: WebStats | null;
1160
+ /** 轨迹条目:用于把工具行链接到轨迹 inspector */
1161
+ trajectory?: TrajEntry[];
1162
+ onOpenTrajectory?: (toolCallId: string) => void;
1163
+ /** 在本机编辑器打开工具行涉及的文件 */
1164
+ onOpenFile?: (path: string) => void;
1165
+ }) {
1166
+ const scrollRef = useRef<HTMLDivElement>(null);
1167
+ const stickToBottom = useRef(true);
1168
+ const { t } = useI18n();
1169
+ // 脱离底部后累计的新内容条数(回到底部按钮上的角标)
1170
+ const [unseen, setUnseen] = useState(0);
1171
+ /** 是否已离开底部(state 版:ref 变化不会触发渲染,静态会话翻页时按钮出不来) */
1172
+ const [awayFromBottom, setAwayFromBottom] = useState(false);
1173
+ const prevLenRef = useRef(messages.length);
1174
+ // 本轮开始时间:工作指示的计时基准。以本轮用户消息的时间戳为准(中途打开页面也能显示真实已用时长),没有就取现在
1175
+ const lastUserTs = useMemo(() => {
1176
+ for (let i = messages.length - 1; i >= 0; i -= 1) if (messages[i].role === "user") return messages[i].timestamp;
1177
+ return undefined;
1178
+ }, [messages]);
1179
+ const [streamStartedAt, setStreamStartedAt] = useState<number | null>(null);
1180
+ useEffect(() => {
1181
+ setStreamStartedAt((prev) => (isStreaming ? prev ?? (lastUserTs && Date.now() - lastUserTs < 6 * 3600_000 ? lastUserTs : Date.now()) : null));
1182
+ }, [isStreaming, lastUserTs]);
1183
+ const runningTool = useMemo(() => Object.values(tools).find((tool) => tool.state === "running")?.name, [tools]);
1184
+ const lastAssistantIndex = useMemo(() => {
1185
+ for (let i = messages.length - 1; i >= 0; i -= 1) if (messages[i].role === "assistant") return i;
1186
+ return -1;
1187
+ }, [messages]);
1188
+ const turns = useMemo(() => buildTurns(messages), [messages]);
1189
+ const firstUser = useMemo(() => messages.findIndex((m) => m.role === "user"), [messages]);
1190
+
1191
+ useEffect(() => {
1192
+ const grew = messages.length > prevLenRef.current;
1193
+ prevLenRef.current = messages.length;
1194
+ if (!stickToBottom.current) {
1195
+ if (grew) setUnseen((n) => n + 1);
1196
+ return;
1197
+ }
1198
+ const frame = requestAnimationFrame(() => {
1199
+ const node = scrollRef.current;
1200
+ if (node) node.scrollTop = node.scrollHeight;
1201
+ });
1202
+ return () => cancelAnimationFrame(frame);
1203
+ }, [messages, tools, error, connected, retryNotice, compaction, isStreaming]);
1204
+
1205
+ const scrollToBottom = () => {
1206
+ const node = scrollRef.current;
1207
+ if (node) node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
1208
+ stickToBottom.current = true;
1209
+ setAwayFromBottom(false);
1210
+ setUnseen(0);
1211
+ };
1212
+
1213
+ return (
1214
+ <div className="relative flex h-full min-h-0 flex-col">
1215
+ <div
1216
+ ref={scrollRef}
1217
+ className="min-h-0 flex-1 overflow-y-auto"
1218
+ role="log"
1219
+ aria-live="polite"
1220
+ aria-relevant="additions"
1221
+ onScroll={(event) => {
1222
+ const node = event.currentTarget;
1223
+ const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 96;
1224
+ stickToBottom.current = atBottom;
1225
+ setAwayFromBottom(!atBottom);
1226
+ if (atBottom) setUnseen(0);
1227
+ }}
1228
+ >
1229
+ <div className="mx-auto flex w-full flex-col px-4 py-6" style={{ maxWidth: "var(--dsh-chat-content-width)" }}>
1230
+ {turns.map((turn, ti) => (
1231
+ <TurnBlock
1232
+ key={turn.key}
1233
+ turn={turn}
1234
+ messages={messages}
1235
+ tools={tools}
1236
+ cwd={cwd}
1237
+ isStreaming={isStreaming}
1238
+ isLast={ti === turns.length - 1}
1239
+ lastAssistantIndex={lastAssistantIndex}
1240
+ contextFiles={turn.userIndex >= 0 && turn.userIndex === firstUser ? contextFiles : undefined}
1241
+ retryNotice={retryNotice}
1242
+ streamStartedAt={streamStartedAt}
1243
+ runningTool={runningTool}
1244
+ outputTokens={stats?.tokens.output}
1245
+ workingMessage={workingMessage}
1246
+ onFork={onFork}
1247
+ onInspectTool={onOpenTrajectory}
1248
+ onOpenFile={onOpenFile}
1249
+ />
1250
+ ))}
1251
+ {/* 还没有任何消息就已在流式(极少见):单独给一条工作指示 */}
1252
+ {isStreaming && streamStartedAt && turns.length === 0 && (
1253
+ <div className="pw-turn">
1254
+ <WorkingStep startedAt={streamStartedAt} outputTokens={stats?.tokens.output} workingMessage={workingMessage} hasLiveStep={Boolean(runningTool)} />
1255
+ </div>
1256
+ )}
1257
+ {/* 重连提示:跟随消息流显示在最后一条输出下面 */}
1258
+ {!connected && (
1259
+ <div className="mt-3 flex justify-center">
1260
+ <div
1261
+ className="rounded-full px-3 py-1"
1262
+ style={{ fontSize: "var(--piweb-chat-font-t)", color: "var(--dsw-label-caption)", border: "0.5px dashed var(--dsw-border-l3)" }}
1263
+ >
1264
+ {t.reconnecting}
1265
+ </div>
1266
+ </div>
1267
+ )}
1268
+ {compaction && (
1269
+ <div className="mt-3 flex justify-center" role="status">
1270
+ <div className="flex items-center gap-2 rounded-xl px-3 py-2" style={{ fontSize: "var(--piweb-chat-font-s)", background: "var(--dsw-hover)", color: "var(--dsw-label-secondary)" }}>
1271
+ <span>{compaction.phase === "start" ? t.compacting : compaction.errorMessage ? `${t.compactionFailed}: ${compaction.errorMessage}` : t.compactionComplete}</span>
1272
+ {compaction.phase === "end" && <button type="button" onClick={onClearCompaction} aria-label={t.close} className="icon-btn">×</button>}
1273
+ </div>
1274
+ </div>
1275
+ )}
1276
+ {error && (
1277
+ <button
1278
+ className="mt-3 rounded-xl px-3 py-2 text-left"
1279
+ style={{ fontSize: "var(--piweb-chat-font-s)", background: "var(--dsw-danger)", color: "white" }}
1280
+ onClick={onClearError}
1281
+ role="alert"
1282
+ >
1283
+ {error}
1284
+ </button>
1285
+ )}
1286
+ </div>
1287
+ </div>
1288
+ {/* 右侧大纲导航:从助手回答的标题和用户消息生成,随滚动高亮,悬停展开 */}
1289
+ <OutlineRail scrollRef={scrollRef} revision={`${messages.length}:${isStreaming ? 1 : 0}`} />
1290
+ {/* 脱离底部时的“回到底部”浮钮:与输入框卡片同轴,挂在右上角顶部,图标带新内容角标 */}
1291
+ {(unseen > 0 || awayFromBottom) && (
1292
+ <div
1293
+ className="pointer-events-none absolute inset-x-0 bottom-2 mx-auto flex w-full justify-end"
1294
+ style={{ maxWidth: "var(--dsh-composer-card-max-width)" }}
1295
+ >
1296
+ <button
1297
+ type="button"
1298
+ className="pointer-events-auto relative flex h-8 w-8 items-center justify-center rounded-full"
1299
+ style={{ background: "var(--dsw-glass-popover)", border: "0.5px solid var(--dsw-border-l2)", boxShadow: "var(--dsw-elevation-soft)", color: "var(--dsw-label-secondary)" }}
1300
+ onClick={scrollToBottom}
1301
+ title={t.backToBottom}
1302
+ >
1303
+ {/* 向下箭头 */}
1304
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
1305
+ <path d="M8 3.5v9M4.5 9L8 12.5 11.5 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
1306
+ </svg>
1307
+ {unseen > 0 && (
1308
+ <span
1309
+ className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1"
1310
+ style={{ fontSize: 10, fontWeight: 600, background: "var(--dsw-accent)", color: "#fff" }}
1311
+ >
1312
+ {unseen > 99 ? "99+" : unseen}
1313
+ </span>
1314
+ )}
1315
+ </button>
1316
+ </div>
1317
+ )}
1318
+ </div>
1319
+ );
1320
+ }