@rexvane/piweb 0.3.21 → 0.3.22

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 (293) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -4
  3. package/.next/build-manifest.json +5 -5
  4. package/.next/next-minimal-server.js.nft.json +1 -1
  5. package/.next/next-server.js.nft.json +1 -1
  6. package/.next/prerender-manifest.json +7 -7
  7. package/.next/react-loadable-manifest.json +10 -10
  8. package/.next/required-server-files.js +16 -1
  9. package/.next/required-server-files.json +16 -1
  10. package/.next/routes-manifest.json +12 -0
  11. package/.next/server/app/_global-error/page.js +2 -2
  12. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  13. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_global-error.html +1 -1
  15. package/.next/server/app/_global-error.meta +8 -0
  16. package/.next/server/app/_global-error.rsc +15 -11
  17. package/.next/server/app/_global-error.segments/_full.segment.rsc +15 -11
  18. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +20 -16
  19. package/.next/server/app/_global-error.segments/_tree.segment.rsc +3 -1
  20. package/.next/server/app/_not-found/page.js +1 -1
  21. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/_not-found.html +1 -1
  24. package/.next/server/app/_not-found.meta +8 -0
  25. package/.next/server/app/_not-found.rsc +17 -13
  26. package/.next/server/app/_not-found.segments/_full.segment.rsc +17 -13
  27. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +24 -20
  28. package/.next/server/app/_not-found.segments/_tree.segment.rsc +4 -2
  29. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  30. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  31. package/.next/server/app/api/agent/[id]/route.js +1 -1
  32. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  33. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  34. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  35. package/.next/server/app/api/agent/new/route.js +1 -1
  36. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  37. package/.next/server/app/api/config/route.js +1 -1
  38. package/.next/server/app/api/config/route.js.nft.json +1 -1
  39. package/.next/server/app/api/files/route.js +1 -1
  40. package/.next/server/app/api/files/route.js.nft.json +1 -1
  41. package/.next/server/app/api/fs/route.js +1 -1
  42. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  43. package/.next/server/app/api/git/route.js +4 -4
  44. package/.next/server/app/api/git/route.js.nft.json +1 -1
  45. package/.next/server/app/api/github/route.js +5 -0
  46. package/.next/server/app/api/github/route.js.nft.json +1 -0
  47. package/.next/server/app/api/github/route_client-reference-manifest.js +1 -0
  48. package/.next/server/app/api/growth/route.js +1 -1
  49. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  50. package/.next/server/app/api/health/route.js +1 -1
  51. package/.next/server/app/api/health/route.js.nft.json +1 -1
  52. package/.next/server/app/api/models/defaults/route.js +1 -0
  53. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -0
  54. package/.next/server/app/api/models/defaults/route_client-reference-manifest.js +1 -0
  55. package/.next/server/app/api/models/route.js +1 -1
  56. package/.next/server/app/api/models/route.js.nft.json +1 -1
  57. package/.next/server/app/api/pi-settings/route.js +1 -1
  58. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  59. package/.next/server/app/api/plugins/route.js +1 -1
  60. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  61. package/.next/server/app/api/prompts/route.js +1 -1
  62. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  63. package/.next/server/app/api/remote/route.js +3 -3
  64. package/.next/server/app/api/remote/route.js.nft.json +1 -1
  65. package/.next/server/app/api/security/route.js +1 -1
  66. package/.next/server/app/api/security/route.js.nft.json +1 -1
  67. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  68. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  69. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  70. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  71. package/.next/server/app/api/sessions/import/route.js +2 -2
  72. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  73. package/.next/server/app/api/sessions/route.js +1 -1
  74. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  75. package/.next/server/app/api/skills/route.js +2 -2
  76. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  77. package/.next/server/app/api/update/route.js +2 -2
  78. package/.next/server/app/api/update/route.js.nft.json +1 -1
  79. package/.next/server/app/api/version/route.js +1 -1
  80. package/.next/server/app/api/version/route.js.nft.json +1 -1
  81. package/.next/server/app/api/web-auth/route.js +1 -1
  82. package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
  83. package/.next/server/app/api/workspaces/route.js +1 -1
  84. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  85. package/.next/server/app/icon.svg/route.js +1 -1
  86. package/.next/server/app/icon.svg/route.js.nft.json +1 -1
  87. package/.next/server/app/icon.svg.meta +1 -1
  88. package/.next/server/app/index.html +1 -1
  89. package/.next/server/app/index.meta +8 -0
  90. package/.next/server/app/index.rsc +19 -13
  91. package/.next/server/app/index.segments/__PAGE__.segment.rsc +25 -19
  92. package/.next/server/app/index.segments/_full.segment.rsc +19 -13
  93. package/.next/server/app/index.segments/_tree.segment.rsc +3 -2
  94. package/.next/server/app/login/page.js +1 -1
  95. package/.next/server/app/login/page.js.nft.json +1 -1
  96. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  97. package/.next/server/app/login.html +1 -1
  98. package/.next/server/app/login.meta +8 -0
  99. package/.next/server/app/login.rsc +23 -17
  100. package/.next/server/app/login.segments/_full.segment.rsc +23 -17
  101. package/.next/server/app/login.segments/_tree.segment.rsc +4 -2
  102. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +30 -24
  103. package/.next/server/app/page.js +1 -1
  104. package/.next/server/app/page.js.nft.json +1 -1
  105. package/.next/server/app/page_client-reference-manifest.js +1 -1
  106. package/.next/server/app-paths-manifest.json +6 -4
  107. package/.next/server/chunks/229.js +1 -1
  108. package/.next/server/chunks/421.js +1 -1
  109. package/.next/server/chunks/430.js +215 -31
  110. package/.next/server/chunks/496.js +1 -1
  111. package/.next/server/chunks/538.js +1 -1
  112. package/.next/server/chunks/662.js +2 -2
  113. package/.next/server/chunks/686.js +1 -1
  114. package/.next/server/chunks/813.js +23 -1
  115. package/.next/server/chunks/920.js +2 -2
  116. package/.next/server/chunks/924.js +1 -1
  117. package/.next/server/chunks/970.js +305 -0
  118. package/.next/server/middleware-build-manifest.js +1 -1
  119. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  120. package/.next/server/middleware.js +185 -23
  121. package/.next/server/middleware.js.nft.json +1 -1
  122. package/.next/server/pages/404.html +1 -1
  123. package/.next/server/pages/500.html +1 -1
  124. package/.next/server/preview-props.json +5 -0
  125. package/.next/server/server-reference-manifest.json +1 -1
  126. package/.next/static/WMazYvD-gbeLsCUfUxvvM/_buildManifest.js +1 -0
  127. package/.next/static/chunks/316-698d10eb465bd8ef.js +1 -0
  128. package/.next/static/chunks/3794-0978865cda773008.js +52 -0
  129. package/.next/static/chunks/4673.4cf66cbdd8364c57.js +1 -0
  130. package/.next/static/chunks/4686-09fcd0028079006c.js +1 -0
  131. package/.next/static/chunks/4778.eddd3a5813f85824.js +1 -0
  132. package/.next/static/chunks/4bd1b696-e199e788e58acf79.js +1 -0
  133. package/.next/static/chunks/{5173.91ddb803785009f8.js → 5173.dfd1cbb795b9a21c.js} +1 -1
  134. package/.next/static/chunks/618.949981da99be7182.js +4 -0
  135. package/.next/static/chunks/7411.67366dc5032a141d.js +1 -0
  136. package/.next/static/chunks/app/_global-error/page-f977af7f858990a2.js +1 -0
  137. package/.next/static/chunks/app/api/agent/[id]/events/route-f977af7f858990a2.js +1 -0
  138. package/.next/static/chunks/app/api/agent/[id]/route-f977af7f858990a2.js +1 -0
  139. package/.next/static/chunks/app/api/agent/[id]/tree/route-f977af7f858990a2.js +1 -0
  140. package/.next/static/chunks/app/api/agent/new/route-f977af7f858990a2.js +1 -0
  141. package/.next/static/chunks/app/api/config/route-f977af7f858990a2.js +1 -0
  142. package/.next/static/chunks/app/api/files/route-f977af7f858990a2.js +1 -0
  143. package/.next/static/chunks/app/api/fs/route-f977af7f858990a2.js +1 -0
  144. package/.next/static/chunks/app/api/git/route-f977af7f858990a2.js +1 -0
  145. package/.next/static/chunks/app/api/github/route-f977af7f858990a2.js +1 -0
  146. package/.next/static/chunks/app/api/growth/route-f977af7f858990a2.js +1 -0
  147. package/.next/static/chunks/app/api/health/route-f977af7f858990a2.js +1 -0
  148. package/.next/static/chunks/app/api/models/defaults/route-f977af7f858990a2.js +1 -0
  149. package/.next/static/chunks/app/api/models/route-f977af7f858990a2.js +1 -0
  150. package/.next/static/chunks/app/api/pi-settings/route-f977af7f858990a2.js +1 -0
  151. package/.next/static/chunks/app/api/plugins/route-f977af7f858990a2.js +1 -0
  152. package/.next/static/chunks/app/api/prompts/route-f977af7f858990a2.js +1 -0
  153. package/.next/static/chunks/app/api/remote/route-f977af7f858990a2.js +1 -0
  154. package/.next/static/chunks/app/api/security/route-f977af7f858990a2.js +1 -0
  155. package/.next/static/chunks/app/api/sessions/[id]/export/route-f977af7f858990a2.js +1 -0
  156. package/.next/static/chunks/app/api/sessions/[id]/route-f977af7f858990a2.js +1 -0
  157. package/.next/static/chunks/app/api/sessions/import/route-f977af7f858990a2.js +1 -0
  158. package/.next/static/chunks/app/api/sessions/route-f977af7f858990a2.js +1 -0
  159. package/.next/static/chunks/app/api/skills/route-f977af7f858990a2.js +1 -0
  160. package/.next/static/chunks/app/api/update/route-f977af7f858990a2.js +1 -0
  161. package/.next/static/chunks/app/api/version/route-f977af7f858990a2.js +1 -0
  162. package/.next/static/chunks/app/api/web-auth/route-f977af7f858990a2.js +1 -0
  163. package/.next/static/chunks/app/api/workspaces/route-f977af7f858990a2.js +1 -0
  164. package/.next/static/chunks/app/page-fc4ed2ff29b6235e.js +1 -0
  165. package/.next/static/chunks/framework-47b723d27f4a4963.js +1 -0
  166. package/.next/static/chunks/main-5403f0764579e5fe.js +5 -0
  167. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f977af7f858990a2.js +1 -0
  168. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f977af7f858990a2.js +1 -0
  169. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f977af7f858990a2.js +1 -0
  170. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f977af7f858990a2.js +1 -0
  171. package/.next/static/chunks/webpack-22ebffc20adb44ae.js +1 -0
  172. package/.next/static/css/6671d0d1589fdc0e.css +1 -0
  173. package/.next/static/css/{2959bd6b626b7ffe.css → f15a853af8b5127f.css} +1 -1
  174. package/.next/types/app/api/agent/[id]/events/route.ts +3 -1
  175. package/.next/types/app/api/agent/[id]/route.ts +3 -1
  176. package/.next/types/app/api/agent/[id]/tree/route.ts +3 -1
  177. package/.next/types/app/api/agent/new/route.ts +3 -1
  178. package/.next/types/app/api/config/route.ts +3 -1
  179. package/.next/types/app/api/files/route.ts +3 -1
  180. package/.next/types/app/api/fs/route.ts +3 -1
  181. package/.next/types/app/api/git/route.ts +3 -1
  182. package/.next/types/app/api/github/route.ts +355 -0
  183. package/.next/types/app/api/growth/route.ts +3 -1
  184. package/.next/types/app/api/health/route.ts +3 -1
  185. package/.next/types/app/api/models/defaults/route.ts +355 -0
  186. package/.next/types/app/api/models/route.ts +3 -1
  187. package/.next/types/app/api/pi-settings/route.ts +3 -1
  188. package/.next/types/app/api/plugins/route.ts +3 -1
  189. package/.next/types/app/api/prompts/route.ts +3 -1
  190. package/.next/types/app/api/remote/route.ts +3 -1
  191. package/.next/types/app/api/security/route.ts +3 -1
  192. package/.next/types/app/api/sessions/[id]/export/route.ts +3 -1
  193. package/.next/types/app/api/sessions/[id]/route.ts +3 -1
  194. package/.next/types/app/api/sessions/import/route.ts +3 -1
  195. package/.next/types/app/api/sessions/route.ts +3 -1
  196. package/.next/types/app/api/skills/route.ts +3 -1
  197. package/.next/types/app/api/update/route.ts +3 -1
  198. package/.next/types/app/api/version/route.ts +3 -1
  199. package/.next/types/app/api/web-auth/route.ts +3 -1
  200. package/.next/types/app/api/workspaces/route.ts +3 -1
  201. package/.next/types/app/layout.ts +4 -1
  202. package/.next/types/app/login/page.ts +4 -1
  203. package/.next/types/app/page.ts +4 -1
  204. package/.next/types/cache-life.d.ts +11 -43
  205. package/.next/types/routes.d.ts +3 -1
  206. package/.next/types/validator.ts +76 -2
  207. package/package.json +2 -2
  208. package/src/app/api/files/route.ts +5 -2
  209. package/src/app/api/github/route.ts +26 -0
  210. package/src/app/api/models/defaults/route.ts +18 -0
  211. package/src/app/api/models/route.ts +24 -1
  212. package/src/app/globals.css +186 -0
  213. package/src/components/AppShell.tsx +82 -30
  214. package/src/components/ChatInput.tsx +244 -63
  215. package/src/components/ChatWindow.tsx +18 -27
  216. package/src/components/ContextMeter.tsx +193 -143
  217. package/src/components/GitPanel.tsx +110 -40
  218. package/src/components/ProjectPanel.tsx +55 -30
  219. package/src/components/PromptPanel.tsx +2 -4
  220. package/src/components/ProviderSetupModal.module.css +20 -0
  221. package/src/components/ProviderSetupModal.tsx +134 -29
  222. package/src/components/SettingsPanel.tsx +243 -27
  223. package/src/components/TrajectoryView.tsx +4 -6
  224. package/src/components/composer/ComposerImages.tsx +306 -0
  225. package/src/hooks/useGrowth.ts +3 -0
  226. package/src/hooks/usePiWeb.ts +12 -2
  227. package/src/hooks/useWorkspaceFiles.ts +93 -0
  228. package/src/i18n.tsx +108 -2
  229. package/src/lib/agent-manager.ts +135 -104
  230. package/src/lib/files-service.ts +100 -1
  231. package/src/lib/format.ts +56 -0
  232. package/src/lib/git-service.ts +45 -13
  233. package/src/lib/github-identity.ts +245 -0
  234. package/src/lib/growth-service.ts +6 -2
  235. package/src/lib/hero-model-memory.ts +28 -0
  236. package/src/lib/model-defaults.ts +105 -0
  237. package/src/lib/model-draft.ts +257 -9
  238. package/src/lib/model-endpoint.ts +23 -0
  239. package/src/lib/model-files.ts +33 -0
  240. package/src/lib/models-service.ts +240 -78
  241. package/src/lib/pi.ts +27 -2
  242. package/src/lib/process-format.ts +3 -2
  243. package/src/lib/session/metrics.ts +31 -0
  244. package/src/lib/thinking-level.ts +39 -0
  245. package/src/lib/trajectory.ts +17 -3
  246. package/src/lib/types.ts +12 -1
  247. package/src/lib/workspace-index.ts +270 -0
  248. package/.next/server/chunks/741.js +0 -165
  249. package/.next/static/chunks/316-5ea443ee6b512aa9.js +0 -1
  250. package/.next/static/chunks/3794-3679f1c05160977c.js +0 -52
  251. package/.next/static/chunks/4673.8a5a9e6855350e34.js +0 -1
  252. package/.next/static/chunks/4686-882531aef1d2e250.js +0 -1
  253. package/.next/static/chunks/4778.305dc6abb9889627.js +0 -1
  254. package/.next/static/chunks/4bd1b696-93bf506b236d6248.js +0 -1
  255. package/.next/static/chunks/618.ee5d5bf1c647e483.js +0 -4
  256. package/.next/static/chunks/839.bfd2db36483faa3e.js +0 -1
  257. package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +0 -1
  258. package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +0 -1
  259. package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +0 -1
  260. package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +0 -1
  261. package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +0 -1
  262. package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +0 -1
  263. package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +0 -1
  264. package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +0 -1
  265. package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +0 -1
  266. package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +0 -1
  267. package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +0 -1
  268. package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +0 -1
  269. package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +0 -1
  270. package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +0 -1
  271. package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +0 -1
  272. package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +0 -1
  273. package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +0 -1
  274. package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +0 -1
  275. package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +0 -1
  276. package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +0 -1
  277. package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +0 -1
  278. package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +0 -1
  279. package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +0 -1
  280. package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +0 -1
  281. package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +0 -1
  282. package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +0 -1
  283. package/.next/static/chunks/app/page-c04af72e549695bd.js +0 -1
  284. package/.next/static/chunks/framework-c4e651fe89edfa91.js +0 -1
  285. package/.next/static/chunks/main-6159b22a8cb0b7c5.js +0 -5
  286. package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +0 -1
  287. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +0 -1
  288. package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +0 -1
  289. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +0 -1
  290. package/.next/static/chunks/webpack-9b4bb574e5817654.js +0 -1
  291. package/.next/static/css/ea27b9e30f76303f.css +0 -1
  292. package/.next/static/sNX5GQkOWuK5YEID1hT-C/_buildManifest.js +0 -1
  293. /package/.next/static/{sNX5GQkOWuK5YEID1hT-C → WMazYvD-gbeLsCUfUxvvM}/_ssgManifest.js +0 -0
@@ -1,48 +1,23 @@
1
1
  "use client";
2
2
 
3
- /**
4
- * 上下文占用:
5
- * 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 / 窗口」与 13 类分段占用条
6
- * (mac 磁盘存储条式的多色分段,含 Free Space 段)。
7
- * 分段口径:全部为字符/4 启发式估算(与 SDK estimateTokens 一致),
8
- * 服务端分项(System Prompt / Tools / Memory / Skills / Compacted / Buffer)来自快照,
9
- * 消息分项(User / Agent Text / Thinking / Tool Call / Tool Output)弹窗打开时前端统计。
10
- * 冷会话(只浏览历史、agent 没启动)拿不到组装后的 System Prompt 和工具定义:
11
- * 其余分项照常显示,另给「现在统计」按需启动会话,分项随后经 context_breakdown 事件刷新。
12
- * 无对话时不渲染(由父组件经 contextVisible 控制)。
13
- */
3
+ /** 上下文总量是 SDK 估算;API 输入用量与内容字符分类分别展示,不能按比例分摊 token。 */
14
4
  import { useEffect, useMemo, useRef, useState } from "react";
15
5
  import { useI18n } from "@/i18n";
6
+ import { fmtPercent, fmtTokens } from "@/lib/format";
7
+ import { formatCapacity, parseCapacity } from "@/lib/model-draft";
16
8
  import { classifyMessageChars } from "@/lib/process-format";
17
- import type { ContextBreakdown } from "@/lib/types";
18
-
19
- function fmtTok(n: number): string {
20
- return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(Math.round(n));
21
- }
9
+ import type { ContextBreakdown, TrajTokens } from "@/lib/types";
22
10
 
23
- /** 13 类分段的定义(顺序即渲染顺序) */
24
- interface Segment {
25
- key: string;
26
- label: string;
27
- tokens: number;
28
- color: string;
29
- }
30
-
31
- export function ContextMeter({
32
- percent,
33
- tokens,
34
- contextWindow,
35
- source,
36
- }: {
11
+ export function ContextMeter({ percent, tokens, contextWindow, model, source }: {
37
12
  percent: number | null;
38
13
  tokens: number | null;
39
14
  contextWindow: number | null;
40
- /** 分段数据源:消息字符统计只在弹窗打开时执行(流式期间消息每 token 都变,不能常驻扫描) */
15
+ /** 当前会话的模型:有它才能在弹窗里改上下文窗口 */
16
+ model?: { provider: string; id: string } | null;
41
17
  source?: {
42
18
  systemChars: number;
43
- messages: { role?: string; content: Array<{ type: string; text?: string; thinking?: string; arguments?: unknown }> }[];
19
+ messages: Array<{ role?: string; content: Array<{ type: string; text?: string; thinking?: string; arguments?: unknown; name?: string }>; usage?: TrajTokens; stopReason?: string }>;
44
20
  breakdown?: ContextBreakdown;
45
- /** 冷会话里点「现在统计」:启动会话(prepare),System Prompt 与工具定义才算得出来 */
46
21
  loadFull?: () => Promise<unknown> | unknown;
47
22
  };
48
23
  }) {
@@ -52,154 +27,229 @@ export function ContextMeter({
52
27
  const { t } = useI18n();
53
28
  const cold = Boolean(source?.breakdown?.cold);
54
29
 
55
- // 新分项到了(不再是冷的)就收起「统计中」
56
- useEffect(() => {
57
- if (!cold) setCounting(false);
58
- }, [cold]);
59
-
30
+ useEffect(() => { if (!cold) setCounting(false); }, [cold]);
60
31
  useEffect(() => {
61
32
  if (!open) return;
62
- const h = (e: MouseEvent) => {
63
- if (!ref.current?.contains(e.target as Node)) setOpen(false);
64
- };
33
+ const h = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); };
65
34
  document.addEventListener("mousedown", h);
66
35
  return () => document.removeEventListener("mousedown", h);
67
36
  }, [open]);
68
37
 
69
- const used = tokens ?? 0;
70
- const window_ = contextWindow ?? 0;
71
- const b = source?.breakdown;
38
+ // 流式期间只在打开弹窗后扫描,分类是实际文本字符数,不是假设的 token 数。
39
+ const chars = useMemo(() => classifyMessageChars(open ? source?.messages ?? [] : []), [open, source]);
40
+ const lastUsage = useMemo(() => open ? source?.messages.findLast((m) => m.role === "assistant" && m.stopReason !== "error" && m.stopReason !== "aborted" && m.stopReason !== "pending" &&
41
+ (m.usage?.input ?? 0) + (m.usage?.cacheRead ?? 0) + (m.usage?.cacheWrite ?? 0) + (m.usage?.output ?? 0) > 0)?.usage : undefined, [open, source]);
42
+ const measuredInput = lastUsage ? (lastUsage.input ?? 0) + (lastUsage.cacheRead ?? 0) + (lastUsage.cacheWrite ?? 0) : null;
72
43
 
73
- // 消息分项只在弹窗打开时统计(≈4 字符/token,与 SDK 一致)
74
- const messageSegments = useMemo(() => {
75
- if (!open) return { user: 0, agentText: 0, agentThinking: 0, agentToolCall: 0, toolOutput: 0 };
76
- const chars = classifyMessageChars(source?.messages ?? []);
77
- return {
78
- user: Math.max(0, Math.round(chars.user / 4)),
79
- agentText: Math.max(0, Math.round(chars.agentText / 4)),
80
- agentThinking: Math.max(0, Math.round(chars.agentThinking / 4)),
81
- agentToolCall: Math.max(0, Math.round(chars.agentToolCall / 4)),
82
- toolOutput: Math.max(0, Math.round(chars.toolOutput / 4)),
83
- };
84
- }, [open, source]);
44
+ // 刚改过窗口:服务端已落盘,但页面上的 contextWindow 还是改之前的值(冷会话要等下一次快照)。
45
+ // 在它变之前先显示刚保存的值;contextWindow 一变(快照 / 用量追上来,或别处改了)就以 props 为准。
46
+ const modelKey = model ? `${model.provider}/${model.id}` : "";
47
+ const [applied, setApplied] = useState<{ key: string; from: number; value: number } | null>(null);
48
+ useEffect(() => {
49
+ if (applied && (applied.key !== modelKey || contextWindow !== applied.from)) setApplied(null);
50
+ }, [applied, modelKey, contextWindow]);
51
+ const override = applied && applied.key === modelKey && contextWindow === applied.from ? applied.value : null;
85
52
 
86
- const p = percent ?? 0;
87
- const usageLabel = percent === null ? t.contextUnknown : `${Math.round(p)}% ${t.contextUsed}`;
53
+ const known = tokens !== null && Number.isFinite(tokens) && tokens >= 0;
54
+ const used = known ? tokens : 0;
55
+ const window_ = override ?? contextWindow ?? 0;
56
+ const recomputed = override !== null && known;
57
+ const p = recomputed ? used / override * 100 : percent ?? 0;
58
+ const usageLabel = percent === null && !recomputed ? t.contextUnknown : `${fmtPercent(p)} ${t.contextEstimated}`;
88
59
  const r = 5.5;
89
- const c = 2 * Math.PI * r;
90
- const shown = Math.max(0, Math.min(100, p));
91
-
92
- const segments: Segment[] = b
93
- ? [
94
- { key: "systemPrompt", label: t.contextSystemPrompt, tokens: b.systemPrompt, color: "var(--dsw-accent)" },
95
- { key: "systemTools", label: t.contextSystemTools, tokens: b.systemTools, color: "#8B7EC8" },
96
- { key: "customTools", label: t.contextCustomTools, tokens: b.customTools, color: "#B39DDB" },
97
- { key: "memory", label: t.contextMemory, tokens: b.memory, color: "#5FA8D3" },
98
- { key: "skills", label: t.contextSkills, tokens: b.skills, color: "#4FB286" },
99
- { key: "user", label: t.contextUserMessages, tokens: messageSegments.user, color: "var(--dsw-success)" },
100
- { key: "agentText", label: t.contextAgentText, tokens: messageSegments.agentText, color: "#7BC96F" },
101
- { key: "agentThinking", label: t.contextAgentThinking, tokens: messageSegments.agentThinking, color: "#C9A66B" },
102
- { key: "agentToolCall", label: t.contextAgentToolCall, tokens: messageSegments.agentToolCall, color: "#D3A15F" },
103
- { key: "toolOutput", label: t.contextToolOutput, tokens: messageSegments.toolOutput, color: "var(--dsw-warn)" },
104
- { key: "compacted", label: t.contextCompacted, tokens: b.compacted, color: "#A05A78" },
105
- { key: "buffer", label: t.contextAutoCompactBuffer, tokens: b.autoCompactBuffer, color: "var(--dsw-border-l3)" },
106
- ]
107
- : // 服务端分项缺失(旧快照):退回旧三段口径
108
- [
109
- { key: "system", label: t.contextSystem, tokens: Math.max(0, Math.round((source?.systemChars ?? 0) / 4)), color: "var(--dsw-accent)" },
110
- { key: "messages", label: t.contextMessages, tokens: messageSegments.user + messageSegments.agentText + messageSegments.agentThinking, color: "var(--dsw-success)" },
111
- { key: "tools", label: t.contextTools, tokens: Math.max(0, used - Math.max(0, Math.round((source?.systemChars ?? 0) / 4)) - messageSegments.user - messageSegments.agentText - messageSegments.agentThinking), color: "var(--dsw-warn)" },
112
- ];
113
-
114
- // Free Space:窗口余量(窗口未知则不显示);分段条不归一化到已用之和
115
- const usedSum = segments.reduce((sum, s) => sum + s.tokens, 0);
116
- const free = window_ > 0 ? Math.max(0, window_ - usedSum) : 0;
117
- const barTotal = window_ > 0 ? window_ : usedSum || 1;
118
- const bar = (n: number) => `${(n / barTotal) * 100}%`;
60
+ const circumference = 2 * Math.PI * r;
61
+ const reserved = Math.max(0, source?.breakdown?.autoCompactBuffer ?? 0);
62
+ // 超过窗口时预留不再画到窗口外;未知用量不能将整扇窗口显示为 Free Space。
63
+ const visibleReserved = known && window_ > 0 ? Math.min(reserved, Math.max(0, window_ - used)) : 0;
64
+ const free = known && window_ > 0 ? Math.max(0, window_ - used - reserved) : null;
65
+ const b = source?.breakdown?.characters;
66
+ const rows = [
67
+ { key: "system", label: t.contextSystemPrompt, value: b?.systemPrompt, color: "var(--dsw-accent)" },
68
+ { key: "builtin", label: t.contextSystemTools, value: b?.systemTools, color: "#8B7EC8" },
69
+ { key: "custom", label: t.contextCustomTools, value: b?.customTools, color: "#B39DDB" },
70
+ { key: "memory", label: t.contextMemory, value: b?.memory, color: "#5FA8D3" },
71
+ { key: "skills", label: t.contextSkills, value: b?.skills, color: "#4FB286" },
72
+ { key: "user", label: t.contextUserMessages, value: chars.user, color: "var(--dsw-success)" },
73
+ { key: "text", label: t.contextAgentText, value: chars.agentText, color: "#7BC96F" },
74
+ { key: "thinking", label: t.contextAgentThinking, value: chars.agentThinking, color: "#C9A66B" },
75
+ { key: "call", label: t.contextAgentToolCall, value: chars.agentToolCall, color: "#D3A15F" },
76
+ { key: "output", label: t.contextToolOutput, value: chars.toolOutput, color: "var(--dsw-warn)" },
77
+ { key: "compacted", label: t.contextCompacted, value: b?.compacted, color: "#A05A78" },
78
+ ].filter((row) => row.value !== undefined && row.value > 0);
119
79
 
120
80
  return (
121
81
  <div ref={ref} className="relative">
122
- <button
123
- className="icon-btn"
124
- style={{ width: 28, height: 28 }}
125
- title={usageLabel}
126
- onClick={() => setOpen((o) => !o)}
127
- >
82
+ <button className="icon-btn" style={{ width: 28, height: 28 }} title={usageLabel} aria-label={usageLabel} onClick={() => setOpen((o) => !o)}>
128
83
  <svg width="14" height="14" viewBox="0 0 14 14">
129
84
  <circle cx="7" cy="7" r={r} fill="none" stroke="var(--dsw-border-l3)" strokeWidth="2" />
130
- <circle
131
- cx="7" cy="7" r={r}
132
- fill="none"
133
- stroke={p != null && p > 85 ? "var(--dsw-warn)" : "var(--dsw-accent)"}
134
- strokeWidth="2"
135
- strokeDasharray={`${(c * shown) / 100} ${c}`}
136
- transform="rotate(-90 7 7)"
137
- strokeLinecap="round"
138
- />
85
+ <circle cx="7" cy="7" r={r} fill="none" stroke={p > 85 ? "var(--dsw-warn)" : "var(--dsw-accent)"} strokeWidth="2"
86
+ strokeDasharray={`${circumference * Math.max(0, Math.min(100, p)) / 100} ${circumference}`} transform="rotate(-90 7 7)" strokeLinecap="round" />
139
87
  </svg>
140
88
  </button>
141
89
  {open && (
142
- <div
143
- className="popover absolute bottom-9 right-0 z-50 w-64 p-4"
144
- style={{ fontSize: "var(--dsh-content-font-size-secondary)" }}
145
- >
146
- <div className="font-medium" style={{ color: "var(--dsw-label-primary)" }}>
147
- {usageLabel}
148
- </div>
149
- <div style={{ color: "var(--dsw-label-tertiary)" }} className="mt-1">
150
- {tokens === null ? "—" : `~${fmtTok(used)}`} / {window_ > 0 ? fmtTok(window_) : "—"}
90
+ <div className="popover absolute bottom-9 right-0 z-50 w-72 p-4" style={{ fontSize: "var(--dsh-content-font-size-secondary)" }}>
91
+ <div className="font-medium" style={{ color: "var(--dsw-label-primary)" }}>{usageLabel}</div>
92
+ <div className="mt-1" style={{ color: "var(--dsw-label-tertiary)" }}>
93
+ {known ? `~${fmtTokens(used)}` : "—"} / {window_ > 0 ? fmtTokens(window_) : "—"} tok
151
94
  </div>
152
-
153
- {/* 分段占用条(mac 磁盘存储条式多色分段,含 Free Space) */}
154
- {usedSum > 0 && (
95
+ {measuredInput !== null && <div className="mt-2 flex items-center justify-between gap-2"><span>{t.contextApiInput}</span><span title={`${measuredInput} tok`}>{fmtTokens(measuredInput)} tok</span></div>}
96
+ {known && window_ > 0 && (
155
97
  <div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} aria-hidden>
156
- {segments.filter((s) => s.tokens > 0).map((s) => (
157
- <span key={s.key} style={{ width: bar(s.tokens), background: s.color }} />
158
- ))}
159
- {free > 0 && <span style={{ width: bar(free), background: "transparent" }} />}
98
+ <span className="flex-none" style={{ width: `${Math.min(100, used / window_ * 100)}%`, background: "var(--dsw-accent)" }} />
99
+ {visibleReserved > 0 && <span className="flex-none" style={{ width: `${visibleReserved / window_ * 100}%`, background: "var(--dsw-border-l3)" }} />}
160
100
  </div>
161
101
  )}
162
102
  <div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
163
- {segments.filter((s) => s.tokens > 0).map((s) => (
164
- <SegmentRow key={s.key} label={s.label} tokens={s.tokens} color={s.color} />
165
- ))}
166
- {window_ > 0 && <SegmentRow label={t.contextFreeSpace} tokens={free} color="var(--dsw-selector)" />}
103
+ {reserved > 0 && <DataRow label={t.contextAutoCompactBuffer} value={`${fmtTokens(reserved)} tok`} exact={`${reserved} tok`} color="var(--dsw-border-l3)" />}
104
+ {window_ > 0 && <DataRow label={t.contextFreeSpace} value={free === null ? "—" : `~${fmtTokens(free)} tok`} color="var(--dsw-selector)" />}
105
+ </div>
106
+ <div className="mt-3 font-medium">{t.contextCharacterBreakdown}</div>
107
+ <div className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>{t.contextCharacterHint}</div>
108
+ <div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
109
+ {rows.map((row) => <DataRow key={row.key} label={row.label} value={`${fmtTokens(row.value!)} ${t.contextChars}`} exact={`${row.value} ${t.contextChars}`} color={row.color} />)}
167
110
  </div>
168
- {cold && source?.loadFull ? (
111
+ {cold && source?.loadFull && (
169
112
  <div className="mt-3 flex items-center justify-between gap-2" style={{ color: "var(--dsw-label-tertiary)" }}>
170
113
  <span className="min-w-0" style={{ fontSize: 11.5 }}>{t.contextColdHint}</span>
171
- <button
172
- type="button"
173
- className="btn-outline flex-none"
174
- style={{ height: 24, padding: "0 10px", fontSize: 12 }}
175
- disabled={counting}
114
+ <button type="button" className="btn-outline flex-none" style={{ height: 24, padding: "0 10px", fontSize: 12 }} disabled={counting}
176
115
  onClick={() => {
177
116
  setCounting(true);
178
- // 启动失败(例如没配模型)就把按钮还回来;成功时等 context_breakdown 事件把冷标记去掉
179
117
  void Promise.resolve(source.loadFull?.()).then((result) => {
180
118
  if ((result as { success?: boolean } | undefined)?.success === false) setCounting(false);
181
119
  }, () => setCounting(false));
182
- }}
183
- >
184
- {counting ? t.contextCounting : t.contextCountNow}
185
- </button>
120
+ }}>{counting ? t.contextCounting : t.contextCountNow}</button>
186
121
  </div>
187
- ) : null}
122
+ )}
123
+ {model && window_ > 0 && (
124
+ <WindowEditor key={modelKey} model={model} value={window_} onSaved={(value) => setApplied({ key: modelKey, from: contextWindow ?? 0, value })} />
125
+ )}
188
126
  </div>
189
127
  )}
190
128
  </div>
191
129
  );
192
130
  }
193
131
 
194
- function SegmentRow({ label, tokens, color }: { label: string; tokens: number; color: string }) {
195
- if (tokens <= 0) return null;
132
+ async function postModels<T>(body: Record<string, unknown>): Promise<T> {
133
+ const response = await fetch("/api/models", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
134
+ const payload = (await response.json().catch(() => null)) as { success?: boolean; data?: T; error?: string } | null;
135
+ if (!response.ok || !payload?.success) throw new Error(payload?.error || `request failed (${response.status})`);
136
+ return payload.data as T;
137
+ }
138
+
139
+ /**
140
+ * 弹窗底部的上下文窗口:平时显示精确值,「修改」后填数字保存。
141
+ * 不用滑块:模型的真实上限是 128000、200000 这类精确值,拖出来的近似值多一点上游就 400。
142
+ * 内置目录有原值就并排给出、填大了提醒;改的是该模型的全局配置,这一点写在输入框下面。
143
+ */
144
+ function WindowEditor({ model, value, onSaved }: { model: { provider: string; id: string }; value: number; onSaved: (value: number) => void }) {
145
+ const { t } = useI18n();
146
+ const [editing, setEditing] = useState(false);
147
+ const [text, setText] = useState("");
148
+ /** undefined = 还在查(不显示上限提示,免得先闪一句「没有内置值」) */
149
+ const [builtin, setBuiltin] = useState<number | null | undefined>(undefined);
150
+ const [saving, setSaving] = useState(false);
151
+ const [error, setError] = useState<string | null>(null);
152
+ const infoSeq = useRef(0);
153
+ const parsed = parseCapacity(text);
154
+ const target = parsed !== undefined && !Number.isNaN(parsed) ? parsed : null;
155
+ const valid = target !== null;
156
+ const over = target !== null && typeof builtin === "number" && target > builtin;
157
+ const exact = (n: number) => n.toLocaleString("en-US");
158
+
159
+ const begin = () => {
160
+ setEditing(true);
161
+ setText(formatCapacity(value));
162
+ setError(null);
163
+ setBuiltin(undefined);
164
+ const seq = ++infoSeq.current;
165
+ void postModels<{ builtin: number | null }>({ action: "contextWindowInfo", providerId: model.provider, modelId: model.id }).then(
166
+ (info) => { if (seq === infoSeq.current) setBuiltin(info.builtin); },
167
+ () => { if (seq === infoSeq.current) setBuiltin(null); },
168
+ );
169
+ };
170
+ const cancel = () => {
171
+ infoSeq.current += 1;
172
+ setEditing(false);
173
+ };
174
+ const save = async () => {
175
+ if (target === null || saving) return;
176
+ setSaving(true);
177
+ setError(null);
178
+ try {
179
+ const info = await postModels<{ contextWindow: number }>({ action: "setContextWindow", providerId: model.provider, modelId: model.id, contextWindow: target });
180
+ onSaved(info.contextWindow);
181
+ setEditing(false);
182
+ } catch (err) {
183
+ setError(err instanceof Error ? err.message : String(err));
184
+ } finally {
185
+ setSaving(false);
186
+ }
187
+ };
188
+
189
+ if (!editing) {
190
+ return (
191
+ <div className="hairline-t mt-3 flex items-center justify-between gap-2 pt-3" style={{ color: "var(--dsw-label-tertiary)" }}>
192
+ <span>{t.contextWindowLabel}</span>
193
+ <span className="flex items-center gap-2">
194
+ <span style={{ color: "var(--dsw-label-secondary)", fontFamily: "var(--font-mono)" }}>{exact(value)}</span>
195
+ <button type="button" className="btn-outline" style={{ height: 22, padding: "0 8px", fontSize: 11.5 }} onClick={begin}>{t.contextWindowEdit}</button>
196
+ </span>
197
+ </div>
198
+ );
199
+ }
196
200
  return (
197
- <div className="flex items-center justify-between gap-2">
198
- <span className="flex items-center gap-1.5">
199
- <span className="inline-block rounded-full" style={{ width: 7, height: 7, background: color }} />
200
- {label}
201
- </span>
202
- <span>~{fmtTok(tokens)}</span>
201
+ <div className="hairline-t mt-3 pt-3" style={{ color: "var(--dsw-label-tertiary)" }}>
202
+ <div>{t.contextWindowLabel}</div>
203
+ <div className="mt-1.5 flex items-center gap-1.5">
204
+ <input
205
+ autoFocus
206
+ value={text}
207
+ onChange={(e) => setText(e.target.value)}
208
+ onKeyDown={(e) => {
209
+ if (e.key === "Enter") {
210
+ e.preventDefault();
211
+ void save();
212
+ } else if (e.key === "Escape") {
213
+ e.preventDefault();
214
+ e.stopPropagation();
215
+ cancel();
216
+ }
217
+ }}
218
+ aria-label={t.contextWindowLabel}
219
+ aria-invalid={!valid}
220
+ spellCheck={false}
221
+ className="min-w-0 flex-1 rounded-md px-2"
222
+ style={{ height: 26, fontSize: 12.5, fontFamily: "var(--font-mono)", color: "var(--dsw-label-primary)", background: "var(--dsw-hover)", border: `0.5px solid ${valid ? "var(--dsw-border-l2)" : "var(--dsw-danger)"}` }}
223
+ />
224
+ <button type="button" className="btn-primary-white flex-none" style={{ height: 26, padding: "0 10px", fontSize: 12 }} disabled={!valid || saving} onClick={() => void save()}>
225
+ {saving ? t.saving : t.save}
226
+ </button>
227
+ <button type="button" className="btn-outline flex-none" style={{ height: 26, padding: "0 10px", fontSize: 12 }} disabled={saving} onClick={cancel}>{t.cancel}</button>
228
+ </div>
229
+ {typeof builtin === "number" && (
230
+ <div className="mt-1.5 flex items-center gap-1.5" style={{ fontSize: 11.5 }}>
231
+ <span>{t.contextWindowBuiltin.replace("{value}", exact(builtin))}</span>
232
+ {target !== builtin && (
233
+ <button type="button" className="underline" style={{ color: "var(--dsw-label-secondary)" }} onClick={() => setText(formatCapacity(builtin))}>{t.contextWindowUseBuiltin}</button>
234
+ )}
235
+ </div>
236
+ )}
237
+ {!valid ? (
238
+ <div className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}>{t.contextWindowInvalid}</div>
239
+ ) : over ? (
240
+ <div className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-warn)" }}>{t.contextWindowOverBuiltin}</div>
241
+ ) : builtin === null ? (
242
+ <div className="mt-1" style={{ fontSize: 11.5 }}>{t.contextWindowNoBuiltin}</div>
243
+ ) : null}
244
+ <div className="mt-1" style={{ fontSize: 11.5 }}>{t.contextWindowScope}</div>
245
+ {error && <div className="mt-1" role="alert" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}>{error}</div>}
203
246
  </div>
204
247
  );
205
248
  }
249
+
250
+ function DataRow({ label, value, exact, color }: { label: string; value: string; exact?: string; color: string }) {
251
+ return <div className="flex items-center justify-between gap-2">
252
+ <span className="flex items-center gap-1.5"><span className="inline-block rounded-full" style={{ width: 7, height: 7, background: color }} />{label}</span>
253
+ <span title={exact}>{value}</span>
254
+ </div>;
255
+ }
@@ -39,10 +39,19 @@ interface GitInfo {
39
39
  statusError?: string;
40
40
  }
41
41
 
42
+ interface GithubIdentity {
43
+ login: string;
44
+ name?: string;
45
+ avatar?: string;
46
+ url?: string;
47
+ repo?: string;
48
+ }
49
+
42
50
  interface DiffResult {
43
51
  patch: string;
44
52
  truncated: boolean;
45
53
  binary: boolean;
54
+ stats?: { add: number; del: number };
46
55
  title?: string;
47
56
  }
48
57
 
@@ -75,6 +84,7 @@ export function GitPanel({
75
84
  }) {
76
85
  const { t } = useI18n();
77
86
  const [info, setInfo] = useState<GitInfo | null>(null);
87
+ const [identity, setIdentity] = useState<GithubIdentity | null>(null);
78
88
  const [loading, setLoading] = useState(false);
79
89
  const [statusError, setStatusError] = useState("");
80
90
  const [diffError, setDiffError] = useState("");
@@ -105,6 +115,23 @@ export function GitPanel({
105
115
  }
106
116
  }, [cwd]);
107
117
 
118
+ // GitHub 身份单独拉:要走网络(gh / 头像),不能拖慢上面那条纯本地的状态读取
119
+ useEffect(() => {
120
+ setIdentity(null);
121
+ if (!cwd) return;
122
+ const controller = new AbortController();
123
+ void (async () => {
124
+ try {
125
+ const r = await fetch(`/api/github?cwd=${encodeURIComponent(cwd)}`, { signal: controller.signal });
126
+ const j = await r.json();
127
+ if (j?.success && j.data?.identity) setIdentity(j.data.identity as GithubIdentity);
128
+ } catch {
129
+ /* 取不到就只显示分支,不报错 */
130
+ }
131
+ })();
132
+ return () => controller.abort();
133
+ }, [cwd, refreshKey]);
134
+
108
135
  useEffect(() => {
109
136
  setInfo(null);
110
137
  setView(null);
@@ -165,13 +192,31 @@ export function GitPanel({
165
192
  const error = view ? diffError : statusError || info?.statusError || "";
166
193
 
167
194
  const parsed = useMemo(() => (diff?.patch ? parseUnifiedDiff(diff.patch) : null), [diff]);
168
- const stats = parsed ? diffStats(parsed) : null;
195
+ // 新服务端给完整 Git 统计;旧服务端只在 patch 完整时才允许本地计数。
196
+ const stats = diff?.binary ? null : diff?.stats ?? (parsed && !diff?.truncated ? diffStats(parsed) : null);
169
197
 
170
198
  const conflicts = info?.files.filter((f) => f.kind === "conflict") ?? [];
171
199
  const staged = info?.files.filter((f) => f.kind !== "conflict" && f.kind !== "untracked" && f.indexStatus !== " ") ?? [];
172
200
  const unstaged = info?.files.filter((f) => f.kind !== "conflict" && f.kind !== "untracked" && f.workStatus !== " ") ?? [];
173
201
  const untracked = info?.files.filter((f) => f.kind === "untracked") ?? [];
174
202
 
203
+ /**
204
+ * 按 Git 的三个区呈现(分支 / 上游那些是「版本库」侧,在别的行):
205
+ * - 暂存区:`git add` 过的,下次 commit 会带上;
206
+ * - 工作区:改了还没 add 的 + 未跟踪的新文件(都属于「还没进暂存区」);
207
+ * - 冲突单独置顶(不先解决,提交走不下去)。
208
+ * 未跟踪文件要整份当新增行看,模式逐文件给,不能按组统一。
209
+ */
210
+ const areas: Array<{ key: string; label: string; entries: Array<{ file: GitFileEntry; mode: "staged" | "worktree" | "untracked" }>; danger?: boolean }> = [
211
+ { key: "conflict", label: t.gitConflict, entries: conflicts.map((file) => ({ file, mode: "worktree" as const })), danger: true },
212
+ { key: "staged", label: t.gitStagingArea, entries: staged.map((file) => ({ file, mode: "staged" as const })) },
213
+ {
214
+ key: "worktree",
215
+ label: t.gitWorkingTree,
216
+ entries: [...unstaged.map((file) => ({ file, mode: "worktree" as const })), ...untracked.map((file) => ({ file, mode: "untracked" as const }))],
217
+ },
218
+ ].filter((area) => area.entries.length > 0);
219
+
175
220
  const openFile = (f: GitFileEntry, mode: "staged" | "worktree" | "untracked", label: string) => changeView({ kind: "file", path: f.path, mode, label });
176
221
 
177
222
  const headerTitle = view
@@ -247,6 +292,39 @@ export function GitPanel({
247
292
  )}
248
293
  {!view && info?.isRepo && (
249
294
  <div className="flex flex-col gap-4">
295
+ {/* GitHub 身份:头像 + 账号(取不到就整行不出现) */}
296
+ {identity && (
297
+ <div className="flex items-center gap-2">
298
+ {identity.avatar ? (
299
+ // eslint-disable-next-line @next/next/no-img-element
300
+ <img src={identity.avatar} alt="" width={22} height={22} className="flex-none rounded-full" />
301
+ ) : (
302
+ <span
303
+ className="grid flex-none place-items-center rounded-full"
304
+ style={{ width: 22, height: 22, background: "var(--dsw-selector)", fontSize: 11, fontWeight: 600, color: "var(--dsw-label-secondary)" }}
305
+ aria-hidden
306
+ >
307
+ {identity.login.slice(0, 1).toUpperCase()}
308
+ </span>
309
+ )}
310
+ <a
311
+ href={identity.url ?? `https://github.com/${identity.login}`}
312
+ target="_blank"
313
+ rel="noreferrer"
314
+ className="min-w-0 truncate"
315
+ style={{ fontSize: 12.5, color: "var(--dsw-label-secondary)", textDecoration: "none" }}
316
+ title={identity.name ? `${identity.name} (@${identity.login})` : `@${identity.login}`}
317
+ >
318
+ @{identity.login}
319
+ </a>
320
+ {identity.repo && (
321
+ <span className="min-w-0 truncate" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} title={identity.repo}>
322
+ · {identity.repo}
323
+ </span>
324
+ )}
325
+ </div>
326
+ )}
327
+
250
328
  {/* 分支行 */}
251
329
  <div className="flex flex-wrap items-center gap-2" style={{ fontSize: 12.5 }}>
252
330
  <span
@@ -280,45 +358,37 @@ export function GitPanel({
280
358
  {error ? null : info.files.length === 0 ? (
281
359
  <div style={{ fontSize: 12.5, color: "var(--dsw-label-caption)" }}>{t.gitNoChanges}</div>
282
360
  ) : (
283
- [
284
- { label: t.gitConflict, list: conflicts, mode: "worktree" as const },
285
- { label: t.gitStaged, list: staged, mode: "staged" as const },
286
- { label: t.gitUnstaged, list: unstaged, mode: "worktree" as const },
287
- { label: t.gitUntracked, list: untracked, mode: "untracked" as const },
288
- ].map(
289
- (group) =>
290
- group.list.length > 0 && (
291
- <div key={group.label}>
292
- <div className="mb-1 flex items-center gap-2" style={{ fontSize: 11.5, color: group.mode === "worktree" && group.label === t.gitConflict ? "var(--dsw-danger)" : "var(--dsw-label-caption)" }}>
293
- <span style={{ fontWeight: 600 }}>{group.label}</span>
294
- <span>{group.list.length}</span>
295
- </div>
296
- <div className="flex flex-col">
297
- {group.list.map((f) => (
298
- <button
299
- key={`${group.label}:${f.path}`}
300
- type="button"
301
- className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors"
302
- title={f.path}
303
- onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
304
- onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
305
- onClick={() => openFile(f, group.mode, group.mode === "staged" ? t.gitStagedDiff : group.mode === "untracked" ? t.gitUntracked : t.gitWorktreeDiff)}
306
- >
307
- <span
308
- className="flex-none rounded px-1 text-center"
309
- style={{ minWidth: 16, fontSize: 10.5, fontFamily: "var(--font-mono)", fontWeight: 600, color: KIND_COLOR[f.kind], background: "var(--dsw-hover)" }}
310
- >
311
- {f.kind === "untracked" ? "?" : f.kind === "conflict" ? "!" : group.mode === "staged" ? f.indexStatus : f.workStatus !== " " ? f.workStatus : f.indexStatus}
312
- </span>
313
- <span className="min-w-0 flex-1 truncate" style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--dsw-label-secondary)" }}>
314
- {f.path}
315
- </span>
316
- </button>
317
- ))}
318
- </div>
319
- </div>
320
- ),
321
- )
361
+ areas.map((area) => (
362
+ <div key={area.key}>
363
+ <div className="mb-1 flex items-center gap-2" style={{ fontSize: 11.5, color: area.danger ? "var(--dsw-danger)" : "var(--dsw-label-caption)" }}>
364
+ <span style={{ fontWeight: 600 }}>{area.label}</span>
365
+ <span>{area.entries.length}</span>
366
+ </div>
367
+ <div className="flex flex-col">
368
+ {area.entries.map(({ file: f, mode }) => (
369
+ <button
370
+ key={`${area.key}:${f.path}`}
371
+ type="button"
372
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors"
373
+ title={f.path}
374
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
375
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
376
+ onClick={() => openFile(f, mode, mode === "staged" ? t.gitStagedDiff : mode === "untracked" ? t.gitUntracked : t.gitWorktreeDiff)}
377
+ >
378
+ <span
379
+ className="flex-none rounded px-1 text-center"
380
+ style={{ minWidth: 16, fontSize: 10.5, fontFamily: "var(--font-mono)", fontWeight: 600, color: KIND_COLOR[f.kind], background: "var(--dsw-hover)" }}
381
+ >
382
+ {f.kind === "untracked" ? "?" : f.kind === "conflict" ? "!" : mode === "staged" ? f.indexStatus : f.workStatus !== " " ? f.workStatus : f.indexStatus}
383
+ </span>
384
+ <span className="min-w-0 flex-1 truncate" style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--dsw-label-secondary)" }}>
385
+ {f.path}
386
+ </span>
387
+ </button>
388
+ ))}
389
+ </div>
390
+ </div>
391
+ ))
322
392
  )}
323
393
 
324
394
  {/* 最近提交 */}