@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
|
@@ -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
|
-
|
|
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
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
{/* 最近提交 */}
|
|
@@ -7,13 +7,33 @@
|
|
|
7
7
|
* 带着 lowlight/highlight.js 的常用语言集,实测占首屏 JS 的 472KB(全部首屏 JS 1272KB),
|
|
8
8
|
* 而 Hero 页(还没开会话)根本用不到它。ChatWindow 用 next/dynamic 引用本模块,
|
|
9
9
|
* 首屏不再下载这 472KB;AppShell 在空闲时预取,真正打开会话时通常已经就绪。
|
|
10
|
+
*
|
|
11
|
+
* 流式输出版(v7):
|
|
12
|
+
* - 显示样式可配(见 lib/chat/stream-settings):平滑 / 打字机 / 渐显 / 即时,速度五档、可选打字光标;
|
|
13
|
+
* 设置里改完下一帧生效,正在流的那条回答不用重开。
|
|
14
|
+
* - 逐帧推进:把「已到达的完整文本」和「已显示的文本」分开,每帧只把显示进度往前推一小步,
|
|
15
|
+
* 服务端 token 再来得快、再成串到达,界面也是匀速长出来的,不再一跳一跳。
|
|
16
|
+
* - 增量切分 + 分块记忆化:「已稳定的完整块」按 key 记忆化,扫描进度也复用上一帧,
|
|
17
|
+
* 每帧代价只与「新增的那几个字」成正比(旧版每帧重扫全文才是长回答的卡顿来源)。
|
|
18
|
+
* - 代码围栏内的 tail 用纯文本渲染,等闭合后再交给 highlight.js 高亮一次,
|
|
19
|
+
* 避免流式代码块每帧重新高亮。
|
|
20
|
+
* - 贴底听对话区的(见 chat/chat-scroll);回答结束后先把还没放出来的字放完,再换成整段渲染。
|
|
10
21
|
*/
|
|
11
|
-
import { memo, useEffect, useRef, useState } from "react";
|
|
22
|
+
import { memo, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
12
23
|
import ReactMarkdown from "react-markdown";
|
|
13
24
|
import rehypeHighlight from "rehype-highlight";
|
|
14
25
|
import remarkGfm from "remark-gfm";
|
|
26
|
+
import { ChatScrollContext } from "@/components/chat/chat-scroll";
|
|
15
27
|
import { IconCheckOutline14, IconCopyOutline16 } from "@/components/icons";
|
|
16
28
|
import { copyText } from "@/lib/clipboard";
|
|
29
|
+
import {
|
|
30
|
+
getServerStreamSettings,
|
|
31
|
+
getStreamSettings,
|
|
32
|
+
nextRevealEnd,
|
|
33
|
+
streamStepMs,
|
|
34
|
+
subscribeStreamSettings,
|
|
35
|
+
type StreamSettings,
|
|
36
|
+
} from "@/lib/chat/stream-settings";
|
|
17
37
|
import { useI18n } from "@/i18n";
|
|
18
38
|
|
|
19
39
|
type MarkdownTextNode = { value?: unknown; children?: MarkdownTextNode[] };
|
|
@@ -84,51 +104,327 @@ function SafeImage({ src, alt, title }: { src?: unknown; alt?: string; title?: s
|
|
|
84
104
|
);
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 只有 Markdown 内容、没有 .md 外层容器的纯内容组件。
|
|
109
|
+
* 流式分块时,多个块共用同一个 .md 容器(外边距规则因此照旧生效),
|
|
110
|
+
* 每块各自记忆化:只要 text 没变就不会重新解析。
|
|
111
|
+
*/
|
|
112
|
+
const MarkdownContent = memo(function MarkdownContent({ text }: { text: string }) {
|
|
113
|
+
return (
|
|
114
|
+
<ReactMarkdown
|
|
115
|
+
remarkPlugins={[remarkGfm]}
|
|
116
|
+
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
|
117
|
+
components={{
|
|
118
|
+
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
|
119
|
+
img: ({ src, alt, title }) => <SafeImage src={src} alt={alt} title={title} />,
|
|
120
|
+
pre: ({ node, children, ...props }) => (
|
|
121
|
+
<div className="md-code-block">
|
|
122
|
+
<div className="md-code-copy"><CopyButton text={markdownNodeText(node)} /></div>
|
|
123
|
+
<pre {...props}>{children}</pre>
|
|
124
|
+
</div>
|
|
125
|
+
),
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{text}
|
|
129
|
+
</ReactMarkdown>
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
87
133
|
export const MarkdownBlock = memo(function MarkdownBlock({ text }: { text: string }) {
|
|
88
134
|
return (
|
|
89
135
|
<div className="md">
|
|
90
|
-
<
|
|
91
|
-
remarkPlugins={[remarkGfm]}
|
|
92
|
-
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
|
93
|
-
components={{
|
|
94
|
-
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
|
95
|
-
img: ({ src, alt, title }) => <SafeImage src={src} alt={alt} title={title} />,
|
|
96
|
-
pre: ({ node, children, ...props }) => (
|
|
97
|
-
<div className="md-code-block">
|
|
98
|
-
<div className="md-code-copy"><CopyButton text={markdownNodeText(node)} /></div>
|
|
99
|
-
<pre {...props}>{children}</pre>
|
|
100
|
-
</div>
|
|
101
|
-
),
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
{text}
|
|
105
|
-
</ReactMarkdown>
|
|
136
|
+
<MarkdownContent text={text} />
|
|
106
137
|
</div>
|
|
107
138
|
);
|
|
108
139
|
});
|
|
109
140
|
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
// ---------- 流式渲染 ----------
|
|
142
|
+
|
|
143
|
+
/** 列表项(含有序列表);用于避免在松散列表内部的空行处切块 */
|
|
144
|
+
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d+[.)])\s/;
|
|
145
|
+
/** 围栏行:标记 + 其后的内容(开头行是语言等信息,闭合行只能是空白) */
|
|
146
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
147
|
+
/** 缩进起头的行:上面列表项的续行,或缩进代码块——都和前面连着,不从这里切 */
|
|
148
|
+
const INDENTED_RE = /^[ \t]+\S/;
|
|
149
|
+
|
|
150
|
+
interface FenceState {
|
|
151
|
+
inFence: boolean;
|
|
152
|
+
char: string;
|
|
153
|
+
length: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 按 CommonMark 推进围栏状态,返回这一行开启 / 闭合了围栏,或者都不是。
|
|
158
|
+
* 闭合行必须是同一种字符、不短于开头、后面没有别的内容:围栏里出现 "```js" 或更短的 "```"
|
|
159
|
+
* 都还是代码。反引号开头行的信息里不能再有反引号,否则那一行只是普通文本。
|
|
160
|
+
*/
|
|
161
|
+
function advanceFence(state: FenceState, line: string): "open" | "close" | null {
|
|
162
|
+
const fence = FENCE_RE.exec(line);
|
|
163
|
+
if (!fence) return null;
|
|
164
|
+
const [, marker, rest] = fence;
|
|
165
|
+
if (!state.inFence) {
|
|
166
|
+
if (marker[0] === "`" && rest.includes("`")) return null;
|
|
167
|
+
state.inFence = true;
|
|
168
|
+
state.char = marker[0];
|
|
169
|
+
state.length = marker.length;
|
|
170
|
+
return "open";
|
|
171
|
+
}
|
|
172
|
+
if (marker[0] === state.char && marker.length >= state.length && rest.trim() === "") {
|
|
173
|
+
state.inFence = false;
|
|
174
|
+
return "close";
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface StreamingSplit {
|
|
180
|
+
/** 已稳定的完整块:文本不再变,各自记忆化,不会跟着尾巴一起重新解析 */
|
|
181
|
+
blocks: string[];
|
|
182
|
+
/** 正在生成的尾巴 */
|
|
183
|
+
tail: string;
|
|
184
|
+
/** 尾巴是否落在未闭合的代码围栏里 */
|
|
185
|
+
tailInFence: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 增量切分器:流式文本只会往后长,已经扫过的行不可能再改变判定,所以只扫新增的行。
|
|
190
|
+
* 每帧代价与「新增文本」成正比,长回答不会随长度增长而越来越卡(旧版每帧重扫全文)。
|
|
191
|
+
*
|
|
192
|
+
* 切分规则:
|
|
193
|
+
* - 只在代码围栏之外的空行处切:空行是天然的块边界,两侧各自都是完整可解析的 Markdown;
|
|
194
|
+
* - 还在列表里、下一行又是列表项(松散列表)时不切,保持有序列表序号连续。列表项下面缩进的
|
|
195
|
+
* 续段不算离开列表;空行后顶格的非列表行才算;
|
|
196
|
+
* - 下一行缩进起头时不切:那是上面列表项的续段(或缩进代码块),切开会被当成独立段落;
|
|
197
|
+
* - 半行(还没换行符)不急着判:半行 "2." 看着不像列表项,写完 "2. 乙" 才是,判早了会
|
|
198
|
+
* 把松散列表切开、序号重排。只有「再长也不会改变判定」时才就地判,否则等下一行写完。
|
|
199
|
+
*/
|
|
200
|
+
export function createStreamingSplitter(): { update: (text: string) => StreamingSplit } {
|
|
201
|
+
let prevText = "";
|
|
202
|
+
let scanPos = 0;
|
|
203
|
+
let fence: FenceState = { inFence: false, char: "", length: 0 };
|
|
204
|
+
let segStart = 0;
|
|
205
|
+
/** 最近一次顶格内容是列表项(还在列表里) */
|
|
206
|
+
let inList = false;
|
|
207
|
+
let prevBlank = true;
|
|
208
|
+
let blocks: string[] = [];
|
|
209
|
+
|
|
210
|
+
const reset = () => {
|
|
211
|
+
prevText = "";
|
|
212
|
+
scanPos = 0;
|
|
213
|
+
fence = { inFence: false, char: "", length: 0 };
|
|
214
|
+
segStart = 0;
|
|
215
|
+
inList = false;
|
|
216
|
+
prevBlank = true;
|
|
217
|
+
blocks = [];
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
update(text: string): StreamingSplit {
|
|
222
|
+
// 被整体替换(编辑重发、切会话)时重头扫;正常流式是前缀延长
|
|
223
|
+
if (!text.startsWith(prevText)) reset();
|
|
224
|
+
prevText = text;
|
|
225
|
+
while (scanPos < text.length) {
|
|
226
|
+
const nl = text.indexOf("\n", scanPos);
|
|
227
|
+
// 半行不判:等换行到了再判(围栏行、空行边界都可能被这半行改变)
|
|
228
|
+
if (nl === -1) break;
|
|
229
|
+
const line = text.slice(scanPos, nl);
|
|
230
|
+
const wasInFence = fence.inFence;
|
|
231
|
+
const fenceStep = advanceFence(fence, line);
|
|
232
|
+
const blank = line.trim() === "";
|
|
233
|
+
if (!fence.inFence && blank) {
|
|
234
|
+
const nextStart = nl + 1;
|
|
235
|
+
const nextNl = text.indexOf("\n", nextStart);
|
|
236
|
+
const nextLine = text.slice(nextStart, nextNl === -1 ? text.length : nextNl);
|
|
237
|
+
const nextIndented = INDENTED_RE.test(nextLine);
|
|
238
|
+
// 下一行是否已经写完,或者「再长也不会改变判定」:缩进起头的永远不切;
|
|
239
|
+
// 不在列表里时,非空的下一行写成什么都要切
|
|
240
|
+
const decidable =
|
|
241
|
+
nextNl !== -1 ||
|
|
242
|
+
(nextStart < text.length && nextLine.trim() !== "" && (nextIndented || !inList));
|
|
243
|
+
if (!decidable) break; // scanPos 留在空行处,等下一行写完再判
|
|
244
|
+
const keepTogether = nextIndented || (inList && LIST_ITEM_RE.test(nextLine));
|
|
245
|
+
if (!keepTogether && nextLine.trim() !== "") {
|
|
246
|
+
const seg = text.slice(segStart, nextStart);
|
|
247
|
+
if (seg.trim()) blocks.push(seg);
|
|
248
|
+
segStart = nextStart;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// 列表状态只看围栏外的行:列表项进入列表;顶格的围栏、或空行后顶格的普通行离开列表
|
|
252
|
+
if (!wasInFence && !blank) {
|
|
253
|
+
if (LIST_ITEM_RE.test(line)) inList = true;
|
|
254
|
+
else if (fenceStep === "open" ? !INDENTED_RE.test(line) : prevBlank && !INDENTED_RE.test(line)) inList = false;
|
|
255
|
+
}
|
|
256
|
+
if (!wasInFence || !fence.inFence) prevBlank = blank;
|
|
257
|
+
scanPos = nl + 1;
|
|
258
|
+
}
|
|
259
|
+
return { blocks: blocks.slice(), tail: text.slice(segStart), tailInFence: fence.inFence };
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 把落在未闭合围栏里的尾巴拆成「围栏前的 Markdown」和「围栏里的代码」。
|
|
266
|
+
* 开头那行(```python)不能当代码露出来;正在敲、还没换行的闭合行也不露。
|
|
267
|
+
*/
|
|
268
|
+
export function splitOpenFence(tail: string): { before: string; lang: string; code: string } {
|
|
269
|
+
const state: FenceState = { inFence: false, char: "", length: 0 };
|
|
270
|
+
let openAt = -1;
|
|
271
|
+
let codeAt = 0;
|
|
272
|
+
let lang = "";
|
|
273
|
+
let pos = 0;
|
|
274
|
+
while (pos < tail.length) {
|
|
275
|
+
const nl = tail.indexOf("\n", pos);
|
|
276
|
+
// 开头行一定是完整行:切分器只按完整行判围栏
|
|
277
|
+
if (nl === -1) break;
|
|
278
|
+
const line = tail.slice(pos, nl);
|
|
279
|
+
if (advanceFence(state, line) === "open") {
|
|
280
|
+
openAt = pos;
|
|
281
|
+
codeAt = nl + 1;
|
|
282
|
+
lang = (FENCE_RE.exec(line)?.[2] ?? "").trim().split(/\s+/)[0] ?? "";
|
|
283
|
+
}
|
|
284
|
+
pos = nl + 1;
|
|
285
|
+
}
|
|
286
|
+
if (openAt === -1 || !state.inFence) return { before: tail, lang: "", code: "" };
|
|
287
|
+
let code = tail.slice(codeAt);
|
|
288
|
+
const partial = code.slice(code.lastIndexOf("\n") + 1);
|
|
289
|
+
const marker = partial.trimStart();
|
|
290
|
+
if (marker && partial.length - marker.length <= 3 && marker[0] === state.char && /^(`+|~+)\s*$/.test(marker)) {
|
|
291
|
+
code = code.slice(0, code.length - partial.length);
|
|
292
|
+
}
|
|
293
|
+
return { before: tail.slice(0, openAt), lang, code };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 一次性切分(冷启动 / 测试用);流式循环里请复用 createStreamingSplitter 的扫描进度 */
|
|
297
|
+
export function splitStreamingMarkdown(text: string): StreamingSplit {
|
|
298
|
+
return createStreamingSplitter().update(text);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 流式偏好:设置面板一改,这里立刻换样式(正在流的那条回答不用重开) */
|
|
302
|
+
function useStreamSettings(): StreamSettings {
|
|
303
|
+
return useSyncExternalStore(subscribeStreamSettings, getStreamSettings, getServerStreamSettings);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
interface StreamView extends StreamingSplit {
|
|
307
|
+
/** 当前显示出来的部分(始终是目标文本的前缀) */
|
|
308
|
+
shown: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* live:回答还在流。结束后(live 变 false)组件不急着换掉——先按原节奏把还没放出来的字放完,
|
|
313
|
+
* 追平后再渲染成整段 Markdown(和历史消息同一种渲染,脚注、引用式链接这类跨块语法也就对了)。
|
|
314
|
+
* 一结束就整段替换的话,打字机这类慢样式积压的一两百字会一口吐出来。
|
|
315
|
+
*/
|
|
316
|
+
export const StreamingMarkdownBlock = memo(function StreamingMarkdownBlock({ text, live = true }: { text: string; live?: boolean }) {
|
|
317
|
+
const settings = useStreamSettings();
|
|
318
|
+
const scroll = useContext(ChatScrollContext);
|
|
319
|
+
const splitterRef = useRef<{ update: (text: string) => StreamingSplit } | null>(null);
|
|
320
|
+
if (!splitterRef.current) splitterRef.current = createStreamingSplitter();
|
|
321
|
+
const [view, setView] = useState<StreamView>(() => ({ shown: text, ...splitterRef.current!.update(text) }));
|
|
322
|
+
const viewRef = useRef(view);
|
|
323
|
+
const targetRef = useRef(text);
|
|
324
|
+
const settingsRef = useRef(settings);
|
|
325
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
326
|
+
|
|
327
|
+
const commit = (shown: string) => {
|
|
328
|
+
const next: StreamView = { shown, ...splitterRef.current!.update(shown) };
|
|
329
|
+
viewRef.current = next;
|
|
330
|
+
setView(next);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const stopTimer = () => {
|
|
334
|
+
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
|
335
|
+
timerRef.current = null;
|
|
336
|
+
};
|
|
112
337
|
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
338
|
+
/** 一帧:只把显示进度往前推一点。用 setTimeout 而非 rAF:后台标签页 / 测试环境同样确定。 */
|
|
339
|
+
const step = () => {
|
|
340
|
+
timerRef.current = null;
|
|
341
|
+
const target = targetRef.current;
|
|
342
|
+
const shown = viewRef.current.shown;
|
|
343
|
+
if (shown === target) return; // 追平,收工(下次文本变化再启动)
|
|
344
|
+
if (!target.startsWith(shown)) {
|
|
345
|
+
commit(target); // 目标被整体替换(编辑重发):直接跟上,不再逐帧
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const current = settingsRef.current;
|
|
349
|
+
commit(target.slice(0, nextRevealEnd(target, shown.length, current)));
|
|
350
|
+
timerRef.current = setTimeout(step, streamStepMs(current.speed));
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/** 起节拍(已经在跑就不动);「即时」不排队,直接追平 */
|
|
354
|
+
const start = () => {
|
|
355
|
+
if (timerRef.current !== null) return;
|
|
356
|
+
const current = settingsRef.current;
|
|
357
|
+
if (current.style === "instant") {
|
|
358
|
+
if (viewRef.current.shown !== targetRef.current) commit(targetRef.current);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
timerRef.current = setTimeout(step, streamStepMs(current.speed));
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// 目标变化:更新目标,节拍没在跑就起一个。已有节拍时不重置计时器(防抖会让正文冻住)
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
targetRef.current = text;
|
|
367
|
+
if (text !== viewRef.current.shown) start();
|
|
368
|
+
}, [text]);
|
|
369
|
+
|
|
370
|
+
// 偏好变化即时生效:切成「即时」就停表追平,切回别的样式就继续起节拍
|
|
117
371
|
useEffect(() => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}, [
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
372
|
+
settingsRef.current = settings;
|
|
373
|
+
if (viewRef.current.shown === targetRef.current) return;
|
|
374
|
+
if (settings.style === "instant") {
|
|
375
|
+
stopTimer();
|
|
376
|
+
commit(targetRef.current);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
start();
|
|
380
|
+
}, [settings]);
|
|
381
|
+
|
|
382
|
+
useEffect(() => () => stopTimer(), []);
|
|
383
|
+
|
|
384
|
+
const settled = !live && view.shown === text;
|
|
385
|
+
|
|
386
|
+
// 逐帧推进会让内容往下长:提交后(绘制前)跟着贴底,界面不再跳动。
|
|
387
|
+
// 贴不贴听对话区的(往上翻过、大纲正在跳转都会松开),这里不另量距离、不另设阈值;
|
|
388
|
+
// 追平那一帧和换成整段渲染那一帧同样会变高,也要贴。
|
|
389
|
+
useLayoutEffect(() => {
|
|
390
|
+
if (scroll?.shouldPin()) scroll.pin();
|
|
391
|
+
}, [view, settled, scroll]);
|
|
392
|
+
|
|
393
|
+
if (settled) return <MarkdownBlock text={text} />;
|
|
394
|
+
|
|
395
|
+
const { shown, blocks, tail, tailInFence } = view;
|
|
396
|
+
// 尾巴常驻同一个 key 的容器:块升级时尾巴原地更新而不是整棵重挂——
|
|
397
|
+
// 重挂会让渐显动画在「已经显示过的内容」上重播一遍,正是输出时闪烁几下的来源。
|
|
398
|
+
// 围栏未闭合时用与最终渲染相同的结构(md-code-block > pre > code,含复制按钮,同样不折行),
|
|
399
|
+
// 闭合升级只有语法着色的差别,没有布局跳变;开头的 ``` 行不进代码。
|
|
400
|
+
let tailNode: React.ReactNode;
|
|
401
|
+
if (tailInFence) {
|
|
402
|
+
const { before, lang, code } = splitOpenFence(tail);
|
|
403
|
+
tailNode = (
|
|
404
|
+
<>
|
|
405
|
+
{before.trim() ? <MarkdownContent text={before} /> : null}
|
|
406
|
+
<div className="md-code-block">
|
|
407
|
+
<div className="md-code-copy"><CopyButton text={code} /></div>
|
|
408
|
+
<pre><code className={lang ? `hljs language-${lang}` : undefined}>{code}</code></pre>
|
|
409
|
+
</div>
|
|
410
|
+
</>
|
|
411
|
+
);
|
|
412
|
+
} else {
|
|
413
|
+
tailNode = <MarkdownContent text={tail} />;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div
|
|
418
|
+
className={settings.caret ? "md pw-stream pw-stream-caret" : "md pw-stream"}
|
|
419
|
+
data-stream-style={settings.style}
|
|
420
|
+
data-stream-pending={shown === text ? undefined : ""}
|
|
421
|
+
>
|
|
422
|
+
{blocks.map((block, i) => <MarkdownContent key={i} text={block} />)}
|
|
423
|
+
{tail
|
|
424
|
+
? <div className="pw-stream-tail" key="tail">{tailNode}</div>
|
|
425
|
+
: null}
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
132
428
|
});
|
|
133
429
|
|
|
134
430
|
export default MarkdownBlock;
|