@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useMemo, useState } from "react";
|
|
4
4
|
import { IconCloseOutline14, IconSearchOutline16 } from "@/components/icons";
|
|
5
5
|
import { useI18n } from "@/i18n";
|
|
6
|
+
import { fmtTokens } from "@/lib/format";
|
|
6
7
|
import type { TrajEntry, TrajTokens } from "@/lib/types";
|
|
7
8
|
import styles from "./TrajectoryAnalyzer.module.css";
|
|
8
9
|
|
|
@@ -26,10 +27,7 @@ const KIND_NAME: Record<TrajEntry["kind"], string> = {
|
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
function fmtTok(value?: number): string {
|
|
29
|
-
|
|
30
|
-
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
31
|
-
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
|
32
|
-
return String(value);
|
|
30
|
+
return value == null ? "—" : fmtTokens(value);
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
function fmtTime(ts: number): string {
|
|
@@ -45,8 +43,8 @@ function durationOf(entry: TrajEntry): number | undefined {
|
|
|
45
43
|
|
|
46
44
|
function fmtDur(ms?: number): string {
|
|
47
45
|
if (ms == null) return "—";
|
|
48
|
-
if (ms >= 60_000) return `${(ms / 60_000).toFixed(
|
|
49
|
-
if (ms >= 1_000) return `${(ms / 1_000).toFixed(
|
|
46
|
+
if (ms >= 60_000) return `${(ms / 60_000).toFixed(2)}m`;
|
|
47
|
+
if (ms >= 1_000) return `${(ms / 1_000).toFixed(2)}s`;
|
|
50
48
|
return `${Math.round(ms)}ms`;
|
|
51
49
|
}
|
|
52
50
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 对话区的贴底状态,交给流式正文用。
|
|
5
|
+
*
|
|
6
|
+
* 流式正文每帧往下长一点,长完要不要跟着滚到底,只能听对话区的:它按 96px 判断贴底、
|
|
7
|
+
* 用户往上翻(或大纲 / 搜索在平滑跳转)就松开。正文自己再量一遍距离、另设阈值,
|
|
8
|
+
* 两边说法不一,就会把已经翻上去的人拽回来、把刚起步的平滑跳转打断。
|
|
9
|
+
* 不在对话区里(没有 Provider)就不贴底。
|
|
10
|
+
*/
|
|
11
|
+
export interface ChatScroll {
|
|
12
|
+
/** 现在是否贴着底部(读的是对话区的同一个 ref,不另做判断) */
|
|
13
|
+
shouldPin(): boolean;
|
|
14
|
+
/** 立刻贴到底部 */
|
|
15
|
+
pin(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ChatScrollContext = createContext<ChatScroll | null>(null);
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 输入卡里待发送的图片。
|
|
5
|
+
* - 不超过 COLLAPSE_AT 张:平铺缩略图(不裁切),右上角删除。
|
|
6
|
+
* - 更多时折叠成牌堆:正面那张在最上,后面最多露 4 层 4px 的右边缘;点「展开 N」沿一条向上拱的弧线扇开。
|
|
7
|
+
* 两种状态是同一批卡片换 transform,所以有从牌堆扇开 / 收拢的动画;扇形按容器实测宽度铺开。
|
|
8
|
+
* 层级始终是左压右(和牌堆同一个顺序,展开时不会闪一下换层),每张露出来的是右侧那条,
|
|
9
|
+
* 删除按钮就在那条的右上角——不用悬停也点得到,触屏同样能删;张数多、条带窄到按钮会挤成一排时,
|
|
10
|
+
* 只在悬停 / 聚焦的那张上显示(触屏点开预览里删)。悬停 / 聚焦的那张抬起来、整张露出。
|
|
11
|
+
* 牌堆里后面几张只露空白的卡边(不露图),看起来是一叠;扇开时图再淡入。
|
|
12
|
+
* - 点任意一张进预览:大图、← → 切换、删除当前张(删完自动看下一张);Esc / 点背景 / 关闭按钮退出,焦点回到原处。
|
|
13
|
+
* 预览挂到 body 上(祖先的 backdrop-filter 会让 fixed 只盖住输入卡),按键在捕获阶段拦下,不会再关掉页面上的其它面板。
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
16
|
+
import { createPortal } from "react-dom";
|
|
17
|
+
import { useI18n } from "@/i18n";
|
|
18
|
+
import type { ImageAttachment } from "@/lib/types";
|
|
19
|
+
|
|
20
|
+
/** 超过这个数量就折叠(引用 chip 用同一个阈值) */
|
|
21
|
+
export const COLLAPSE_AT = 4;
|
|
22
|
+
/** 牌堆 / 扇形里卡片的边长 */
|
|
23
|
+
const CARD = 96;
|
|
24
|
+
/** 牌堆里后面每层露出的右边缘、最多露几层(再多的叠在最后一层下面、不显示) */
|
|
25
|
+
const DECK_PEEK = 4;
|
|
26
|
+
const DECK_LAYERS = 4;
|
|
27
|
+
/** 扇形所在圆弧的半径(越大弧越平)、相邻两张中心的最大间距(再大就散成一排,不像一手牌了) */
|
|
28
|
+
const FAN_RADIUS = 520;
|
|
29
|
+
const FAN_GAP_MAX = 76;
|
|
30
|
+
/** 悬停 / 聚焦时那张往上抬多少;左右给旋转后探出去的角留的余量 */
|
|
31
|
+
const FAN_LIFT = 12;
|
|
32
|
+
const FAN_SIDE = 8;
|
|
33
|
+
/** 量不到宽度时(首帧、测试环境)按这个宽度排扇形 */
|
|
34
|
+
const FALLBACK_WIDTH = 360;
|
|
35
|
+
|
|
36
|
+
export interface CardPose {
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
angle: number;
|
|
40
|
+
/** 牌堆里压在最后一层下面、看不见的 */
|
|
41
|
+
hidden?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Layout {
|
|
45
|
+
cards: CardPose[];
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
/** 相邻两张露出来的宽度;太窄时删除按钮会挤成一排,只在悬停 / 聚焦那张上显示 */
|
|
49
|
+
gap: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 露出的条带至少这么宽,每张的删除按钮才常驻(按钮 22px + 一点间隔) */
|
|
53
|
+
const ROOMY_GAP = 30;
|
|
54
|
+
|
|
55
|
+
const round = (value: number) => Math.round(value * 10) / 10;
|
|
56
|
+
|
|
57
|
+
/** 牌堆:只做横向错位,最多露 DECK_LAYERS 层 */
|
|
58
|
+
export function deckLayout(count: number): Layout {
|
|
59
|
+
const cards = Array.from({ length: count }, (_, i) => ({ x: Math.min(i, DECK_LAYERS) * DECK_PEEK, y: 0, angle: 0, hidden: i > DECK_LAYERS }));
|
|
60
|
+
return { cards, width: CARD + Math.min(Math.max(count - 1, 0), DECK_LAYERS) * DECK_PEEK, height: CARD, gap: DECK_PEEK };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 扇形:卡片中心落在一段圆弧上(中间最高、两侧顺着弧下垂),每张转到切线方向;
|
|
65
|
+
* 间距按可用宽度均分、不超过 FAN_GAP_MAX,从左边开始铺(离牌堆原来的位置最近)。
|
|
66
|
+
*/
|
|
67
|
+
export function fanLayout(count: number, width: number): Layout {
|
|
68
|
+
const gap = count > 1 ? Math.min(FAN_GAP_MAX, Math.max(8, (width - CARD - FAN_SIDE * 2) / (count - 1))) : 0;
|
|
69
|
+
const step = gap / FAN_RADIUS;
|
|
70
|
+
const mid = (count - 1) / 2;
|
|
71
|
+
const edge = -mid * step;
|
|
72
|
+
// 正方形转过 θ 后比原来高出(也宽出)的那一截,最外侧两张最大
|
|
73
|
+
const corner = (CARD / 2) * (Math.abs(Math.sin(edge)) + Math.cos(edge) - 1);
|
|
74
|
+
const top = FAN_LIFT + corner;
|
|
75
|
+
const cards = Array.from({ length: count }, (_, i) => {
|
|
76
|
+
const theta = (i - mid) * step;
|
|
77
|
+
return {
|
|
78
|
+
x: round(FAN_SIDE + FAN_RADIUS * (Math.sin(theta) - Math.sin(edge))),
|
|
79
|
+
y: round(top + FAN_RADIUS * (1 - Math.cos(theta))),
|
|
80
|
+
angle: round((theta * 180) / Math.PI),
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
const span = cards.length ? cards[cards.length - 1].x + CARD + FAN_SIDE : 0;
|
|
84
|
+
return { cards, width: Math.ceil(span), height: Math.ceil(top + FAN_RADIUS * (1 - Math.cos(edge)) + CARD + corner), gap: round(gap) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 每张图一个稳定的 key:前几位 base64 对同格式的图几乎都一样(PNG 都是 iVBORw0KGgo…),不能拿来当 key */
|
|
88
|
+
const imageKeys = new WeakMap<ImageAttachment, string>();
|
|
89
|
+
let nextImageKey = 0;
|
|
90
|
+
function keyOf(image: ImageAttachment): string {
|
|
91
|
+
let key = imageKeys.get(image);
|
|
92
|
+
if (!key) {
|
|
93
|
+
nextImageKey += 1;
|
|
94
|
+
key = `image-${nextImageKey}`;
|
|
95
|
+
imageKeys.set(image, key);
|
|
96
|
+
}
|
|
97
|
+
return key;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const srcOf = (image: ImageAttachment) => `data:${image.mimeType};base64,${image.data}`;
|
|
101
|
+
|
|
102
|
+
export function ComposerImages({ images, disabled, onRemove }: { images: ImageAttachment[]; disabled?: boolean; onRemove: (index: number) => void }) {
|
|
103
|
+
const { t } = useI18n();
|
|
104
|
+
const collapsible = images.length > COLLAPSE_AT;
|
|
105
|
+
const [expanded, setExpanded] = useState(false);
|
|
106
|
+
const fanned = collapsible && expanded;
|
|
107
|
+
/** 扇形里悬停 / 聚焦的那张:抬起来、放到最上层 */
|
|
108
|
+
const [lifted, setLifted] = useState<number | null>(null);
|
|
109
|
+
const [preview, setPreview] = useState<number | null>(null);
|
|
110
|
+
const railRef = useRef<HTMLDivElement>(null);
|
|
111
|
+
const [railWidth, setRailWidth] = useState(0);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const rail = railRef.current;
|
|
115
|
+
if (!rail || !collapsible) return;
|
|
116
|
+
// 量的是整条附件轨的内容宽度(去掉左右 4px 内边距):扇形展开后占满这一行
|
|
117
|
+
const measure = () => setRailWidth(Math.max(0, rail.clientWidth - 8));
|
|
118
|
+
measure();
|
|
119
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
120
|
+
const observer = new ResizeObserver(measure);
|
|
121
|
+
observer.observe(rail);
|
|
122
|
+
return () => observer.disconnect();
|
|
123
|
+
}, [collapsible]);
|
|
124
|
+
|
|
125
|
+
const remove = (index: number) => {
|
|
126
|
+
onRemove(index);
|
|
127
|
+
setLifted(null);
|
|
128
|
+
// 预览里删的:还有图就接着看下一张(删的是最后一张就看前一张)
|
|
129
|
+
setPreview((current) => (current === null ? null : images.length <= 1 ? null : Math.min(current, images.length - 2)));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const deleteButton = (index: number) => (
|
|
133
|
+
<button type="button" className="pw-image-delete" disabled={disabled} aria-label={`${t.delete} ${index + 1}`} onClick={() => remove(index)}>
|
|
134
|
+
✕
|
|
135
|
+
</button>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const layout = fanned ? fanLayout(images.length, railWidth || FALLBACK_WIDTH) : deckLayout(images.length);
|
|
139
|
+
const shown = preview !== null && images[preview] ? preview : null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<>
|
|
143
|
+
<div ref={railRef} className="mb-2 flex flex-wrap items-center gap-2 px-1" data-testid="composer-images">
|
|
144
|
+
{collapsible ? (
|
|
145
|
+
<div
|
|
146
|
+
className="pw-image-fan"
|
|
147
|
+
data-testid="image-fan"
|
|
148
|
+
data-expanded={fanned}
|
|
149
|
+
data-crowded={fanned && layout.gap < ROOMY_GAP ? true : undefined}
|
|
150
|
+
style={{ width: fanned ? "100%" : layout.width, height: layout.height }}
|
|
151
|
+
>
|
|
152
|
+
{images.map((image, i) => {
|
|
153
|
+
const pose = layout.cards[i];
|
|
154
|
+
const up = fanned && lifted === i;
|
|
155
|
+
// 牌堆里只有正面那张能点、能删;后面那几张只是露个边,不进 Tab 顺序
|
|
156
|
+
const reachable = fanned || i === 0;
|
|
157
|
+
// 展开从左往右依次扇出,收起反过来;错开的总时长封顶,张数多也不拖沓
|
|
158
|
+
const delay = Math.min(fanned ? i * 14 : (images.length - 1 - i) * 10, 140);
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
key={keyOf(image)}
|
|
162
|
+
className="pw-image-card"
|
|
163
|
+
data-hidden={pose.hidden || undefined}
|
|
164
|
+
data-lifted={up || undefined}
|
|
165
|
+
style={{
|
|
166
|
+
zIndex: up ? images.length + 1 : images.length - i,
|
|
167
|
+
transform: `translate(${pose.x}px, ${pose.y - (up ? FAN_LIFT : 0)}px) rotate(${pose.angle}deg)`,
|
|
168
|
+
transitionDelay: up || lifted !== null ? "0ms" : `${delay}ms`,
|
|
169
|
+
}}
|
|
170
|
+
onMouseEnter={() => fanned && setLifted(i)}
|
|
171
|
+
onMouseLeave={() => setLifted((current) => (current === i ? null : current))}
|
|
172
|
+
onFocus={() => fanned && setLifted(i)}
|
|
173
|
+
onBlur={(event) => {
|
|
174
|
+
// 焦点从图片挪到同一张的删除按钮不算离开
|
|
175
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setLifted((current) => (current === i ? null : current));
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
className="pw-image-face"
|
|
181
|
+
tabIndex={reachable ? undefined : -1}
|
|
182
|
+
aria-hidden={reachable ? undefined : true}
|
|
183
|
+
title={t.imagePreview}
|
|
184
|
+
aria-label={t.imagePreview}
|
|
185
|
+
onClick={() => setPreview(i)}
|
|
186
|
+
>
|
|
187
|
+
<img src={srcOf(image)} alt="" className="h-full w-full object-contain" draggable={false} />
|
|
188
|
+
</button>
|
|
189
|
+
{reachable ? deleteButton(i) : null}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</div>
|
|
194
|
+
) : (
|
|
195
|
+
images.map((image, i) => (
|
|
196
|
+
<div key={keyOf(image)} className="pw-image-thumb">
|
|
197
|
+
<button type="button" className="block" title={t.imagePreview} aria-label={t.imagePreview} onClick={() => setPreview(i)}>
|
|
198
|
+
<img src={srcOf(image)} alt="" className="max-h-24 max-w-32 rounded-[10px] object-contain" draggable={false} />
|
|
199
|
+
</button>
|
|
200
|
+
{deleteButton(i)}
|
|
201
|
+
</div>
|
|
202
|
+
))
|
|
203
|
+
)}
|
|
204
|
+
{collapsible ? (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
className="pw-chip"
|
|
208
|
+
data-testid="images-toggle"
|
|
209
|
+
aria-expanded={fanned}
|
|
210
|
+
onClick={() => {
|
|
211
|
+
setExpanded((open) => !open);
|
|
212
|
+
setLifted(null);
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
{fanned ? t.chipCollapse : t.chipExpandMore.replace("{n}", String(images.length - 1))}
|
|
216
|
+
</button>
|
|
217
|
+
) : null}
|
|
218
|
+
</div>
|
|
219
|
+
{shown !== null
|
|
220
|
+
? createPortal(
|
|
221
|
+
<ImageViewer images={images} index={shown} disabled={disabled} onIndex={setPreview} onRemove={remove} onClose={() => setPreview(null)} />,
|
|
222
|
+
document.body,
|
|
223
|
+
)
|
|
224
|
+
: null}
|
|
225
|
+
</>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 放大预览:手机上没法用本机程序打开图片,应用内看最实在 */
|
|
230
|
+
function ImageViewer({
|
|
231
|
+
images,
|
|
232
|
+
index,
|
|
233
|
+
disabled,
|
|
234
|
+
onIndex,
|
|
235
|
+
onRemove,
|
|
236
|
+
onClose,
|
|
237
|
+
}: {
|
|
238
|
+
images: ImageAttachment[];
|
|
239
|
+
index: number;
|
|
240
|
+
disabled?: boolean;
|
|
241
|
+
onIndex: (index: number) => void;
|
|
242
|
+
onRemove: (index: number) => void;
|
|
243
|
+
onClose: () => void;
|
|
244
|
+
}) {
|
|
245
|
+
const { t } = useI18n();
|
|
246
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
247
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
248
|
+
const count = images.length;
|
|
249
|
+
const go = (delta: number) => onIndex((index + delta + count) % count);
|
|
250
|
+
|
|
251
|
+
// 打开时把焦点移进来,关掉时还给原来那个元素(还在页面上的话)
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
254
|
+
closeRef.current?.focus();
|
|
255
|
+
return () => {
|
|
256
|
+
if (opener?.isConnected) opener.focus();
|
|
257
|
+
};
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
// 捕获阶段处理:Esc / 方向键不再传给页面上其它监听 document 的面板;Tab 只在预览里转
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
const onKey = (event: KeyboardEvent) => {
|
|
263
|
+
if (event.key === "Escape") onClose();
|
|
264
|
+
else if (event.key === "ArrowLeft" && count > 1) go(-1);
|
|
265
|
+
else if (event.key === "ArrowRight" && count > 1) go(1);
|
|
266
|
+
else if (event.key === "Tab") {
|
|
267
|
+
const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled)") ?? [])];
|
|
268
|
+
if (!focusable.length) return;
|
|
269
|
+
const at = focusable.indexOf(document.activeElement as HTMLElement);
|
|
270
|
+
const next = event.shiftKey ? (at <= 0 ? focusable.length - 1 : at - 1) : at === focusable.length - 1 ? 0 : at + 1;
|
|
271
|
+
focusable[next].focus();
|
|
272
|
+
} else return;
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
event.stopPropagation();
|
|
275
|
+
};
|
|
276
|
+
document.addEventListener("keydown", onKey, true);
|
|
277
|
+
return () => document.removeEventListener("keydown", onKey, true);
|
|
278
|
+
// onClose / onIndex 来自父组件的 setState 包装,语义不变;只随张数和当前张重绑
|
|
279
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
280
|
+
}, [count, index]);
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div ref={dialogRef} className="pw-viewer" role="dialog" aria-modal="true" aria-label={t.imageViewer} onClick={onClose}>
|
|
284
|
+
<div className="pw-viewer-bar" onClick={(event) => event.stopPropagation()}>
|
|
285
|
+
<span className="pw-viewer-count">{count > 1 ? `${index + 1} / ${count}` : ""}</span>
|
|
286
|
+
<button type="button" className="pw-viewer-btn" disabled={disabled} onClick={() => onRemove(index)}>
|
|
287
|
+
{t.delete}
|
|
288
|
+
</button>
|
|
289
|
+
<button ref={closeRef} type="button" className="pw-viewer-btn" aria-label={t.close} onClick={onClose}>
|
|
290
|
+
✕
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
<img src={srcOf(images[index])} alt="" className="pw-viewer-image" onClick={(event) => event.stopPropagation()} />
|
|
294
|
+
{count > 1 ? (
|
|
295
|
+
<>
|
|
296
|
+
<button type="button" className="pw-viewer-nav" data-side="prev" aria-label={t.imagePrev} onClick={(event) => { event.stopPropagation(); go(-1); }}>
|
|
297
|
+
‹
|
|
298
|
+
</button>
|
|
299
|
+
<button type="button" className="pw-viewer-nav" data-side="next" aria-label={t.imageNext} onClick={(event) => { event.stopPropagation(); go(1); }}>
|
|
300
|
+
›
|
|
301
|
+
</button>
|
|
302
|
+
</>
|
|
303
|
+
) : null}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useI18n } from "@/i18n";
|
|
5
|
+
import { copyText } from "@/lib/clipboard";
|
|
6
|
+
|
|
7
|
+
/** 要用户自己在终端执行的命令:等宽显示 + 复制按钮(设置行和右下角提示条共用) */
|
|
8
|
+
export function CopyCommand({ command }: { command: string }) {
|
|
9
|
+
const { t } = useI18n();
|
|
10
|
+
const [copied, setCopied] = useState<"" | "ok" | "fail">("");
|
|
11
|
+
return (
|
|
12
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
13
|
+
<code
|
|
14
|
+
className="min-w-0 flex-1"
|
|
15
|
+
style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--dsw-label-primary)", whiteSpace: "nowrap", overflowX: "auto" }}
|
|
16
|
+
>
|
|
17
|
+
{command}
|
|
18
|
+
</code>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
className="btn-outline flex-none"
|
|
22
|
+
style={{ height: 22, padding: "0 8px", fontSize: 11, color: copied === "fail" ? "var(--dsw-danger)" : undefined }}
|
|
23
|
+
onClick={() => {
|
|
24
|
+
void copyText(command).then((ok) => {
|
|
25
|
+
setCopied(ok ? "ok" : "fail");
|
|
26
|
+
setTimeout(() => setCopied(""), 1500);
|
|
27
|
+
});
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{copied === "ok" ? t.updateCopied : copied === "fail" ? t.copyFailed : t.copy}
|
|
31
|
+
</button>
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 右下角的更新提示条:每次服务启动提醒一次(按启动标识记在 localStorage,刷新、多开标签页不重复)。
|
|
5
|
+
*
|
|
6
|
+
* 只列启动检查时就有新版本的那几项:
|
|
7
|
+
* - 能直接更新的点「更新」,按 PiWeb → Pi 的顺序逐个装(同一时间只能有一个更新在跑);
|
|
8
|
+
* - Windows 上的 npm 安装 PiWeb 不能替换运行中的自己,给命令和复制按钮;
|
|
9
|
+
* - 装好后提示手动重启,进程不自己重启。
|
|
10
|
+
* 状态和设置里的版本行共用(update-store),在哪边点都一样。
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
import { CopyCommand } from "@/components/update/CopyCommand";
|
|
14
|
+
import { loadUpdates, runUpdate, UPDATE_TARGETS, useUpdates, type TargetUpdate, type UpdateTarget } from "@/components/update/update-store";
|
|
15
|
+
import { useI18n } from "@/i18n";
|
|
16
|
+
|
|
17
|
+
/** 已经关掉过的那次启动的标识 */
|
|
18
|
+
export const UPDATE_NOTICE_KEY = "piweb.updateNotice";
|
|
19
|
+
|
|
20
|
+
const LABEL: Record<UpdateTarget, string> = { piweb: "PiWeb", pi: "Pi" };
|
|
21
|
+
|
|
22
|
+
export function UpdateNotice() {
|
|
23
|
+
const { t } = useI18n();
|
|
24
|
+
const updates = useUpdates();
|
|
25
|
+
/** 读过 localStorage 之后才知道这次启动是否已经关掉过(服务端渲染没有 localStorage) */
|
|
26
|
+
const [dismissed, setDismissed] = useState<string | null | undefined>(undefined);
|
|
27
|
+
/** 启动检查时就有新版本的项:提示条只管这些,之后它们变成更新中 / 已更新 / 失败也留在条里 */
|
|
28
|
+
const [offered, setOffered] = useState<UpdateTarget[] | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
try {
|
|
32
|
+
setDismissed(localStorage.getItem(UPDATE_NOTICE_KEY));
|
|
33
|
+
} catch {
|
|
34
|
+
setDismissed(null);
|
|
35
|
+
}
|
|
36
|
+
void loadUpdates();
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (offered === null && updates.loaded) setOffered(UPDATE_TARGETS.filter((target) => updates.targets[target].phase === "available"));
|
|
41
|
+
}, [offered, updates]);
|
|
42
|
+
|
|
43
|
+
if (dismissed === undefined || !offered?.length || !updates.startId || dismissed === updates.startId) return null;
|
|
44
|
+
const startId = updates.startId;
|
|
45
|
+
const items = offered.map((target) => ({ target, ...updates.targets[target] }));
|
|
46
|
+
const updatable = items.filter((item) => !item.command && (item.phase === "available" || item.phase === "error"));
|
|
47
|
+
const updating = items.some((item) => item.phase === "updating");
|
|
48
|
+
const anyUpdated = items.some((item) => item.phase === "updated");
|
|
49
|
+
const dismiss = () => {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(UPDATE_NOTICE_KEY, startId);
|
|
52
|
+
} catch {
|
|
53
|
+
/* 存不下就只关这一页 */
|
|
54
|
+
}
|
|
55
|
+
setDismissed(startId);
|
|
56
|
+
};
|
|
57
|
+
const updateAll = async () => {
|
|
58
|
+
// 一个一个来:服务端同一时间只允许一个更新;前一个失败(比如有会话在跑)就停下
|
|
59
|
+
for (const item of updatable) {
|
|
60
|
+
if (!(await runUpdate(item.target))) break;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const title = updating ? t.updateNoticeUpdatingTitle : anyUpdated && !updatable.length ? t.updateNoticeDoneTitle : t.updateNoticeTitle;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
role="status"
|
|
68
|
+
className="pointer-events-auto flex flex-col gap-2 rounded-xl px-3 py-2.5"
|
|
69
|
+
style={{ fontSize: 12.5, background: "var(--dsw-glass-popover)", border: "0.5px solid var(--dsw-border-l2)", boxShadow: "var(--dsw-elevation-soft)", borderLeft: "3px solid var(--dsw-accent)" }}
|
|
70
|
+
data-testid="update-notice"
|
|
71
|
+
>
|
|
72
|
+
<span style={{ fontWeight: 600, color: "var(--dsw-label-primary)" }}>{title}</span>
|
|
73
|
+
{items.map((item) => (
|
|
74
|
+
<div key={item.target} className="flex flex-col gap-1">
|
|
75
|
+
<span className="flex items-center gap-1.5" style={{ color: "var(--dsw-label-secondary)" }}>
|
|
76
|
+
<span style={{ color: "var(--dsw-label-primary)" }}>{LABEL[item.target]}</span>
|
|
77
|
+
<ItemStatus item={item} />
|
|
78
|
+
</span>
|
|
79
|
+
{item.command && item.phase === "available" ? (
|
|
80
|
+
<span className="flex flex-col gap-1">
|
|
81
|
+
<span style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>{t.updateRunCommandHint}</span>
|
|
82
|
+
<CopyCommand command={item.command} />
|
|
83
|
+
</span>
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
{anyUpdated && !updating ? <span style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>{t.updateRestartHint}</span> : null}
|
|
88
|
+
{!updating ? (
|
|
89
|
+
<div className="flex justify-end gap-2">
|
|
90
|
+
<button type="button" className="btn-outline" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={dismiss}>
|
|
91
|
+
{anyUpdated || !updatable.length ? t.updateNoticeOk : t.updateNoticeLater}
|
|
92
|
+
</button>
|
|
93
|
+
{updatable.length ? (
|
|
94
|
+
<button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void updateAll()}>
|
|
95
|
+
{t.updateNoticeUpdate}
|
|
96
|
+
</button>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ItemStatus({ item }: { item: TargetUpdate }) {
|
|
105
|
+
const { t } = useI18n();
|
|
106
|
+
if (item.phase === "updating") {
|
|
107
|
+
return (
|
|
108
|
+
<span className="flex items-center gap-1.5" style={{ color: "var(--dsw-label-tertiary)" }}>
|
|
109
|
+
<span className="h-3 w-3 flex-none animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
110
|
+
{t.updatingNow}
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (item.phase === "updated") return <span style={{ color: "var(--dsw-success)" }}>{t.updateNoticeUpdated.replace("{v}", item.latest)}</span>;
|
|
115
|
+
if (item.phase === "error") {
|
|
116
|
+
// npm / 构建的报错可能很长:只露三行,完整内容放在悬停提示里
|
|
117
|
+
return (
|
|
118
|
+
<span className="line-clamp-3 min-w-0 break-all" style={{ color: "var(--dsw-danger)" }} title={item.error}>
|
|
119
|
+
{item.busy ? t.updateBusy : `${t.updateFailed}: ${item.error}`}
|
|
120
|
+
</span>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return <span>{versionChange(item, t.updateCommits)}</span>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 「0.3.18 → 0.3.20」;Git 检出版本号没变、只有新提交时写提交数 */
|
|
127
|
+
export function versionChange(item: Pick<TargetUpdate, "current" | "latest" | "behind">, commitsText: string): string {
|
|
128
|
+
if (item.latest === item.current && item.behind > 0) return commitsText.replace("{n}", String(item.behind));
|
|
129
|
+
return `${item.current} → ${item.latest}`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 更新状态的前端单例:设置里的两行「PiWeb 版本 / Pi 版本」和右下角提示条共用一份,
|
|
5
|
+
* 在一处点了更新,另一处同步显示进度和结果。
|
|
6
|
+
*
|
|
7
|
+
* 页面打开时取一次启动检查的结果(GET /api/update,服务启动时已经在查),
|
|
8
|
+
* 不用先点「检查更新」就知道有没有新版本。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type UpdateTarget = "piweb" | "pi";
|
|
12
|
+
export type UpdatePhase = "idle" | "checking" | "available" | "updating" | "latest" | "updated" | "error";
|
|
13
|
+
|
|
14
|
+
export interface TargetUpdate {
|
|
15
|
+
phase: UpdatePhase;
|
|
16
|
+
current: string;
|
|
17
|
+
latest: string;
|
|
18
|
+
/** Git 检出:main 上落后的提交数(版本号没变时用它说明「有新提交」) */
|
|
19
|
+
behind: number;
|
|
20
|
+
/** 要用户自己执行的命令(Windows 上的 npm 安装 PiWeb 不能替换运行中的自己);空串表示能直接更新 */
|
|
21
|
+
command: string;
|
|
22
|
+
error: string;
|
|
23
|
+
/** 失败是因为有会话正在运行(409) */
|
|
24
|
+
busy: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UpdateState {
|
|
28
|
+
/** 启动检查的结果取回来了(成功或失败都算) */
|
|
29
|
+
loaded: boolean;
|
|
30
|
+
/** 本次服务启动的标识,提示条按它做到「每次启动提醒一次」;关掉启动检查时为 null */
|
|
31
|
+
startId: string | null;
|
|
32
|
+
targets: Record<UpdateTarget, TargetUpdate>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const UPDATE_TARGETS: readonly UpdateTarget[] = ["piweb", "pi"];
|
|
36
|
+
|
|
37
|
+
interface CheckData {
|
|
38
|
+
current: string;
|
|
39
|
+
latest: string;
|
|
40
|
+
canUpdate: boolean;
|
|
41
|
+
behind?: number;
|
|
42
|
+
command?: string;
|
|
43
|
+
updatedTo?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const blank = (): TargetUpdate => ({ phase: "idle", current: "", latest: "", behind: 0, command: "", error: "", busy: false });
|
|
47
|
+
const initial = (): UpdateState => ({ loaded: false, startId: null, targets: { piweb: blank(), pi: blank() } });
|
|
48
|
+
|
|
49
|
+
let state: UpdateState = initial();
|
|
50
|
+
let loading: Promise<void> | null = null;
|
|
51
|
+
const listeners = new Set<() => void>();
|
|
52
|
+
|
|
53
|
+
function emit(): void {
|
|
54
|
+
for (const listener of listeners) listener();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function patch(target: UpdateTarget, next: Partial<TargetUpdate>): void {
|
|
58
|
+
state = { ...state, targets: { ...state.targets, [target]: { ...state.targets[target], ...next } } };
|
|
59
|
+
emit();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fromCheck(check: CheckData): Partial<TargetUpdate> {
|
|
63
|
+
const base = { current: check.current, behind: check.behind ?? 0, error: "", busy: false };
|
|
64
|
+
if (check.updatedTo) return { ...base, phase: "updated", latest: check.updatedTo, command: "" };
|
|
65
|
+
if (check.canUpdate) return { ...base, phase: "available", latest: check.latest, command: check.command ?? "" };
|
|
66
|
+
return { ...base, phase: "latest", latest: check.latest, command: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class UpdateRequestError extends Error {
|
|
70
|
+
busy: boolean;
|
|
71
|
+
constructor(message: string, busy: boolean) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.busy = busy;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function post(target: UpdateTarget, action: "check" | "update") {
|
|
78
|
+
const response = await fetch("/api/update", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify({ target, action }),
|
|
82
|
+
});
|
|
83
|
+
const json = await response.json();
|
|
84
|
+
if (!json.success) throw new UpdateRequestError(String(json.error ?? response.status), response.status === 409);
|
|
85
|
+
return json.data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function failure(error: unknown): Partial<TargetUpdate> {
|
|
89
|
+
return { phase: "error", error: error instanceof Error ? error.message : String(error), busy: error instanceof UpdateRequestError && error.busy };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 取启动检查的结果;每个页面只取一次(设置面板反复开关不重复请求) */
|
|
93
|
+
export function loadUpdates(): Promise<void> {
|
|
94
|
+
loading ??= (async () => {
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch("/api/update", { cache: "no-store" });
|
|
97
|
+
const json = await response.json();
|
|
98
|
+
if (!json.success) throw new Error(String(json.error));
|
|
99
|
+
const targets = { ...state.targets };
|
|
100
|
+
for (const target of UPDATE_TARGETS) {
|
|
101
|
+
const result = json.data?.results?.[target];
|
|
102
|
+
// 已经手动查过 / 正在更新的不覆盖;启动检查失败的项留在 idle,照旧手动检查
|
|
103
|
+
if (targets[target].phase !== "idle" || !result || "error" in result) continue;
|
|
104
|
+
targets[target] = { ...targets[target], ...fromCheck(result) };
|
|
105
|
+
}
|
|
106
|
+
state = { loaded: true, startId: json.data?.startId ?? null, targets };
|
|
107
|
+
} catch {
|
|
108
|
+
state = { ...state, loaded: true };
|
|
109
|
+
}
|
|
110
|
+
emit();
|
|
111
|
+
})();
|
|
112
|
+
return loading;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function checkUpdate(target: UpdateTarget): Promise<void> {
|
|
116
|
+
patch(target, { phase: "checking", error: "", busy: false });
|
|
117
|
+
try {
|
|
118
|
+
patch(target, fromCheck(await post(target, "check")));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
patch(target, failure(error));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 更新一项;成功返回 true。装好后要用户手动重启,进程不自己重启 */
|
|
125
|
+
export async function runUpdate(target: UpdateTarget): Promise<boolean> {
|
|
126
|
+
patch(target, { phase: "updating", error: "", busy: false });
|
|
127
|
+
try {
|
|
128
|
+
const data = await post(target, "update");
|
|
129
|
+
patch(target, { phase: "updated", latest: data?.version || state.targets[target].latest, command: "" });
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
patch(target, failure(error));
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function subscribe(listener: () => void): () => void {
|
|
138
|
+
listeners.add(listener);
|
|
139
|
+
return () => {
|
|
140
|
+
listeners.delete(listener);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const getState = () => state;
|
|
145
|
+
|
|
146
|
+
export function useUpdates(): UpdateState {
|
|
147
|
+
return useSyncExternalStore(subscribe, getState, getState);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 仅供测试:回到页面刚打开时的状态 */
|
|
151
|
+
export function resetUpdates(): void {
|
|
152
|
+
state = initial();
|
|
153
|
+
loading = null;
|
|
154
|
+
emit();
|
|
155
|
+
}
|