@rexvane/piweb 0.3.21 → 0.3.23
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 +9 -6
- 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 +18 -11
- package/.next/required-server-files.js +16 -1
- package/.next/required-server-files.json +16 -1
- package/.next/routes-manifest.json +20 -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 +2 -2
- 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]/side-chat/route.js +5 -0
- package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +1 -0
- package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +1 -0
- 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 +4 -4
- 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 +2 -2
- 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 +20 -13
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +26 -19
- package/.next/server/app/index.segments/_full.segment.rsc +20 -13
- package/.next/server/app/index.segments/_tree.segment.rsc +4 -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 +9 -6
- package/.next/server/chunks/1813.js +23 -0
- package/.next/server/chunks/2430.js +377 -0
- package/.next/server/chunks/2970.js +305 -0
- package/.next/server/chunks/3421.js +1 -0
- package/.next/server/chunks/3924.js +6 -0
- package/.next/server/chunks/4807.js +1 -0
- package/.next/server/chunks/538.js +1 -1
- package/.next/server/chunks/5393.js +1 -0
- package/.next/server/chunks/5802.js +2 -0
- package/.next/server/chunks/6229.js +1 -0
- package/.next/server/chunks/7496.js +1 -0
- package/.next/server/chunks/7662.js +2 -0
- package/.next/server/chunks/8686.js +1 -0
- package/.next/server/chunks/{593.js → 974.js} +1 -1
- package/.next/server/chunks/{920.js → 9920.js} +2 -2
- package/.next/server/functions-config-manifest.json +2 -1
- 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/server/webpack-runtime.js +1 -1
- package/.next/static/chunks/2716.44fdf1b21454b961.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.e261707dce391be7.js +1 -0
- package/.next/static/chunks/4686-0cd87513f9ea3538.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.5f79671208209b13.js +1 -0
- package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/page-dc3cb41d91c7432e.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-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +1 -0
- package/.next/static/css/9fc559c2c4ad1d0f.css +1 -0
- package/.next/static/css/c6e2227c36ec2fd6.css +1 -0
- package/.next/static/css/{2959bd6b626b7ffe.css → f15a853af8b5127f.css} +1 -1
- package/.next/static/hYPL1-dmhKFjLS3f5yoqN/_buildManifest.js +1 -0
- 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]/side-chat/route.ts +355 -0
- 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 +4 -1
- package/.next/types/validator.ts +86 -2
- package/next.config.ts +2 -0
- package/package.json +8 -2
- package/scripts/release.mjs +3 -7
- package/scripts/runtime/check-installed.mjs +66 -0
- package/scripts/runtime/pi-packages.mjs +51 -0
- package/scripts/verify-package.mjs +29 -7
- package/src/app/api/agent/[id]/side-chat/route.ts +77 -0
- 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/globals.css +1748 -0
- package/src/components/AppShell.tsx +145 -43
- package/src/components/ChatInput.tsx +264 -79
- package/src/components/ChatWindow.tsx +13 -23
- package/src/components/ContextMeter.tsx +213 -144
- package/src/components/DrawerPanel.tsx +1 -1
- package/src/components/FileViewer.tsx +89 -8
- package/src/components/GitPanel.tsx +110 -40
- package/src/components/GrowthTree.tsx +30 -5
- package/src/components/ModelSelector.tsx +21 -5
- 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 +252 -27
- package/src/components/TrajectoryView.tsx +4 -6
- package/src/components/chat/reading-anchor.ts +81 -0
- package/src/components/composer/ComposerImages.tsx +326 -0
- package/src/components/side-chat/SideChatPanel.module.css +131 -0
- package/src/components/side-chat/SideChatPanel.tsx +189 -0
- package/src/components/side-chat/icons.tsx +8 -0
- package/src/components/side-chat/request-history.ts +29 -0
- package/src/components/side-chat/store.ts +248 -0
- package/src/hooks/useFileViewer.ts +6 -4
- package/src/hooks/useGrowth.ts +3 -0
- package/src/hooks/usePiWeb.ts +12 -2
- package/src/hooks/useWorkspaceFiles.ts +93 -0
- package/src/i18n.tsx +150 -2
- package/src/lib/agent-manager.ts +135 -104
- package/src/lib/files-service.ts +166 -3
- 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/path-security.ts +24 -32
- 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/session-reader.ts +1 -1
- package/src/lib/side-chat/context.ts +123 -0
- package/src/lib/side-chat/service.ts +191 -0
- package/src/lib/side-chat/types.ts +61 -0
- package/src/lib/thinking-level.ts +39 -0
- package/src/lib/trajectory.ts +17 -3
- package/src/lib/types.ts +12 -1
- package/src/lib/update-service.ts +4 -6
- package/src/lib/workspace-index.ts +270 -0
- package/.next/server/chunks/229.js +0 -1
- package/.next/server/chunks/393.js +0 -1
- package/.next/server/chunks/421.js +0 -1
- package/.next/server/chunks/430.js +0 -193
- package/.next/server/chunks/496.js +0 -1
- package/.next/server/chunks/662.js +0 -2
- package/.next/server/chunks/686.js +0 -1
- package/.next/server/chunks/741.js +0 -165
- package/.next/server/chunks/802.js +0 -2
- package/.next/server/chunks/807.js +0 -1
- package/.next/server/chunks/813.js +0 -1
- package/.next/server/chunks/924.js +0 -6
- package/.next/static/chunks/2716.7ad073038e94a802.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-882531aef1d2e250.js +0 -1
- package/.next/static/chunks/4778.305dc6abb9889627.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.bfd2db36483faa3e.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-c04af72e549695bd.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-9b4bb574e5817654.js +0 -1
- package/.next/static/css/ea27b9e30f76303f.css +0 -1
- package/.next/static/sNX5GQkOWuK5YEID1hT-C/_buildManifest.js +0 -1
- /package/.next/static/{sNX5GQkOWuK5YEID1hT-C → hYPL1-dmhKFjLS3f5yoqN}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,326 @@
|
|
|
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, onClear }: { images: ImageAttachment[]; disabled?: boolean; onRemove: (index: number) => void; onClear?: () => 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="pointer-events-auto 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
|
+
// 容器(composer-attachments)已经浮在输入卡上方、不占布局高度,这里不用再抬
|
|
151
|
+
style={{
|
|
152
|
+
position: "relative",
|
|
153
|
+
width: fanned ? "100%" : layout.width,
|
|
154
|
+
height: layout.height,
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{images.map((image, i) => {
|
|
158
|
+
const pose = layout.cards[i];
|
|
159
|
+
const up = fanned && lifted === i;
|
|
160
|
+
// 牌堆里只有正面那张能点、能删;后面那几张只是露个边,不进 Tab 顺序
|
|
161
|
+
const reachable = fanned || i === 0;
|
|
162
|
+
// 展开从左往右依次扇出,收起反过来;错开的总时长封顶,张数多也不拖沓
|
|
163
|
+
const delay = Math.min(fanned ? i * 14 : (images.length - 1 - i) * 10, 140);
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
key={keyOf(image)}
|
|
167
|
+
className="pw-image-card"
|
|
168
|
+
data-hidden={pose.hidden || undefined}
|
|
169
|
+
data-lifted={up || undefined}
|
|
170
|
+
style={{
|
|
171
|
+
zIndex: up ? images.length + 1 : images.length - i,
|
|
172
|
+
transform: `translate(${pose.x}px, ${pose.y - (up ? FAN_LIFT : 0)}px) rotate(${pose.angle}deg)`,
|
|
173
|
+
transitionDelay: up || lifted !== null ? "0ms" : `${delay}ms`,
|
|
174
|
+
}}
|
|
175
|
+
onMouseEnter={() => fanned && setLifted(i)}
|
|
176
|
+
onMouseLeave={() => setLifted((current) => (current === i ? null : current))}
|
|
177
|
+
onFocus={() => fanned && setLifted(i)}
|
|
178
|
+
onBlur={(event) => {
|
|
179
|
+
// 焦点从图片挪到同一张的删除按钮不算离开
|
|
180
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setLifted((current) => (current === i ? null : current));
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="pw-image-face"
|
|
186
|
+
tabIndex={reachable ? undefined : -1}
|
|
187
|
+
aria-hidden={reachable ? undefined : true}
|
|
188
|
+
title={t.imagePreview}
|
|
189
|
+
aria-label={t.imagePreview}
|
|
190
|
+
onClick={() => setPreview(i)}
|
|
191
|
+
>
|
|
192
|
+
<img src={srcOf(image)} alt="" className="h-full w-full object-contain" draggable={false} />
|
|
193
|
+
</button>
|
|
194
|
+
{reachable ? deleteButton(i) : null}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
) : (
|
|
200
|
+
images.map((image, i) => (
|
|
201
|
+
<div key={keyOf(image)} className="pw-image-thumb">
|
|
202
|
+
<button type="button" className="block" title={t.imagePreview} aria-label={t.imagePreview} onClick={() => setPreview(i)}>
|
|
203
|
+
<img src={srcOf(image)} alt="" className="max-h-24 max-w-32 rounded-[10px] object-contain" draggable={false} />
|
|
204
|
+
</button>
|
|
205
|
+
{deleteButton(i)}
|
|
206
|
+
</div>
|
|
207
|
+
))
|
|
208
|
+
)}
|
|
209
|
+
{images.length > 1 && onClear ? (
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
className="pw-chip"
|
|
213
|
+
data-testid="images-clear"
|
|
214
|
+
disabled={disabled}
|
|
215
|
+
onClick={() => {
|
|
216
|
+
onClear();
|
|
217
|
+
setLifted(null);
|
|
218
|
+
setPreview(null);
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{t.imageClearAll}
|
|
222
|
+
</button>
|
|
223
|
+
) : null}
|
|
224
|
+
{collapsible ? (
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className="pw-chip"
|
|
228
|
+
data-testid="images-toggle"
|
|
229
|
+
aria-expanded={fanned}
|
|
230
|
+
onClick={() => {
|
|
231
|
+
setExpanded((open) => !open);
|
|
232
|
+
setLifted(null);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
{fanned ? t.chipCollapse : t.chipExpandMore.replace("{n}", String(images.length - 1))}
|
|
236
|
+
</button>
|
|
237
|
+
) : null}
|
|
238
|
+
</div>
|
|
239
|
+
{shown !== null
|
|
240
|
+
? createPortal(
|
|
241
|
+
<ImageViewer images={images} index={shown} disabled={disabled} onIndex={setPreview} onRemove={remove} onClose={() => setPreview(null)} />,
|
|
242
|
+
document.body,
|
|
243
|
+
)
|
|
244
|
+
: null}
|
|
245
|
+
</>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 放大预览:手机上没法用本机程序打开图片,应用内看最实在 */
|
|
250
|
+
function ImageViewer({
|
|
251
|
+
images,
|
|
252
|
+
index,
|
|
253
|
+
disabled,
|
|
254
|
+
onIndex,
|
|
255
|
+
onRemove,
|
|
256
|
+
onClose,
|
|
257
|
+
}: {
|
|
258
|
+
images: ImageAttachment[];
|
|
259
|
+
index: number;
|
|
260
|
+
disabled?: boolean;
|
|
261
|
+
onIndex: (index: number) => void;
|
|
262
|
+
onRemove: (index: number) => void;
|
|
263
|
+
onClose: () => void;
|
|
264
|
+
}) {
|
|
265
|
+
const { t } = useI18n();
|
|
266
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
267
|
+
const closeRef = useRef<HTMLButtonElement>(null);
|
|
268
|
+
const count = images.length;
|
|
269
|
+
const go = (delta: number) => onIndex((index + delta + count) % count);
|
|
270
|
+
|
|
271
|
+
// 打开时把焦点移进来,关掉时还给原来那个元素(还在页面上的话)
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
274
|
+
closeRef.current?.focus();
|
|
275
|
+
return () => {
|
|
276
|
+
if (opener?.isConnected) opener.focus();
|
|
277
|
+
};
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
// 捕获阶段处理:Esc / 方向键不再传给页面上其它监听 document 的面板;Tab 只在预览里转
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const onKey = (event: KeyboardEvent) => {
|
|
283
|
+
if (event.key === "Escape") onClose();
|
|
284
|
+
else if (event.key === "ArrowLeft" && count > 1) go(-1);
|
|
285
|
+
else if (event.key === "ArrowRight" && count > 1) go(1);
|
|
286
|
+
else if (event.key === "Tab") {
|
|
287
|
+
const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled)") ?? [])];
|
|
288
|
+
if (!focusable.length) return;
|
|
289
|
+
const at = focusable.indexOf(document.activeElement as HTMLElement);
|
|
290
|
+
const next = event.shiftKey ? (at <= 0 ? focusable.length - 1 : at - 1) : at === focusable.length - 1 ? 0 : at + 1;
|
|
291
|
+
focusable[next].focus();
|
|
292
|
+
} else return;
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
event.stopPropagation();
|
|
295
|
+
};
|
|
296
|
+
document.addEventListener("keydown", onKey, true);
|
|
297
|
+
return () => document.removeEventListener("keydown", onKey, true);
|
|
298
|
+
// onClose / onIndex 来自父组件的 setState 包装,语义不变;只随张数和当前张重绑
|
|
299
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
300
|
+
}, [count, index]);
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div ref={dialogRef} className="pw-lightbox" role="dialog" aria-modal="true" aria-label={t.imageViewer} onClick={onClose}>
|
|
304
|
+
<div className="pw-lightbox-bar" onClick={(event) => event.stopPropagation()}>
|
|
305
|
+
<span className="pw-lightbox-count">{count > 1 ? `${index + 1} / ${count}` : ""}</span>
|
|
306
|
+
<button type="button" className="pw-lightbox-btn" disabled={disabled} onClick={() => onRemove(index)}>
|
|
307
|
+
{t.delete}
|
|
308
|
+
</button>
|
|
309
|
+
<button ref={closeRef} type="button" className="pw-lightbox-btn" aria-label={t.close} onClick={onClose}>
|
|
310
|
+
✕
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
<img src={srcOf(images[index])} alt="" className="pw-lightbox-image" onClick={(event) => event.stopPropagation()} />
|
|
314
|
+
{count > 1 ? (
|
|
315
|
+
<>
|
|
316
|
+
<button type="button" className="pw-lightbox-nav" data-side="prev" aria-label={t.imagePrev} onClick={(event) => { event.stopPropagation(); go(-1); }}>
|
|
317
|
+
‹
|
|
318
|
+
</button>
|
|
319
|
+
<button type="button" className="pw-lightbox-nav" data-side="next" aria-label={t.imageNext} onClick={(event) => { event.stopPropagation(); go(1); }}>
|
|
320
|
+
›
|
|
321
|
+
</button>
|
|
322
|
+
</>
|
|
323
|
+
) : null}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
.panel {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: 100%;
|
|
5
|
+
min-height: 0;
|
|
6
|
+
min-width: 0;
|
|
7
|
+
}
|
|
8
|
+
.header {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: 6px;
|
|
12
|
+
padding: 10px 14px;
|
|
13
|
+
border-bottom: 0.5px solid var(--dsw-border-l2);
|
|
14
|
+
}
|
|
15
|
+
.header button { width: 28px; height: 28px; flex-shrink: 0; }
|
|
16
|
+
.title { flex: 1; font-size: 13.5px; font-weight: 600; }
|
|
17
|
+
/* 历史记录:按钮 + 浮层菜单(原生 select 不跟深色主题) */
|
|
18
|
+
.history { position: relative; padding: 8px 14px 0; }
|
|
19
|
+
.historyButton {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 6px;
|
|
23
|
+
width: 100%;
|
|
24
|
+
min-width: 0;
|
|
25
|
+
height: 30px;
|
|
26
|
+
padding: 0 8px;
|
|
27
|
+
border: 0.5px solid var(--dsw-border-l2);
|
|
28
|
+
border-radius: 8px;
|
|
29
|
+
background: var(--dsw-hover);
|
|
30
|
+
color: var(--dsw-label-primary);
|
|
31
|
+
font-size: 12px;
|
|
32
|
+
text-align: left;
|
|
33
|
+
transition: background var(--ds-duration-fast) var(--ds-ease-in-out), border-color var(--ds-duration-fast) var(--ds-ease-in-out);
|
|
34
|
+
}
|
|
35
|
+
.historyButton:hover { background: var(--dsw-selector); }
|
|
36
|
+
.historyButton[aria-expanded="true"] { border-color: var(--dsw-border-l3); background: var(--dsw-selector); }
|
|
37
|
+
.historyMenu {
|
|
38
|
+
position: absolute;
|
|
39
|
+
left: 14px;
|
|
40
|
+
right: 14px;
|
|
41
|
+
top: calc(100% + 4px);
|
|
42
|
+
z-index: 30;
|
|
43
|
+
max-height: 260px;
|
|
44
|
+
overflow-y: auto;
|
|
45
|
+
padding: 4px;
|
|
46
|
+
border: 0.5px solid var(--dsw-border-l2);
|
|
47
|
+
border-radius: 10px;
|
|
48
|
+
background: var(--dsw-glass-popover);
|
|
49
|
+
box-shadow: var(--dsw-elevation-soft);
|
|
50
|
+
}
|
|
51
|
+
.historyItem {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 6px;
|
|
55
|
+
width: 100%;
|
|
56
|
+
padding: 6px 8px;
|
|
57
|
+
border-radius: 7px;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
color: var(--dsw-label-secondary);
|
|
60
|
+
text-align: left;
|
|
61
|
+
}
|
|
62
|
+
.historyItem:hover { background: var(--dsw-hover); color: var(--dsw-label-primary); }
|
|
63
|
+
.historyItem[aria-checked="true"] { background: var(--dsw-accent-soft); color: var(--dsw-accent); }
|
|
64
|
+
.historyMeta { flex: none; font-size: 11px; color: var(--dsw-label-caption); }
|
|
65
|
+
.messages { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
|
66
|
+
.messageContent { display: flex; flex-direction: column; gap: 20px; padding: 20px 16px; }
|
|
67
|
+
.empty { margin: 36px 8px; color: var(--dsw-label-secondary); text-align: center; line-height: 1.8; }
|
|
68
|
+
.empty p { margin: 0 0 8px; font-size: 14px; }
|
|
69
|
+
.empty span { font-size: 12px; color: var(--dsw-label-tertiary); }
|
|
70
|
+
.question {
|
|
71
|
+
align-self: flex-end;
|
|
72
|
+
max-width: 94%;
|
|
73
|
+
padding: 9px 12px;
|
|
74
|
+
border-radius: 14px 14px 4px 14px;
|
|
75
|
+
background: var(--dsw-accent-soft);
|
|
76
|
+
font-size: var(--piweb-chat-font-size, var(--dsh-content-font-size));
|
|
77
|
+
line-height: 1.65;
|
|
78
|
+
white-space: pre-wrap;
|
|
79
|
+
overflow-wrap: anywhere;
|
|
80
|
+
}
|
|
81
|
+
.answer { min-width: 0; font-size: var(--piweb-chat-font-size, var(--dsh-content-font-size)); line-height: 1.75; overflow-wrap: anywhere; }
|
|
82
|
+
.answer :global(.md) { font-size: inherit; }
|
|
83
|
+
.answer :global(.md pre), .answer :global(.md table) { max-width: 100%; }
|
|
84
|
+
.notice, .waiting { font-size: 12px; line-height: 1.7; color: var(--dsw-label-tertiary); }
|
|
85
|
+
.notice { margin: 4px 0 10px; }
|
|
86
|
+
.error, .storageError { font-size: 12px; line-height: 1.7; color: var(--dsw-danger); overflow-wrap: anywhere; }
|
|
87
|
+
.error button { margin-left: 10px; text-decoration: underline; }
|
|
88
|
+
.storageError { margin: 0; padding: 4px 14px; }
|
|
89
|
+
.composer {
|
|
90
|
+
margin: 10px 12px 12px;
|
|
91
|
+
padding: 11px 12px 9px;
|
|
92
|
+
border: 0.5px solid var(--dsw-border-l2);
|
|
93
|
+
border-radius: 14px;
|
|
94
|
+
background: var(--dsw-bg-base);
|
|
95
|
+
flex-shrink: 0;
|
|
96
|
+
}
|
|
97
|
+
.composer:focus-within { border-color: var(--dsw-border-l3); }
|
|
98
|
+
.composer textarea {
|
|
99
|
+
display: block;
|
|
100
|
+
width: 100%;
|
|
101
|
+
min-height: 64px;
|
|
102
|
+
max-height: 180px;
|
|
103
|
+
resize: none;
|
|
104
|
+
border: 0;
|
|
105
|
+
outline: none;
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: inherit;
|
|
108
|
+
font-family: inherit;
|
|
109
|
+
font-size: var(--piweb-chat-font-size, var(--dsh-content-font-size));
|
|
110
|
+
line-height: 1.65;
|
|
111
|
+
}
|
|
112
|
+
.composer textarea::placeholder { color: var(--dsw-label-tertiary); }
|
|
113
|
+
.actions { display: flex; align-items: center; gap: 8px; justify-content: space-between; padding-top: 5px; }
|
|
114
|
+
.model { flex: 1; min-width: 0; }
|
|
115
|
+
/* 右下角的上下文环:夹在模型选择器与发送键之间 */
|
|
116
|
+
.meter { display: flex; align-items: center; flex: none; }
|
|
117
|
+
.model :global(.chip) { max-width: 100%; font-size: 12px; }
|
|
118
|
+
.model :global(.chip:disabled) { opacity: 0.6; }
|
|
119
|
+
.model :global([role="menu"]) { left: 0; right: auto; width: min(312px, calc(100% + 38px)); }
|
|
120
|
+
.send {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
justify-content: center;
|
|
124
|
+
width: 30px;
|
|
125
|
+
height: 30px;
|
|
126
|
+
flex-shrink: 0;
|
|
127
|
+
border-radius: 50%;
|
|
128
|
+
background: var(--dsw-label-primary);
|
|
129
|
+
color: var(--dsw-bg-base);
|
|
130
|
+
}
|
|
131
|
+
.send:disabled { opacity: 0.3; cursor: default; }
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import dynamic from "next/dynamic";
|
|
5
|
+
import { IconChevronDown14, IconCloseOutline14, IconNewChatOutline16, IconSendArrowUp14, IconStopFill16 } from "@/components/icons";
|
|
6
|
+
import { ContextMeter } from "@/components/ContextMeter";
|
|
7
|
+
import { ModelSelector, type ModelChoice } from "@/components/ModelSelector";
|
|
8
|
+
import { useI18n } from "@/i18n";
|
|
9
|
+
import { EMPTY_SIDE_CHAT, sideChatStore, type SideChatStore } from "./store";
|
|
10
|
+
import styles from "./SideChatPanel.module.css";
|
|
11
|
+
|
|
12
|
+
const Markdown = dynamic(() => import("@/components/MarkdownBlock").then((module) => module.MarkdownBlock));
|
|
13
|
+
const StreamingMarkdown = dynamic(() => import("@/components/MarkdownBlock").then((module) => module.StreamingMarkdownBlock));
|
|
14
|
+
|
|
15
|
+
export function SideChatPanel({ sessionId, ready = true, mainModel, models = [], providerNames = {}, authByProvider = {}, onClose, store = sideChatStore }: {
|
|
16
|
+
sessionId: string | null;
|
|
17
|
+
ready?: boolean;
|
|
18
|
+
mainModel?: { provider: string; id: string; name: string };
|
|
19
|
+
models?: ModelChoice[];
|
|
20
|
+
providerNames?: Record<string, string>;
|
|
21
|
+
authByProvider?: Record<string, boolean>;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
store?: SideChatStore;
|
|
24
|
+
}) {
|
|
25
|
+
const { t } = useI18n();
|
|
26
|
+
const subscribe = useCallback((listener: () => void) => store.subscribe(sessionId, listener), [sessionId, store]);
|
|
27
|
+
const getSnapshot = useCallback(() => store.get(sessionId), [sessionId, store]);
|
|
28
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_SIDE_CHAT);
|
|
29
|
+
const thread = state.threads.find((item) => item.id === state.currentThreadId);
|
|
30
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
32
|
+
const pinned = useRef(true);
|
|
33
|
+
const lastTop = useRef(0);
|
|
34
|
+
const busy = Boolean(thread?.request);
|
|
35
|
+
const threadId = thread?.id;
|
|
36
|
+
/** 历史记录菜单是否展开 */
|
|
37
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
38
|
+
const historyRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
const selectedModel = thread?.model
|
|
40
|
+
? models.find((model) => model.provider === thread.model!.provider && model.id === thread.model!.id) ?? { ...thread.model, name: thread.model.id }
|
|
41
|
+
: mainModel;
|
|
42
|
+
const canSend = Boolean(sessionId && ready && selectedModel);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
pinned.current = true;
|
|
46
|
+
lastTop.current = 0;
|
|
47
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
48
|
+
}, [sessionId, threadId]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const node = scrollRef.current;
|
|
51
|
+
if (!node) return;
|
|
52
|
+
const pin = () => { if (pinned.current) node.scrollTop = node.scrollHeight; };
|
|
53
|
+
const observer = new ResizeObserver(pin);
|
|
54
|
+
if (node.firstElementChild) observer.observe(node.firstElementChild);
|
|
55
|
+
pin();
|
|
56
|
+
return () => observer.disconnect();
|
|
57
|
+
}, [thread?.messages]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const textarea = textareaRef.current;
|
|
60
|
+
if (!textarea) return;
|
|
61
|
+
textarea.style.height = "auto";
|
|
62
|
+
textarea.style.height = `${Math.min(180, Math.max(64, textarea.scrollHeight))}px`;
|
|
63
|
+
}, [thread?.draft, threadId]);
|
|
64
|
+
|
|
65
|
+
// 历史记录菜单:点外部或 Esc 关掉(与圆环弹窗同一套做法)
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!historyOpen) return;
|
|
68
|
+
const onDown = (event: MouseEvent) => {
|
|
69
|
+
if (!historyRef.current?.contains(event.target as Node)) setHistoryOpen(false);
|
|
70
|
+
};
|
|
71
|
+
const onKey = (event: KeyboardEvent) => {
|
|
72
|
+
if (event.key === "Escape") setHistoryOpen(false);
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener("mousedown", onDown);
|
|
75
|
+
document.addEventListener("keydown", onKey);
|
|
76
|
+
return () => {
|
|
77
|
+
document.removeEventListener("mousedown", onDown);
|
|
78
|
+
document.removeEventListener("keydown", onKey);
|
|
79
|
+
};
|
|
80
|
+
}, [historyOpen]);
|
|
81
|
+
|
|
82
|
+
// 上下文:这次问出去的真实 prompt(input + 缓存读写)对上该模型的窗口。
|
|
83
|
+
// 没有真实用量就一律 null —— 圆环显示「用量未知」,不编数字。
|
|
84
|
+
const usage = thread?.usage;
|
|
85
|
+
const contextTokens = usage ? usage.input + usage.cacheRead + usage.cacheWrite : null;
|
|
86
|
+
const contextWindow = thread?.contextWindow ?? null;
|
|
87
|
+
const contextPercent = contextTokens !== null && contextWindow ? (contextTokens / contextWindow) * 100 : null;
|
|
88
|
+
|
|
89
|
+
const send = () => {
|
|
90
|
+
if (!sessionId || !canSend || busy || !thread?.draft.trim()) return;
|
|
91
|
+
pinned.current = true;
|
|
92
|
+
store.send(sessionId);
|
|
93
|
+
textareaRef.current?.focus({ preventScroll: true });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<section className={styles.panel} aria-label={t.sideChat} data-testid="side-chat-panel">
|
|
98
|
+
<header className={styles.header}>
|
|
99
|
+
<span className={styles.title}>{t.sideChat}</span>
|
|
100
|
+
<button type="button" className="icon-btn" aria-label={t.sideChatNew} title={t.sideChatNew} disabled={!sessionId} onClick={() => sessionId && store.createThread(sessionId)}>
|
|
101
|
+
<IconNewChatOutline16 size={16} />
|
|
102
|
+
</button>
|
|
103
|
+
<button type="button" className="icon-btn" aria-label={t.close} title={t.close} onClick={onClose}><IconCloseOutline14 size={14} /></button>
|
|
104
|
+
</header>
|
|
105
|
+
{state.threads.length > 1 && (
|
|
106
|
+
// 原生 select 在深色界面里是系统白底菜单(又丑又不跟主题),改用与模型选择器一致的浮层菜单
|
|
107
|
+
<div className={styles.history} ref={historyRef}>
|
|
108
|
+
<button type="button" className={styles.historyButton} aria-label={t.sideChatHistory} aria-haspopup="menu" aria-expanded={historyOpen} onClick={() => setHistoryOpen((open) => !open)}>
|
|
109
|
+
<span className="truncate">{thread ? `${thread.title || t.sideChatNew}${thread.request ? " · …" : ""}` : t.sideChatHistory}</span>
|
|
110
|
+
<IconChevronDown14 size={12} style={{ flex: "none", color: "var(--dsw-label-caption)" }} />
|
|
111
|
+
</button>
|
|
112
|
+
{historyOpen && (
|
|
113
|
+
<div className={styles.historyMenu} role="menu" aria-label={t.sideChatHistory}>
|
|
114
|
+
{[...state.threads].reverse().map((item) => (
|
|
115
|
+
<button key={item.id} type="button" role="menuitemradio" aria-checked={item.id === threadId} className={styles.historyItem}
|
|
116
|
+
onClick={() => { if (sessionId) store.selectThread(sessionId, item.id); setHistoryOpen(false); }}>
|
|
117
|
+
<span className="truncate">{item.title || t.sideChatNew}</span>
|
|
118
|
+
{item.request ? <span className={styles.historyMeta}>…</span> : null}
|
|
119
|
+
</button>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
<div className={styles.messages} ref={scrollRef} role="log" aria-label={t.sideChatMessages} aria-live="polite" aria-relevant="additions" onScroll={(event) => {
|
|
126
|
+
const node = event.currentTarget;
|
|
127
|
+
const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
|
|
128
|
+
const movedUp = node.scrollTop < lastTop.current - 1 && distance > 2;
|
|
129
|
+
pinned.current = !movedUp && distance < 48;
|
|
130
|
+
lastTop.current = node.scrollTop;
|
|
131
|
+
}}>
|
|
132
|
+
<div className={styles.messageContent}>
|
|
133
|
+
{!thread?.messages.length ? (
|
|
134
|
+
<div className={styles.empty}>
|
|
135
|
+
<p>{sessionId ? t.sideChatEmpty : t.sideChatPickSession}</p>
|
|
136
|
+
{sessionId && <span>{t.sideChatHint}</span>}
|
|
137
|
+
</div>
|
|
138
|
+
) : thread.messages.map((message) => (
|
|
139
|
+
<div key={message.id} className={message.role === "user" ? styles.question : styles.answer} data-role={message.role}>
|
|
140
|
+
{message.role === "user" ? message.text : <>
|
|
141
|
+
{message.contextTruncated && <p className={styles.notice}>{t.sideChatContextTruncated}</p>}
|
|
142
|
+
{message.text && (message.status === "pending" ? <StreamingMarkdown text={message.text} /> : <Markdown text={message.text} />)}
|
|
143
|
+
{message.status === "pending" && !message.text && <span className={styles.waiting} role="status">{thread.connectionLost ? t.reconnecting : t.sideChatAnswering}</span>}
|
|
144
|
+
{message.error && <div className={styles.error} role="alert">{message.error}{message.status === "error" && message === thread.messages.at(-1) && <button type="button" disabled={busy} onClick={() => sessionId && store.send(sessionId, true)}>{t.retry}</button>}</div>}
|
|
145
|
+
{message.status === "aborted" && <p className={styles.notice}>{t.turnAborted}</p>}
|
|
146
|
+
{message.lengthLimited && <p className={styles.notice}>{t.sideChatLengthLimited}</p>}
|
|
147
|
+
</>}
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
{state.storageFailed && <p className={styles.storageError} role="alert">{t.sideChatStorageFailed}</p>}
|
|
153
|
+
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); send(); }}>
|
|
154
|
+
<textarea ref={textareaRef} aria-label={t.sideChatQuestion} placeholder={sessionId ? t.sideChatPlaceholder : t.sideChatPickSession} value={thread?.draft ?? ""} disabled={!canSend}
|
|
155
|
+
onChange={(event) => sessionId && store.setDraft(sessionId, event.target.value)}
|
|
156
|
+
onKeyDown={(event) => {
|
|
157
|
+
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229) { event.preventDefault(); send(); }
|
|
158
|
+
}} />
|
|
159
|
+
<div className={styles.actions}>
|
|
160
|
+
<div className={styles.model} role="group" aria-label={t.sideChatModel}>
|
|
161
|
+
<ModelSelector model={selectedModel} models={models} providerNames={providerNames} authByProvider={authByProvider}
|
|
162
|
+
modelOnly disabled={!sessionId} thinkingLevels={[]} onSelectLevel={() => {}}
|
|
163
|
+
defaultOption={{ label: t.sideChatFollowMain, selected: !thread?.model, onSelect: () => sessionId && store.setModel(sessionId) }}
|
|
164
|
+
onSelectModel={(provider, id) => sessionId && store.setModel(sessionId, { provider, id })} />
|
|
165
|
+
</div>
|
|
166
|
+
<div className={styles.meter}>
|
|
167
|
+
<ContextMeter
|
|
168
|
+
percent={contextPercent}
|
|
169
|
+
tokens={contextTokens}
|
|
170
|
+
contextWindow={contextWindow}
|
|
171
|
+
label={t.contextMeasured}
|
|
172
|
+
source={thread?.characters ? {
|
|
173
|
+
systemChars: 0,
|
|
174
|
+
messages: thread.messages.map((message) => ({ role: message.role, content: [{ type: "text", text: message.text }] })),
|
|
175
|
+
breakdown: {
|
|
176
|
+
characters: { systemPrompt: thread.characters.systemPrompt, background: thread.characters.background, systemTools: 0, customTools: 0, memory: 0, skills: 0, compacted: 0 },
|
|
177
|
+
systemPrompt: 0, systemTools: 0, customTools: 0, memory: 0, skills: 0, compacted: 0, autoCompactBuffer: 0,
|
|
178
|
+
},
|
|
179
|
+
} : undefined}
|
|
180
|
+
model={selectedModel ? { provider: selectedModel.provider, id: selectedModel.id } : null}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
{busy ? <button type="button" className={styles.send} title={t.stop} aria-label={t.stop} onClick={() => sessionId && store.stop(sessionId)}><IconStopFill16 size={15} /></button>
|
|
184
|
+
: <button type="submit" className={styles.send} title={t.send} aria-label={t.send} disabled={!canSend || !thread?.draft.trim()}><IconSendArrowUp14 size={16} /></button>}
|
|
185
|
+
</div>
|
|
186
|
+
</form>
|
|
187
|
+
</section>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
|
|
3
|
+
export function SideChatIcon({ size = 16, ...props }: SVGProps<SVGSVGElement> & { size?: number }) {
|
|
4
|
+
return <svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
|
|
5
|
+
<path d="M12.5 11.5H6l-3.5 2V4.5A2 2 0 0 1 4.5 2.5h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2Z" />
|
|
6
|
+
<path d="M7.5 14.5h6.5l3.5 2V7.5a2 2 0 0 0-1-1.73" />
|
|
7
|
+
</svg>;
|
|
8
|
+
}
|