@rexvane/piweb 0.3.20 → 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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +6 -4
- package/.next/build-manifest.json +5 -5
- package/.next/next-minimal-server.js.nft.json +1 -1
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +7 -7
- package/.next/react-loadable-manifest.json +12 -12
- package/.next/required-server-files.js +18 -2
- package/.next/required-server-files.json +18 -2
- package/.next/routes-manifest.json +12 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.meta +8 -0
- package/.next/server/app/_global-error.rsc +15 -11
- package/.next/server/app/_global-error.segments/_full.segment.rsc +15 -11
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +20 -16
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +3 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.meta +8 -0
- package/.next/server/app/_not-found.rsc +17 -13
- package/.next/server/app/_not-found.segments/_full.segment.rsc +17 -13
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +24 -20
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +4 -2
- package/.next/server/app/api/agent/[id]/events/route.js +3 -3
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/server/app/api/fs/route.js +1 -1
- package/.next/server/app/api/fs/route.js.nft.json +1 -1
- package/.next/server/app/api/git/route.js +4 -4
- package/.next/server/app/api/git/route.js.nft.json +1 -1
- package/.next/server/app/api/github/route.js +5 -0
- package/.next/server/app/api/github/route.js.nft.json +1 -0
- package/.next/server/app/api/github/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/health/route.js +1 -1
- package/.next/server/app/api/health/route.js.nft.json +1 -1
- package/.next/server/app/api/models/defaults/route.js +1 -0
- package/.next/server/app/api/models/defaults/route.js.nft.json +1 -0
- package/.next/server/app/api/models/defaults/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/pi-settings/route.js +1 -1
- package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/remote/route.js +3 -3
- package/.next/server/app/api/remote/route.js.nft.json +1 -1
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +2 -2
- package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/version/route.js.nft.json +1 -1
- package/.next/server/app/api/web-auth/route.js +1 -1
- package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg/route.js +1 -1
- package/.next/server/app/icon.svg/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg.meta +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.meta +8 -0
- package/.next/server/app/index.rsc +19 -13
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +25 -19
- package/.next/server/app/index.segments/_full.segment.rsc +19 -13
- package/.next/server/app/index.segments/_tree.segment.rsc +3 -2
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.meta +8 -0
- package/.next/server/app/login.rsc +23 -17
- package/.next/server/app/login.segments/_full.segment.rsc +23 -17
- package/.next/server/app/login.segments/_tree.segment.rsc +4 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +30 -24
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +6 -4
- package/.next/server/chunks/229.js +1 -1
- package/.next/server/chunks/421.js +1 -0
- package/.next/server/chunks/430.js +215 -31
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/538.js +1 -1
- package/.next/server/chunks/662.js +2 -0
- package/.next/server/chunks/686.js +1 -1
- package/.next/server/chunks/807.js +1 -1
- package/.next/server/chunks/813.js +23 -1
- package/.next/server/chunks/920.js +2 -2
- package/.next/server/chunks/924.js +1 -1
- package/.next/server/chunks/970.js +305 -0
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/middleware.js +185 -23
- package/.next/server/middleware.js.nft.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/preview-props.json +5 -0
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/WMazYvD-gbeLsCUfUxvvM/_buildManifest.js +1 -0
- package/.next/static/chunks/316-698d10eb465bd8ef.js +1 -0
- package/.next/static/chunks/3794-0978865cda773008.js +52 -0
- package/.next/static/chunks/4673.4cf66cbdd8364c57.js +1 -0
- package/.next/static/chunks/4686-09fcd0028079006c.js +1 -0
- package/.next/static/chunks/4778.eddd3a5813f85824.js +1 -0
- package/.next/static/chunks/4bd1b696-e199e788e58acf79.js +1 -0
- package/.next/static/chunks/{5173.91ddb803785009f8.js → 5173.dfd1cbb795b9a21c.js} +1 -1
- package/.next/static/chunks/618.949981da99be7182.js +4 -0
- package/.next/static/chunks/7411.67366dc5032a141d.js +1 -0
- package/.next/static/chunks/8552.25c39ef4c995eba0.js +1 -0
- package/.next/static/chunks/app/_global-error/page-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/config/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/files/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/fs/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/git/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/github/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/growth/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/health/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/models/defaults/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/models/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/remote/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/security/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/skills/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/update/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/version/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/app/page-fc4ed2ff29b6235e.js +1 -0
- package/.next/static/chunks/framework-47b723d27f4a4963.js +1 -0
- package/.next/static/chunks/main-5403f0764579e5fe.js +5 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f977af7f858990a2.js +1 -0
- package/.next/static/chunks/webpack-22ebffc20adb44ae.js +1 -0
- package/.next/static/css/6671d0d1589fdc0e.css +1 -0
- package/.next/static/css/{2959bd6b626b7ffe.css → f15a853af8b5127f.css} +1 -1
- package/.next/types/app/api/agent/[id]/events/route.ts +3 -1
- package/.next/types/app/api/agent/[id]/route.ts +3 -1
- package/.next/types/app/api/agent/[id]/tree/route.ts +3 -1
- package/.next/types/app/api/agent/new/route.ts +3 -1
- package/.next/types/app/api/config/route.ts +3 -1
- package/.next/types/app/api/files/route.ts +3 -1
- package/.next/types/app/api/fs/route.ts +3 -1
- package/.next/types/app/api/git/route.ts +3 -1
- package/.next/types/app/api/github/route.ts +355 -0
- package/.next/types/app/api/growth/route.ts +3 -1
- package/.next/types/app/api/health/route.ts +3 -1
- package/.next/types/app/api/models/defaults/route.ts +355 -0
- package/.next/types/app/api/models/route.ts +3 -1
- package/.next/types/app/api/pi-settings/route.ts +3 -1
- package/.next/types/app/api/plugins/route.ts +3 -1
- package/.next/types/app/api/prompts/route.ts +3 -1
- package/.next/types/app/api/remote/route.ts +3 -1
- package/.next/types/app/api/security/route.ts +3 -1
- package/.next/types/app/api/sessions/[id]/export/route.ts +3 -1
- package/.next/types/app/api/sessions/[id]/route.ts +3 -1
- package/.next/types/app/api/sessions/import/route.ts +3 -1
- package/.next/types/app/api/sessions/route.ts +3 -1
- package/.next/types/app/api/skills/route.ts +3 -1
- package/.next/types/app/api/update/route.ts +3 -1
- package/.next/types/app/api/version/route.ts +3 -1
- package/.next/types/app/api/web-auth/route.ts +3 -1
- package/.next/types/app/api/workspaces/route.ts +3 -1
- package/.next/types/app/layout.ts +4 -1
- package/.next/types/app/login/page.ts +4 -1
- package/.next/types/app/page.ts +4 -1
- package/.next/types/cache-life.d.ts +11 -43
- package/.next/types/routes.d.ts +3 -1
- package/.next/types/validator.ts +76 -2
- package/README.md +14 -5
- package/next.config.ts +4 -2
- package/package.json +2 -2
- package/scripts/release.mjs +1 -1
- package/src/app/api/agent/[id]/events/route.ts +42 -13
- package/src/app/api/files/route.ts +5 -2
- package/src/app/api/github/route.ts +26 -0
- package/src/app/api/models/defaults/route.ts +18 -0
- package/src/app/api/models/route.ts +24 -1
- package/src/app/api/update/route.ts +18 -2
- package/src/app/globals.css +275 -0
- package/src/components/AppShell.tsx +89 -33
- package/src/components/ChatInput.tsx +245 -63
- package/src/components/ChatWindow.tsx +123 -108
- package/src/components/ContextMeter.tsx +204 -123
- package/src/components/DrawerPanel.tsx +19 -26
- package/src/components/ExtensionUI.tsx +8 -2
- package/src/components/GitPanel.tsx +110 -40
- package/src/components/MarkdownBlock.tsx +333 -37
- package/src/components/ProjectPanel.tsx +55 -30
- package/src/components/PromptPanel.tsx +2 -4
- package/src/components/ProviderSetupModal.module.css +20 -0
- package/src/components/ProviderSetupModal.tsx +134 -29
- package/src/components/SettingsPanel.tsx +365 -80
- package/src/components/TrajectoryView.tsx +4 -6
- package/src/components/chat/chat-scroll.ts +18 -0
- package/src/components/composer/ComposerImages.tsx +306 -0
- package/src/components/update/CopyCommand.tsx +34 -0
- package/src/components/update/UpdateNotice.tsx +130 -0
- package/src/components/update/update-store.ts +155 -0
- package/src/hooks/useGrowth.ts +3 -0
- package/src/hooks/usePiWeb.ts +54 -27
- package/src/hooks/useWorkspaceFiles.ts +93 -0
- package/src/i18n.tsx +182 -6
- package/src/instrumentation-node.ts +3 -0
- package/src/lib/agent-manager.ts +193 -119
- package/src/lib/chat/event-stream.ts +113 -0
- package/src/lib/chat/stream-settings.ts +192 -0
- package/src/lib/files-service.ts +100 -1
- package/src/lib/format.ts +56 -0
- package/src/lib/git-service.ts +45 -13
- package/src/lib/github-identity.ts +245 -0
- package/src/lib/growth-service.ts +6 -2
- package/src/lib/hero-model-memory.ts +28 -0
- package/src/lib/model-defaults.ts +105 -0
- package/src/lib/model-draft.ts +257 -9
- package/src/lib/model-endpoint.ts +23 -0
- package/src/lib/model-files.ts +33 -0
- package/src/lib/models-service.ts +240 -78
- package/src/lib/pi.ts +27 -2
- package/src/lib/process-format.ts +3 -2
- package/src/lib/session/metrics.ts +31 -0
- package/src/lib/thinking-level.ts +39 -0
- package/src/lib/trajectory.ts +17 -3
- package/src/lib/types.ts +19 -1
- package/src/lib/update/startup-check.ts +117 -0
- package/src/lib/update-service.ts +23 -3
- package/src/lib/workspace-index.ts +270 -0
- package/.next/server/chunks/131.js +0 -1
- package/.next/server/chunks/243.js +0 -1
- package/.next/server/chunks/741.js +0 -165
- package/.next/static/_Y3Lq0lI5RJ1tFSOnDi0L/_buildManifest.js +0 -1
- package/.next/static/chunks/316-5ea443ee6b512aa9.js +0 -1
- package/.next/static/chunks/3794-3679f1c05160977c.js +0 -52
- package/.next/static/chunks/4673.8a5a9e6855350e34.js +0 -1
- package/.next/static/chunks/4686-b1e9fa876d0ea671.js +0 -1
- package/.next/static/chunks/4778.e4c9ed093a413bc4.js +0 -1
- package/.next/static/chunks/4bd1b696-93bf506b236d6248.js +0 -1
- package/.next/static/chunks/618.ee5d5bf1c647e483.js +0 -4
- package/.next/static/chunks/839.d3fe337a9117c325.js +0 -1
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +0 -1
- package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +0 -1
- package/.next/static/chunks/framework-c4e651fe89edfa91.js +0 -1
- package/.next/static/chunks/main-6159b22a8cb0b7c5.js +0 -5
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +0 -1
- package/.next/static/chunks/webpack-3614a8337843456b.js +0 -1
- package/.next/static/css/7391c42e1ae617f9.css +0 -1
- /package/.next/static/{_Y3Lq0lI5RJ1tFSOnDi0L → WMazYvD-gbeLsCUfUxvvM}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 流式文字输出偏好:显示样式 / 推进速度 / 打字光标。
|
|
3
|
+
*
|
|
4
|
+
* 存 localStorage(piweb.streamStyle / piweb.streamSpeed / piweb.streamCaret),
|
|
5
|
+
* 模块级订阅:设置面板一改,正在流的那条回答在下一帧就换样式,不必重开会话;
|
|
6
|
+
* 跨标签页由 storage 事件同步。
|
|
7
|
+
*
|
|
8
|
+
* 推进量是纯函数 nextRevealEnd(target, shownLength, settings):给定目标文本、已显示长度和偏好,
|
|
9
|
+
* 算出这一帧该显示到哪。不变量是「显示内容始终是目标前缀、单调增长、最终追平」——
|
|
10
|
+
* 见 tests/stream-settings.test.ts。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type StreamStyle = "smooth" | "instant" | "typewriter" | "fade";
|
|
14
|
+
|
|
15
|
+
export interface StreamSettings {
|
|
16
|
+
/** 文字出现的方式 */
|
|
17
|
+
style: StreamStyle;
|
|
18
|
+
/** 推进速度档位 1..5(3 = 标准) */
|
|
19
|
+
speed: number;
|
|
20
|
+
/** 打字光标:流式期间在文字末尾闪烁 */
|
|
21
|
+
caret: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 设置面板里的展示顺序(即时排在最后:它是「不要平滑」的退路) */
|
|
25
|
+
export const STREAM_STYLES: readonly StreamStyle[] = ["smooth", "typewriter", "fade", "instant"];
|
|
26
|
+
export const STREAM_SPEED_MIN = 1;
|
|
27
|
+
export const STREAM_SPEED_MAX = 5;
|
|
28
|
+
export const DEFAULT_STREAM_SETTINGS: StreamSettings = { style: "smooth", speed: 3, caret: false };
|
|
29
|
+
|
|
30
|
+
const KEY = {
|
|
31
|
+
style: "piweb.streamStyle",
|
|
32
|
+
speed: "piweb.streamSpeed",
|
|
33
|
+
caret: "piweb.streamCaret",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/** 每帧间隔(约 60fps 起步):档位越高越密 */
|
|
37
|
+
const STEP_MS = [24, 20, 16, 12, 8];
|
|
38
|
+
/** 平滑 / 渐显:每帧推进「积压 / 该值」,值越大追赶越缓、手感越匀(突发不再狂奔) */
|
|
39
|
+
const SMOOTH_DIVISOR = [14, 10, 7, 5, 3];
|
|
40
|
+
/** 平滑 / 渐显:每帧至少推进的字符数,保证收尾时也能走完 */
|
|
41
|
+
const SMOOTH_MIN_CHARS = [1, 1, 1, 2, 3];
|
|
42
|
+
/** 平滑 / 渐显:每帧至多推进的字符数——大块突发被摊到更多帧里缓缓出现,不会一口吞掉 */
|
|
43
|
+
const SMOOTH_MAX_CHARS = [140, 110, 90, 70, 50];
|
|
44
|
+
/** 打字机:每帧固定敲出的字符数 */
|
|
45
|
+
const TYPEWRITER_CHARS = [1, 2, 3, 5, 8];
|
|
46
|
+
/** 打字机也要有底线:积压超过约 1.5 秒的量就跟着加速,不出现「回答完了字还在爬」 */
|
|
47
|
+
const TYPEWRITER_CATCHUP_DIVISOR = 90;
|
|
48
|
+
|
|
49
|
+
export function isStreamStyle(value: string | null | undefined): value is StreamStyle {
|
|
50
|
+
return value === "smooth" || value === "instant" || value === "typewriter" || value === "fade";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clampStreamSpeed(value: number): number {
|
|
54
|
+
if (!Number.isFinite(value)) return DEFAULT_STREAM_SETTINGS.speed;
|
|
55
|
+
return Math.max(STREAM_SPEED_MIN, Math.min(STREAM_SPEED_MAX, Math.round(value)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 这一档速度下每帧的间隔(毫秒) */
|
|
59
|
+
export function streamStepMs(speed: number): number {
|
|
60
|
+
return STEP_MS[clampStreamSpeed(speed) - 1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readStored(): StreamSettings {
|
|
64
|
+
try {
|
|
65
|
+
const style = localStorage.getItem(KEY.style);
|
|
66
|
+
const speed = Number.parseInt(localStorage.getItem(KEY.speed) ?? "", 10);
|
|
67
|
+
const caret = localStorage.getItem(KEY.caret);
|
|
68
|
+
return {
|
|
69
|
+
style: isStreamStyle(style) ? style : DEFAULT_STREAM_SETTINGS.style,
|
|
70
|
+
speed: Number.isFinite(speed) ? clampStreamSpeed(speed) : DEFAULT_STREAM_SETTINGS.speed,
|
|
71
|
+
caret: caret === null ? DEFAULT_STREAM_SETTINGS.caret : caret === "1" || caret === "true",
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
// 隐私模式 / localStorage 不可用:这一份内存偏好照样生效
|
|
75
|
+
return DEFAULT_STREAM_SETTINGS;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadStreamSettings(): StreamSettings {
|
|
80
|
+
if (typeof window === "undefined") return DEFAULT_STREAM_SETTINGS;
|
|
81
|
+
return readStored();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function persist(settings: StreamSettings): void {
|
|
85
|
+
if (typeof window === "undefined") return;
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(KEY.style, settings.style);
|
|
88
|
+
localStorage.setItem(KEY.speed, String(settings.speed));
|
|
89
|
+
localStorage.setItem(KEY.caret, settings.caret ? "1" : "0");
|
|
90
|
+
} catch {
|
|
91
|
+
/* 配额满 / 被禁用:内存里已经生效 */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let cached: StreamSettings | null = null;
|
|
96
|
+
const listeners = new Set<() => void>();
|
|
97
|
+
let storageBound = false;
|
|
98
|
+
|
|
99
|
+
/** useSyncExternalStore 的 getSnapshot:同一份引用,只有真改了才换(否则会无限重渲染) */
|
|
100
|
+
export function getStreamSettings(): StreamSettings {
|
|
101
|
+
if (!cached) cached = loadStreamSettings();
|
|
102
|
+
return cached;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 服务端 / 水合首帧没有 localStorage,给默认值 */
|
|
106
|
+
export function getServerStreamSettings(): StreamSettings {
|
|
107
|
+
return DEFAULT_STREAM_SETTINGS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bindStorage(): void {
|
|
111
|
+
if (storageBound || typeof window === "undefined") return;
|
|
112
|
+
storageBound = true;
|
|
113
|
+
window.addEventListener("storage", (event) => {
|
|
114
|
+
if (event.key !== null && event.key !== KEY.style && event.key !== KEY.speed && event.key !== KEY.caret) return;
|
|
115
|
+
cached = loadStreamSettings();
|
|
116
|
+
for (const listener of listeners) listener();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function subscribeStreamSettings(listener: () => void): () => void {
|
|
121
|
+
listeners.add(listener);
|
|
122
|
+
bindStorage();
|
|
123
|
+
return () => {
|
|
124
|
+
listeners.delete(listener);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 改一项并持久化,返回合并后的完整偏好;正在流的回答下帧就用新值 */
|
|
129
|
+
export function setStreamSettings(patch: Partial<StreamSettings>): StreamSettings {
|
|
130
|
+
const current = getStreamSettings();
|
|
131
|
+
const next: StreamSettings = {
|
|
132
|
+
style: isStreamStyle(patch.style) ? patch.style : current.style,
|
|
133
|
+
speed: patch.speed === undefined ? current.speed : clampStreamSpeed(patch.speed),
|
|
134
|
+
caret: patch.caret === undefined ? current.caret : patch.caret === true,
|
|
135
|
+
};
|
|
136
|
+
cached = next;
|
|
137
|
+
persist(next);
|
|
138
|
+
for (const listener of listeners) listener();
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 不在 UTF-16 代理对中间切:切在中间会渲染成一个替换字符(半个 emoji)。
|
|
144
|
+
* 端点越界时按文本长度收口。
|
|
145
|
+
*/
|
|
146
|
+
export function safeSliceEnd(text: string, end: number): number {
|
|
147
|
+
if (end <= 0) return 0;
|
|
148
|
+
if (end >= text.length) return text.length;
|
|
149
|
+
const high = text.charCodeAt(end - 1);
|
|
150
|
+
const low = text.charCodeAt(end);
|
|
151
|
+
if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) return Math.min(end + 1, text.length);
|
|
152
|
+
return end;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 渐显:只在整词处停 —— 从 end 往回找到最近的分隔符,切在它后面;
|
|
157
|
+
* 这一段里没有分隔符(正在敲的词)就照常长,否则会停在原地走不完。
|
|
158
|
+
*/
|
|
159
|
+
function snapToWordEnd(target: string, from: number, end: number): number {
|
|
160
|
+
if (end >= target.length) return target.length;
|
|
161
|
+
for (let i = end - 1; i >= from; i -= 1) {
|
|
162
|
+
if (/\s/.test(target[i])) return i + 1;
|
|
163
|
+
}
|
|
164
|
+
return end;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 这一帧显示到哪个下标(返回值 = 目标文本前缀长度)。
|
|
169
|
+
* - instant:不排队,直接追平;
|
|
170
|
+
* - smooth:自适应 —— 积压越多推得越快,但每帧有上限:大块突发被摊到多帧缓缓出现;
|
|
171
|
+
* - typewriter:固定节奏逐字,但积压过大时跟着加速(上限约 1.5 秒的落后量);
|
|
172
|
+
* - fade:在 smooth 的基础上按整词推进。
|
|
173
|
+
*/
|
|
174
|
+
export function nextRevealEnd(target: string, shownLength: number, settings: StreamSettings): number {
|
|
175
|
+
const total = target.length;
|
|
176
|
+
if (settings.style === "instant") return total;
|
|
177
|
+
const backlog = total - shownLength;
|
|
178
|
+
if (backlog <= 0) return total;
|
|
179
|
+
const level = clampStreamSpeed(settings.speed) - 1;
|
|
180
|
+
let end: number;
|
|
181
|
+
if (settings.style === "typewriter") {
|
|
182
|
+
end = shownLength + Math.max(TYPEWRITER_CHARS[level], Math.ceil(backlog / TYPEWRITER_CATCHUP_DIVISOR));
|
|
183
|
+
} else {
|
|
184
|
+
end = shownLength + Math.min(
|
|
185
|
+
SMOOTH_MAX_CHARS[level],
|
|
186
|
+
Math.max(SMOOTH_MIN_CHARS[level], Math.ceil(backlog / SMOOTH_DIVISOR[level])),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (end > total) end = total;
|
|
190
|
+
if (settings.style === "fade") end = snapToWordEnd(target, shownLength, end);
|
|
191
|
+
return safeSliceEnd(target, end);
|
|
192
|
+
}
|
package/src/lib/files-service.ts
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* files-service:会话工作区的文件浏览(详情栏「文件」面板)。
|
|
3
3
|
* 安全边界:目标路径必须落在工作区根内(realpath 双重校验,防符号链接逃逸)。
|
|
4
4
|
*/
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
6
|
import fs from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
8
9
|
import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
|
|
9
10
|
import { getAgentDir } from "./pi";
|
|
11
|
+
import { resolveGitBin } from "./growth-service";
|
|
10
12
|
import { utf8Prefix } from "./text-sanitize";
|
|
11
13
|
|
|
14
|
+
const exec = promisify(execFile);
|
|
15
|
+
|
|
12
16
|
export interface FileEntry {
|
|
13
17
|
name: string;
|
|
14
18
|
kind: "dir" | "file";
|
|
@@ -29,6 +33,101 @@ const MAX_READ_BYTES = 256 * 1024;
|
|
|
29
33
|
/** 单次目录响应上限;后续条目通过 offset 分页获取。 */
|
|
30
34
|
const MAX_ENTRIES = 2000;
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @ 引用的候选索引上限。一次拉给浏览器做本地模糊匹配,所以是一份完整清单:
|
|
38
|
+
* 每敲一个字符不再发请求(实测本仓库 700 余条 ≈ 30 KB)。
|
|
39
|
+
* 超大仓库截断到上限并在界面标出,不静默少候选。
|
|
40
|
+
*/
|
|
41
|
+
const INDEX_LIMIT = 20_000;
|
|
42
|
+
/** 非 git 工作区的退路:只跳过这些明显不该当候选的重目录,其余照常列举 */
|
|
43
|
+
const INDEX_SKIP_DIRS = new Set([".git", "node_modules", ".next", ".next-dev", ".venv", "venv", "__pycache__", "dist", "build", "out", "target", ".turbo", ".cache"]);
|
|
44
|
+
const INDEX_MAX_DEPTH = 12;
|
|
45
|
+
|
|
46
|
+
export interface WorkspaceIndex {
|
|
47
|
+
/** 工作区根下的相对路径("/" 分隔)。只给文件:文件夹由前端从文件路径推导 */
|
|
48
|
+
files: string[];
|
|
49
|
+
/** git = `git ls-files`(尊重 .gitignore);disk = 不是仓库时的目录遍历退路 */
|
|
50
|
+
source: "git" | "disk";
|
|
51
|
+
/** 候选被上限截断:匹配结果可能不全,界面要说明 */
|
|
52
|
+
truncated: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `git ls-files -z` 输出 → 相对路径数组(去重) */
|
|
56
|
+
export function parseLsFiles(raw: string): string[] {
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
for (const entry of raw.split("\0")) {
|
|
59
|
+
if (!entry) continue;
|
|
60
|
+
const rel = entry.replace(/\\/g, "/");
|
|
61
|
+
if (rel.startsWith("/") || rel.split("/").includes("..")) continue;
|
|
62
|
+
seen.add(rel);
|
|
63
|
+
}
|
|
64
|
+
return [...seen];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 不是仓库(或机器上没有 git)时的退路:广度优先遍历,只收文件 */
|
|
68
|
+
async function walkWorkspaceFiles(root: string): Promise<{ files: string[]; truncated: boolean }> {
|
|
69
|
+
const files: string[] = [];
|
|
70
|
+
let queue: Array<{ rel: string; depth: number }> = [{ rel: "", depth: 0 }];
|
|
71
|
+
while (queue.length) {
|
|
72
|
+
const next: typeof queue = [];
|
|
73
|
+
for (const dir of queue) {
|
|
74
|
+
const entries = await fs.readdir(path.join(root, dir.rel), { withFileTypes: true }).catch(() => []);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const rel = dir.rel ? `${dir.rel}/${entry.name}` : entry.name;
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
if (dir.depth + 1 <= INDEX_MAX_DEPTH && !INDEX_SKIP_DIRS.has(entry.name)) next.push({ rel, depth: dir.depth + 1 });
|
|
79
|
+
} else if (entry.isFile()) {
|
|
80
|
+
files.push(rel);
|
|
81
|
+
if (files.length > INDEX_LIMIT) return { files: files.slice(0, INDEX_LIMIT), truncated: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
queue = next;
|
|
86
|
+
}
|
|
87
|
+
return { files, truncated: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* git 失败时只有「不是仓库」「没装 git」两种该退回遍历——界面会把 disk 来源解释成这两种情况。
|
|
92
|
+
* 其它失败(safe.directory 拒绝、索引损坏、超时)照实报错:退回遍历会把 .gitignore 掉的文件混进候选,还给出错误的解释。
|
|
93
|
+
*/
|
|
94
|
+
function gitIndexFailure(error: unknown): "fallback" | Error {
|
|
95
|
+
const e = error as NodeJS.ErrnoException & { stderr?: string; killed?: boolean };
|
|
96
|
+
if (e.code === "ENOENT") return "fallback";
|
|
97
|
+
const stderr = String(e.stderr ?? "");
|
|
98
|
+
if (/not a git repository/i.test(stderr)) return "fallback";
|
|
99
|
+
if (e.killed) return new Error("git ls-files timed out");
|
|
100
|
+
const fatal = stderr.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
101
|
+
return new Error(`git ls-files failed: ${fatal.find((line) => line.startsWith("fatal:")) ?? fatal[0] ?? e.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @ 引用的候选清单:git 可见的文件(含未被忽略的未跟踪文件)。
|
|
106
|
+
* `git ls-files` 的路径相对当前目录,所以 cwd 就是工作区根,输出天然是工作区相对路径。
|
|
107
|
+
*/
|
|
108
|
+
export async function listWorkspaceIndex(cwdValue: unknown): Promise<WorkspaceIndex> {
|
|
109
|
+
const root = await resolveWorkspacePath(cwdValue);
|
|
110
|
+
const git = await resolveGitBin();
|
|
111
|
+
try {
|
|
112
|
+
const { stdout } = await exec(git, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
113
|
+
cwd: root,
|
|
114
|
+
timeout: 15_000,
|
|
115
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
// LC_ALL=C:报错固定为英文,「不是仓库」才认得出来(中文界面的 git 会翻译这句);-z 输出的路径不受影响
|
|
118
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" },
|
|
119
|
+
});
|
|
120
|
+
const files = parseLsFiles(stdout).sort((a, b) => a.localeCompare(b));
|
|
121
|
+
return { files: files.slice(0, INDEX_LIMIT), source: "git", truncated: files.length > INDEX_LIMIT };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const failure = gitIndexFailure(error);
|
|
124
|
+
if (failure !== "fallback") throw failure;
|
|
125
|
+
const walked = await walkWorkspaceFiles(root);
|
|
126
|
+
return { files: walked.files.sort((a, b) => a.localeCompare(b)), source: "disk", truncated: walked.truncated };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
32
131
|
function toRel(root: string, target: string): string {
|
|
33
132
|
const rel = path.relative(root, target).replace(/\\/g, "/");
|
|
34
133
|
return rel === "" ? "." : rel;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全站数字显示格式。
|
|
3
|
+
*
|
|
4
|
+
* 精度口径(用户要求「精确到小数点后两位」):
|
|
5
|
+
* - 比率(缓存命中 / 上下文占用)、速率(tok/s)、秒级时长:一律两位小数;
|
|
6
|
+
* - token 量:≥1000 用 K、≥100 万用 M,都两位小数;<1000 原样整数(计数没有小数含义);
|
|
7
|
+
* - 轮次 / 步数 / 文件数等计数:整数;
|
|
8
|
+
* - 分钟级时长保持 `19m30s`(`19m30.42s` 更难读)、金额保持四位(`$0.0123` 是惯例)。
|
|
9
|
+
*
|
|
10
|
+
* 以前 ChatWindow / ContextMeter / TrajectoryView 各有一份 fmtTok,同一个数字在三处
|
|
11
|
+
* 显示不同(262400 → `262K` / `262.4K` / `262.4K`;53000000 → `53M` / `53000.0K` / `53.0M`),
|
|
12
|
+
* 统一到这里。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** token 量:K / M 两位小数,<1000 整数 */
|
|
16
|
+
export function fmtTokens(value: number): string {
|
|
17
|
+
if (!Number.isFinite(value)) return "—";
|
|
18
|
+
if (Math.abs(value) < 1_000) return String(Math.round(value));
|
|
19
|
+
// 先按 K 缩放;四舍五入到两位小数后已到 1000.00K 就升级成 M,避免出现「1000.00K」
|
|
20
|
+
const k = value / 1_000;
|
|
21
|
+
if (Math.abs(Number(k.toFixed(2))) < 1_000) return `${k.toFixed(2)}K`;
|
|
22
|
+
return `${(value / 1_000_000).toFixed(2)}M`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 计数(轮次 / 步数 / 文件数):整数 */
|
|
26
|
+
export function fmtCount(value: number): string {
|
|
27
|
+
return Number.isFinite(value) ? String(Math.round(value)) : "—";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 百分比:入参是 0–100 的数值,输出两位小数 */
|
|
31
|
+
export function fmtPercent(percent: number): string {
|
|
32
|
+
return `${(Number.isFinite(percent) ? percent : 0).toFixed(2)}%`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 速率:tok/s,两位小数 */
|
|
36
|
+
export function fmtThroughput(tokensPerSecond: number): string {
|
|
37
|
+
return (Number.isFinite(tokensPerSecond) ? tokensPerSecond : 0).toFixed(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 时长:不足 60 秒两位小数(`5.32s`),到分钟级用 `19m30s`(秒级再补小数只会更难读)。
|
|
42
|
+
*/
|
|
43
|
+
export function fmtDuration(ms: number): string {
|
|
44
|
+
const seconds = Math.max(0, Number.isFinite(ms) ? ms : 0) / 1_000;
|
|
45
|
+
if (seconds < 60) return `${seconds.toFixed(2)}s`;
|
|
46
|
+
const whole = Math.round(seconds);
|
|
47
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 文件大小:B 整数,KB / MB 两位小数 */
|
|
51
|
+
export function fmtBytes(bytes: number): string {
|
|
52
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
53
|
+
if (bytes < 1_024) return `${Math.round(bytes)} B`;
|
|
54
|
+
if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(2)} KB`;
|
|
55
|
+
return `${(bytes / 1_024 / 1_024).toFixed(2)} MB`;
|
|
56
|
+
}
|
package/src/lib/git-service.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { execFile } from "node:child_process";
|
|
|
7
7
|
import fs from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
-
import { resolveGitBin } from "./growth-service";
|
|
10
|
+
import { parseNumstat, resolveGitBin } from "./growth-service";
|
|
11
11
|
import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
|
|
12
12
|
|
|
13
13
|
const exec = promisify(execFile);
|
|
@@ -76,7 +76,8 @@ function kindOf(x: string, y: string): GitFileEntry["kind"] {
|
|
|
76
76
|
return "modified";
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
/** 解析仓库根(GitHub 身份解析也要用,所以导出) */
|
|
80
|
+
export async function repoRoot(cwd: string): Promise<{ available: boolean; root: string | null }> {
|
|
80
81
|
try {
|
|
81
82
|
const out = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
82
83
|
return { available: true, root: out ? path.resolve(out) : null };
|
|
@@ -187,11 +188,34 @@ export interface GitDiffResult {
|
|
|
187
188
|
patch: string;
|
|
188
189
|
truncated: boolean;
|
|
189
190
|
binary: boolean;
|
|
191
|
+
/** 完整 diff 的 Git numstat,独立于用于展示的 patch 截断。二进制不计行数。 */
|
|
192
|
+
stats: { add: number; del: number };
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
async function fullDiffStats(root: string, args: string[], noIndex = false): Promise<{ stats: GitDiffResult["stats"]; binary: boolean }> {
|
|
196
|
+
let raw: string;
|
|
197
|
+
try {
|
|
198
|
+
raw = await runGit(root, args);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// --no-index 的退出码 1 表示有差异,stdout 仍是有效的 numstat。
|
|
201
|
+
const result = error as { code?: number; stdout?: string };
|
|
202
|
+
if (!noIndex || result.code !== 1 || typeof result.stdout !== "string" || !result.stdout) throw error;
|
|
203
|
+
raw = result.stdout;
|
|
204
|
+
}
|
|
205
|
+
let add = 0;
|
|
206
|
+
let del = 0;
|
|
207
|
+
let binary = false;
|
|
208
|
+
for (const entry of parseNumstat(raw).values()) {
|
|
209
|
+
add += entry.add;
|
|
210
|
+
del += entry.del;
|
|
211
|
+
binary ||= entry.binary;
|
|
212
|
+
}
|
|
213
|
+
return { stats: { add, del }, binary };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function truncate(patch: string, stats: GitDiffResult["stats"]): GitDiffResult {
|
|
217
|
+
if (Buffer.byteLength(patch) <= MAX_DIFF_BYTES) return { patch, truncated: false, binary: false, stats };
|
|
218
|
+
return { patch: Buffer.from(patch).subarray(0, MAX_DIFF_BYTES).toString("utf8"), truncated: true, binary: false, stats };
|
|
195
219
|
}
|
|
196
220
|
|
|
197
221
|
function assertRepoRelative(root: string, relPath: unknown): string {
|
|
@@ -215,6 +239,8 @@ export async function gitDiff(cwdValue: unknown, relPath: unknown, mode: unknown
|
|
|
215
239
|
if (!isPathInside(realRoot, realFile)) throw new BoundaryError("path escapes the repository");
|
|
216
240
|
const stat = await fs.stat(realFile).catch(() => null);
|
|
217
241
|
if (!stat?.isFile()) throw new BoundaryError("file not found");
|
|
242
|
+
const { stats, binary } = await fullDiffStats(root, ["diff", "--no-index", "--no-ext-diff", "--no-textconv", "--numstat", "-z", "--", "/dev/null", realFile], true);
|
|
243
|
+
if (binary) return { patch: "", truncated: false, binary: true, stats };
|
|
218
244
|
const buffer = Buffer.alloc(Math.min(stat.size, MAX_DIFF_BYTES + 1));
|
|
219
245
|
const handle = await fs.open(realFile, "r");
|
|
220
246
|
let bytesRead = 0;
|
|
@@ -228,8 +254,7 @@ export async function gitDiff(cwdValue: unknown, relPath: unknown, mode: unknown
|
|
|
228
254
|
await handle.close();
|
|
229
255
|
}
|
|
230
256
|
const head = buffer.subarray(0, bytesRead);
|
|
231
|
-
|
|
232
|
-
const text = head.subarray(0, MAX_DIFF_BYTES).toString("utf8").replace(/\r/g, "");
|
|
257
|
+
const text = head.subarray(0, MAX_DIFF_BYTES).toString("utf8").replace(/\r\n/g, "\n");
|
|
233
258
|
const lines = text.split("\n");
|
|
234
259
|
if (lines[lines.length - 1] === "") lines.pop();
|
|
235
260
|
const body = lines.map((l) => `+${l}`).join("\n");
|
|
@@ -237,12 +262,16 @@ export async function gitDiff(cwdValue: unknown, relPath: unknown, mode: unknown
|
|
|
237
262
|
patch: `--- /dev/null\n+++ b/${rel}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`,
|
|
238
263
|
truncated: stat.size > MAX_DIFF_BYTES,
|
|
239
264
|
binary: false,
|
|
265
|
+
stats,
|
|
240
266
|
};
|
|
241
267
|
}
|
|
242
|
-
const
|
|
243
|
-
const out = await
|
|
244
|
-
|
|
245
|
-
|
|
268
|
+
const scope = mode === "staged" ? ["--cached"] : [];
|
|
269
|
+
const [out, counts] = await Promise.all([
|
|
270
|
+
runGit(root, ["diff", ...scope, "--no-color", "--no-ext-diff", "--no-textconv", "--", rel], 8 * 1024 * 1024),
|
|
271
|
+
fullDiffStats(root, ["diff", ...scope, "--no-ext-diff", "--no-textconv", "--numstat", "-z", "--", rel]),
|
|
272
|
+
]);
|
|
273
|
+
if (counts.binary) return { patch: "", truncated: false, binary: true, stats: counts.stats };
|
|
274
|
+
return truncate(out, counts.stats);
|
|
246
275
|
}
|
|
247
276
|
|
|
248
277
|
/** 单次提交的内容(含各文件 patch) */
|
|
@@ -252,6 +281,9 @@ export async function gitShow(cwdValue: unknown, hash: unknown): Promise<GitDiff
|
|
|
252
281
|
if (!available || !root) throw new BoundaryError("not a git repository");
|
|
253
282
|
if (typeof hash !== "string" || !/^[0-9a-f]{4,40}$/i.test(hash)) throw new BoundaryError("invalid commit hash");
|
|
254
283
|
const title = (await runGit(root, ["log", "-1", "--pretty=%h %s%n%an · %cr", hash])).trim();
|
|
255
|
-
const out = await
|
|
256
|
-
|
|
284
|
+
const [out, counts] = await Promise.all([
|
|
285
|
+
runGit(root, ["show", "--no-color", "--no-ext-diff", "--no-textconv", "--format=", "--patch", hash], 8 * 1024 * 1024),
|
|
286
|
+
fullDiffStats(root, ["show", "--no-ext-diff", "--no-textconv", "--format=", "--numstat", "-z", hash]),
|
|
287
|
+
]);
|
|
288
|
+
return { title, ...truncate(out, counts.stats), binary: counts.binary && !out.includes("@@ ") };
|
|
257
289
|
}
|