@rexvane/piweb 0.3.24 → 0.3.26
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 +11 -11
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +5 -5
- package/.next/react-loadable-manifest.json +13 -20
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- package/.next/routes-manifest.json +6 -8
- package/.next/server/app/_global-error/page.js +2 -2
- 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.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js +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.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -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]/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/agent/resources/route.js +1 -0
- package/.next/server/app/api/agent/resources/route.js.nft.json +1 -0
- package/.next/server/app/api/agent/resources/route_client-reference-manifest.js +1 -0
- 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 +4 -4
- package/.next/server/app/api/github/route.js.nft.json +1 -1
- 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/models/defaults/route.js +1 -1
- package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
- 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 -3
- 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/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/index.html +1 -1
- package/.next/server/app/index.rsc +6 -7
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +6 -7
- package/.next/server/app/index.segments/_full.segment.rsc +6 -7
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -3
- package/.next/server/app/login/page.js +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.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- 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 +11 -11
- package/.next/server/chunks/3924.js +3 -3
- package/.next/server/chunks/538.js +1 -1
- package/.next/server/chunks/620.js +1 -0
- package/.next/server/chunks/6229.js +1 -1
- package/.next/server/chunks/7496.js +1 -1
- package/.next/server/chunks/7662.js +2 -2
- package/.next/server/chunks/8686.js +1 -1
- package/.next/server/functions-config-manifest.json +1 -2
- 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/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/preview-props.json +3 -3
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/QJCtqgDKzGS2BdKABd9ch/_buildManifest.js +1 -0
- package/.next/static/chunks/2577.86c8538fbf05a8b2.js +1 -0
- package/.next/static/chunks/2716.fa561519675a3ff1.js +1 -0
- package/.next/static/chunks/2906.8a0e09bb47605cf1.js +1 -0
- package/.next/static/chunks/4673.ea36e55005c0f82f.js +1 -0
- package/.next/static/chunks/4686-4dc565817a160351.js +1 -0
- package/.next/static/chunks/4778.0d2c5d75b791d352.js +1 -0
- package/.next/static/chunks/{618.949981da99be7182.js → 618.9a22656b863f242b.js} +2 -2
- package/.next/static/chunks/app/_global-error/page-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/agent/resources/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/config/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/files/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/fs/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/git/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/github/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/growth/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/health/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/models/defaults/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/models/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/remote/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/security/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/skills/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/update/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/version/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/app/page-71f2d3b3ed864b3d.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f2d93969c0bcf406.js +1 -0
- package/.next/static/chunks/webpack-ffc09b5fcc8aaa6d.js +1 -0
- package/.next/static/css/014eb1f21414e615.css +1 -0
- package/.next/static/css/56b129a13637fcfd.css +1 -0
- package/.next/static/css/7e3caf797e4cb19a.css +1 -0
- package/.next/types/app/api/agent/{[id]/side-chat → resources}/route.ts +3 -3
- package/.next/types/routes.d.ts +2 -2
- package/.next/types/validator.ts +9 -9
- package/README.md +1 -1
- package/next.config.ts +1 -1
- package/package.json +9 -9
- package/scripts/apply-global.mjs +87 -0
- package/scripts/launcher.mjs +21 -0
- package/scripts/testing/prepare-sdk-tools.mjs +19 -0
- package/src/app/api/agent/new/route.ts +2 -1
- package/src/app/api/agent/resources/route.ts +18 -0
- package/src/app/api/models/route.ts +22 -1
- package/src/app/api/plugins/route.ts +3 -2
- package/src/app/api/prompts/route.ts +3 -2
- package/src/app/api/remote/route.ts +22 -4
- package/src/app/api/sessions/[id]/export/route.ts +3 -1
- package/src/app/api/skills/route.ts +3 -2
- package/src/app/globals.css +1002 -1765
- package/src/components/AppShell.tsx +128 -93
- package/src/components/ChatInput.tsx +177 -98
- package/src/components/ChatWindow.tsx +189 -56
- package/src/components/ContextMeter.tsx +33 -9
- package/src/components/CustomSelect.module.css +142 -0
- package/src/components/CustomSelect.tsx +157 -0
- package/src/components/DrawerPanel.tsx +1 -1
- package/src/components/ExtensionUI.tsx +9 -3
- package/src/components/FileViewer.tsx +24 -21
- package/src/components/GitPanel.tsx +35 -13
- package/src/components/GrowthTree.tsx +6 -2
- package/src/components/ImageViewer.tsx +72 -0
- package/src/components/MarkdownBlock.tsx +67 -96
- package/src/components/ModelSelector.tsx +36 -39
- package/src/components/PiMark.tsx +76 -41
- package/src/components/PreviewImage.tsx +33 -0
- package/src/components/ProjectPanel.tsx +8 -5
- package/src/components/PromptPanel.tsx +85 -20
- package/src/components/ProviderSetupModal.module.css +12 -11
- package/src/components/ProviderSetupModal.tsx +107 -36
- package/src/components/RemoteSection.tsx +53 -2
- package/src/components/SessionImportSection.tsx +30 -15
- package/src/components/SessionSidebar.tsx +72 -62
- package/src/components/SettingsPanel.tsx +49 -151
- package/src/components/TrajectoryAnalyzer.module.css +26 -11
- package/src/components/TrajectoryView.tsx +11 -3
- package/src/components/chat/markdown-actions.tsx +23 -0
- package/src/components/composer/ComposerFiles.tsx +224 -0
- package/src/components/composer/ComposerImages.tsx +59 -127
- package/src/hooks/usePiWeb.ts +47 -2
- package/src/i18n.tsx +62 -80
- package/src/lib/agent-manager.ts +365 -33
- package/src/lib/chat/file-path.ts +54 -0
- package/src/lib/chat/skill-expansion.ts +51 -0
- package/src/lib/extension-ui.ts +25 -1
- package/src/lib/font.ts +37 -39
- package/src/lib/git-service.ts +4 -2
- package/src/lib/growth-turns.ts +3 -1
- package/src/lib/model-draft.ts +69 -2
- package/src/lib/models-service.ts +53 -3
- package/src/lib/pi.ts +2 -2
- package/src/lib/remote/assets.json +1 -1
- package/src/lib/remote/supervisor.ts +48 -5
- package/src/lib/security-service.ts +2 -1
- package/src/lib/session-reader.ts +25 -13
- package/src/lib/session-retention.ts +11 -8
- package/src/lib/skills-service.ts +2 -1
- package/src/lib/slash-command-token.ts +14 -0
- package/src/lib/trajectory.ts +111 -15
- package/src/lib/types.ts +28 -3
- package/src/lib/update-service.ts +29 -9
- package/src/lib/workspace-store.ts +36 -1
- package/.next/server/app/api/agent/[id]/side-chat/route.js +0 -5
- package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +0 -1
- package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +0 -1
- package/.next/server/chunks/3421.js +0 -1
- package/.next/static/chunks/2716.44fdf1b21454b961.js +0 -1
- package/.next/static/chunks/4673.e261707dce391be7.js +0 -1
- package/.next/static/chunks/4686-0cd87513f9ea3538.js +0 -1
- package/.next/static/chunks/4778.eddd3a5813f85824.js +0 -1
- package/.next/static/chunks/7411.5f79671208209b13.js +0 -1
- package/.next/static/chunks/8552.25c39ef4c995eba0.js +0 -1
- package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +0 -1
- package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +0 -1
- package/.next/static/css/08815507594e8b8d.css +0 -1
- package/.next/static/css/9fc559c2c4ad1d0f.css +0 -1
- package/.next/static/css/c6e2227c36ec2fd6.css +0 -1
- package/.next/static/css/f15a853af8b5127f.css +0 -1
- package/.next/static/lvKCJmjFj8LuqSojRVLRF/_buildManifest.js +0 -1
- package/src/app/api/agent/[id]/side-chat/route.ts +0 -77
- package/src/components/side-chat/SideChatPanel.module.css +0 -131
- package/src/components/side-chat/SideChatPanel.tsx +0 -189
- package/src/components/side-chat/icons.tsx +0 -8
- package/src/components/side-chat/request-history.ts +0 -29
- package/src/components/side-chat/store.ts +0 -248
- package/src/lib/chat/stream-settings.ts +0 -192
- package/src/lib/side-chat/context.ts +0 -123
- package/src/lib/side-chat/service.ts +0 -191
- package/src/lib/side-chat/types.ts +0 -61
- /package/.next/static/{lvKCJmjFj8LuqSojRVLRF → QJCtqgDKzGS2BdKABd9ch}/_ssgManifest.js +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"css": "/*\n * 手机端适配。不改 PiWeb,只在经代理的远程页面上生效\n * (本机直连 127.0.0.1:30141 的桌面页面完全不加载它)。\n *\n * 取值参照 dsh-remote 为 DSH 定的那套移动适配(同作者),并针对 PiWeb 的实测问题:\n * 390px 下输入框 304px、markdown 表格 442px 超出视口、底部统计行被截断。\n */\n@media (max-width: 840px) {\n\t/* 1) 输入框自适应高度失控。\n\t 实测:视口 390 → 304px,500 → 43px,700+ → 22px;而自然内容高始终 22px。\n\t 原因在 ChatInput 的 autoSize():它把\"被 flex 拉伸后\"的 scrollHeight 写进了\n\t inline height,写回后又把自己撑住,形成自锁。inline 样式不是 important,\n\t 所以这里用 max-height !important 把它夹住。 */\n\ttextarea {\n\t\tmax-height: 132px !important;\n\t\toverflow-y: auto !important;\n\t}\n\n\t/* 2) Markdown 宽表格超出视口被裁掉(实测 442 \u003e 390,body 又是 overflow-x:hidden,\n\t 等于右侧内容既看不到也滑不到)。让表格自己横向滚动。 */\n\t.md table {\n\t\tdisplay: block !important;\n\t\tmax-width: 100% !important;\n\t\toverflow-x: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\n\t/* 3) 全局溢出护栏(dsh-remote 同款):任何一块都不许把文档撑宽 */\n\thtml,\n\tbody {\n\t\tmax-width: 100vw !important;\n\t\toverflow-x: hidden !important;\n\t}\n\tpre {\n\t\tmax-width: 100% !important;\n\t\toverflow-x: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\timg,\n\tsvg,\n\tvideo,\n\tcanvas {\n\t\tmax-width: 100% !important;\n\t}\n\n\t/* 4) 底部统计行原本 nowrap + ellipsis,手机上「| 输入 2…」之后永远看不到。\n\t 改成可横向滑动,滚动条隐藏,信息不再丢。 */\n\tdiv.text-center \u003e div.overflow-hidden.text-ellipsis.whitespace-nowrap {\n\t\toverflow-x: auto !important;\n\t\toverflow-y: hidden !important;\n\t\ttext-overflow: clip !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t\tscrollbar-width: none;\n\t}\n\tdiv.text-center \u003e div.overflow-hidden.text-ellipsis.whitespace-nowrap::-webkit-scrollbar {\n\t\tdisplay: none;\n\t}\n\n\t/* 5) 给手机的手势条留位置(没有 insets 的设备 env() 为 0,等于没写) */\n\t.pi-main {\n\t\tpadding-bottom: env(safe-area-inset-bottom) !important;\n\t}\n\n\t/* 6) 左上角的抽屉按钮。原来是个悬浮的圆角方块(12px/12px、34px),盖在内容上、\n\t 和右边那排图标也不在一条水平线上。按 dsh-remote 对 DSH 左侧的做法收成贴角圆形,\n\t 尺寸和位置改成与右侧图标完全一致(实测右侧图标是 top 12 / 30×30,中心 y=27),\n\t 这样整行对齐,按钮底边 42 也在头部分隔线(y=43)之上,不会压住那条线。 */\n\t.pi-mobile-menu {\n\t\twidth: 30px !important;\n\t\theight: 30px !important;\n\t\tborder-radius: 999px !important;\n\t\tleft: calc(env(safe-area-inset-left) + 12px) !important;\n\t\ttop: calc(env(safe-area-inset-top) + 12px) !important;\n\t}\n\t.pi-main \u003e .hairline-b {\n\t\tpadding-left: 52px !important;\n\t}\n\n\t/* 7) 右侧大纲导轨是绝对定位的(right-2 + 约 34px 宽的竖条),正文和用户气泡\n\t 会从它下面穿过去。不动条宽——那是层级信息——而是把导轨贴边收窄,\n\t 再给滚动区留出它实际占的宽度。 */\n\t.outline-rail {\n\t\tright: 2px !important;\n\t}\n\t.outline-rail \u003e div {\n\t\tpadding-left: 6px !important;\n\t\tpadding-right: 2px !important;\n\t}\n\t.pi-main div.min-h-0.flex-1.overflow-y-auto {\n\t\tpadding-right: 30px !important;\n\t}\n\n\t/* 8) 设置弹窗:高度用 100vh(移动端的大视口),内容列又没有 min-height:0\n\t 也没有 overflow-y,于是 1986px 的内容被 overflow:hidden 裁在 828px 里,\n\t 手机上完全滑不动。实测 client=828 / scroll=1986。 */\n\t.settings-dialog {\n\t\theight: calc(100vh - 16px) !important;\n\t}\n\t.settings-dialog \u003e div:not(.settings-nav) {\n\t\tmin-height: 0 !important;\n\t\toverflow-y: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\n\t/* 9) 模型 / 思考等级浮层锚在胶囊自己的 relative 容器上,right-0 是**胶囊**右缘,\n\t 312px 往左展开就顶出屏幕左边(实测 left 为负)。把胶囊容器解除定位,\n\t 浮层改用整张输入卡(上一层 relative)做定位父级,right-0 落在卡片右缘就装得下。\n\t :has() 在移动端 Safari 15.4+ / Chrome 105+ 都可用。 */\n\tdiv.relative:has(\u003e button.chip[aria-haspopup=\"menu\"]) {\n\t\tposition: static !important;\n\t}\n\n\t/* 10) 设置→模型 的提供方卡片:那一行是 nowrap,中间的名字列被右侧一串按钮挤成\n\t 0 宽(实测 w=0),于是「凭据: auth.json」竖着拆行、「编辑」拆成「编/辑」、\n\t 右侧的按钮还被裁掉。手机上让这行能换行,并给名字列一个真实的最小宽度。 */\n\t.settings-dialog div.flex.items-center.gap-3:has(\u003e div.min-w-0.flex-1) {\n\t\tflex-wrap: wrap !important;\n\t\trow-gap: 8px !important;\n\t}\n\t.settings-dialog div.flex.items-center.gap-3:has(\u003e div.min-w-0.flex-1) \u003e div.min-w-0.flex-1 {\n\t\tflex: 1 1 140px !important;\n\t\tmin-width: 140px !important;\n\t}\n\n\t/* 11) 设置里 Row 那一套([文字列][控件列] 两栏)。控件列是 flex-none,\n\t 有的行右侧很宽(「开关 + 两个数字输入框」≈272px、「繁忙时 Enter」的下拉≈233px),\n\t 于是 390px 下文字列只剩 70px 左右,中文按每行一两字竖排,行高被撑到\n\t 122px / 218px(实测 70×122、69×218)。\n\t 试过的几种写法与实测结论:\n\t - 只给文字列 flex 增长权重:完全没用(那行没有空闲空间)\n\t - 只缩小 gap:只能缓解(70×122 → 86×98)\n\t - 让所有 Row 都上下排(flex:1 1 100%):能修好,但把本来正常的行也撑高\n\t (界面语言 53→85、字体步进 95→105),是密度回退\n\t - 正解:文字列 flex-basis 设 0、只留 min-width 当\"不许被压到多窄\"的下限。\n\t 换行判断用的是 flex-basis(max-content 会很大,所以上一版全换行了),\n\t 设成 0 之后判断就只看 min-width:\n\t 控件窄的行(界面语言 77px、字体步进 116px)→ 仍并排,保持紧凑\n\t 控件宽的行(272px / 233px)→ 控件换到下一行,文字拿满 326px\n\t 实测:界面语言 53 不变、界面字体 95→71、繁忙时Enter 239→135、\n\t 自动压缩 143→111、自动重试 119→111,全部不再竖排。 */\n\t.settings-dialog div.hairline-b.gap-6 {\n\t\tflex-wrap: wrap !important;\n\t\tgap: 8px !important;\n\t}\n\t.settings-dialog div.hairline-b.gap-6 \u003e div.min-w-0 {\n\t\tflex: 1 1 0 !important;\n\t\tmin-width: 45% !important;\n\t}\n\t.settings-dialog div.hairline-b.gap-6 \u003e div.flex-none \u003e div.flex.items-center {\n\t\tflex-wrap: wrap !important;\n\t\tjustify-content: flex-end !important;\n\t\trow-gap: 6px !important;\n\t}\n\n\t/* 12) 外观主题卡片。卡片是 flex: 1 1 150px 的两列网格,但左右各留 32px 内边距,\n\t 159px 的卡片只剩 95px 给标题,于是「石灰与煤炭」「亚麻与苔绿」掉到第二行,\n\t 卡片高度变成 110px 而其余是 88px,网格参差(实测 6 张卡:4 张 88 / 2 张 110)。\n\t 把左右内边距收到 10px,6 张卡全部回到 88px,标题一行放得下。 */\n\t.settings-dialog button[aria-pressed] {\n\t\tpadding-left: 10px !important;\n\t\tpadding-right: 10px !important;\n\t}\n\n\t/* 13) 可点目标偏小(实测:字体步进器 26×26、reset 48×24、开关 36×20)。\n\t 只扩**热区**,绝不改可见尺寸:开关的滑块位置在 PiWeb 里是写死的\n\t (left: 18px / 2px),把开关拉宽会让滑块错位、变成被拉长的怪药丸\n\t —— 我第一版写的 min-width:44px 就是这个后果,用户报了\"启动按钮好丑\"。\n\t 另外这里只做**纵向**扩张(左右不扩):字体步进器 −/+/ 之间只有 8px,\n\t 横向扩到 44px 会让两个按钮的热区重叠约 10px,点 − 反而命中 +。 */\n\t.settings-dialog .icon-btn,\n\t.settings-dialog button.h-6,\n\t.settings-dialog button.h-5,\n\t.settings-dialog [role=\"switch\"] {\n\t\tposition: relative !important;\n\t}\n\t.settings-dialog .icon-btn::after,\n\t.settings-dialog button.h-6::after,\n\t.settings-dialog button.h-5::after,\n\t.settings-dialog [role=\"switch\"]::after {\n\t\tcontent: \"\" !important;\n\t\tposition: absolute !important;\n\t\tleft: 0 !important;\n\t\tright: 0 !important;\n\t\ttop: 50% !important;\n\t\theight: max(100%, 44px) !important;\n\t\ttransform: translateY(-50%) !important;\n\t\tborder-radius: 999px !important;\n\t}\n}\n\n/* 手机上的浮层统一兜底:不许比屏幕宽,不许比屏幕高,超出就自己滚。 */\n@media (max-width: 840px) {\n\t.popover {\n\t\tmax-width: calc(100vw - 16px) !important;\n\t\tmax-height: 420px !important;\n\t\toverflow-y: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n}\n\n/* 11) 模型名太长时跑马灯。取值与做法照搬 dsh-remote 对 DSH 的:\n\t 名字窗口固定 28vw 并允许被压缩,装得下就完全不动;装不下时由注入的\n\t mobile-fit.js 打上 data-piweb-marquee,再由这里的两份伪元素首尾相接匀速左移。\n\t 一份走完另一份正好接上(travel 已含间隔),所以循环处看不出接缝。 */\n@media (max-width: 840px) {\n\tbutton.chip[aria-haspopup=\"menu\"] \u003e span.truncate {\n\t\tmin-width: 0 !important;\n\t\tmax-width: 28vw !important;\n\t}\n\t[data-piweb-marquee] {\n\t\tposition: relative !important;\n\t\tdisplay: block !important;\n\t\twidth: 28vw !important;\n\t\tflex: none !important;\n\t\ttext-overflow: clip !important;\n\t\twhite-space: nowrap !important;\n\t\toverflow: hidden !important;\n\t\ttext-indent: 0 !important;\n\t\tcontain: layout paint !important;\n\t\tcolor: transparent !important;\n\t\tline-height: 20px !important;\n\t}\n\t[data-piweb-marquee]::before,\n\t[data-piweb-marquee]::after {\n\t\tcontent: attr(data-piweb-text);\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tcolor: var(--piweb-marquee-color, var(--dsw-label-primary));\n\t\tfont-size: 13px;\n\t\tline-height: 20px;\n\t\twhite-space: nowrap;\n\t\twill-change: transform;\n\t\ttransform: translate3d(0, 0, 0);\n\t\tanimation: piweb-mfit-marquee var(--piweb-marquee-duration, 8s) linear infinite both;\n\t}\n\t[data-piweb-marquee]::after {\n\t\tleft: var(--piweb-marquee-text, 0px);\n\t}\n\t@keyframes piweb-mfit-marquee {\n\t\tfrom { transform: translate3d(0, 0, 0); }\n\t\tto { transform: translate3d(calc(-1 * var(--piweb-marquee-text, 0px)), 0, 0); }\n\t}\n}\n\n/* dvh 是\"实际可见高度\",比 100vh(移动 Chrome 的大视口)可靠;\n 不支持时上面那两条固定值兜底。 */\n@supports (height: 100dvh) {\n\t@media (max-width: 840px) {\n\t\ttextarea {\n\t\t\tmax-height: min(132px, 26dvh) !important;\n\t\t}\n\t\t.popover {\n\t\t\tmax-height: min(60dvh, 420px) !important;\n\t\t}\n\t\t.settings-dialog {\n\t\t\theight: calc(100dvh - 16px) !important;\n\t\t}\n\t}\n}\n",
|
|
2
|
+
"css": "/*\n * 手机端适配。不改 PiWeb,只在经代理的远程页面上生效\n * (本机直连 127.0.0.1:30141 的桌面页面完全不加载它)。\n *\n * 取值参照 dsh-remote 为 DSH 定的那套移动适配(同作者),并针对 PiWeb 的实测问题:\n * 390px 下输入框 304px、markdown 表格 442px 超出视口、底部统计行被截断。\n */\n@media (max-width: 840px) {\n\t/* 1) 输入框自适应高度失控。\n\t 实测:视口 390 → 304px,500 → 43px,700+ → 22px;而自然内容高始终 22px。\n\t 原因在 ChatInput 的 autoSize():它把\"被 flex 拉伸后\"的 scrollHeight 写进了\n\t inline height,写回后又把自己撑住,形成自锁。inline 样式不是 important,\n\t 所以这里用 max-height !important 把它夹住。 */\n\ttextarea {\n\t\tmax-height: 132px !important;\n\t\toverflow-y: auto !important;\n\t}\n\n\t/* 2) Markdown 宽表格超出视口被裁掉(实测 442 \u003e 390,body 又是 overflow-x:hidden,\n\t 等于右侧内容既看不到也滑不到)。让表格自己横向滚动。 */\n\t.md table {\n\t\tdisplay: block !important;\n\t\tmax-width: 100% !important;\n\t\toverflow-x: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\n\t/* 3) 全局溢出护栏(dsh-remote 同款):任何一块都不许把文档撑宽 */\n\thtml,\n\tbody {\n\t\tmax-width: 100vw !important;\n\t\toverflow-x: hidden !important;\n\t}\n\tpre {\n\t\tmax-width: 100% !important;\n\t\toverflow-x: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\timg,\n\tsvg,\n\tvideo,\n\tcanvas {\n\t\tmax-width: 100% !important;\n\t}\n\n\t/* 4) 底部统计行原本 nowrap + ellipsis,手机上「| 输入 2…」之后永远看不到。\n\t 改成可横向滑动,滚动条隐藏,信息不再丢。 */\n\tdiv.text-center \u003e div.overflow-hidden.text-ellipsis.whitespace-nowrap {\n\t\toverflow-x: auto !important;\n\t\toverflow-y: hidden !important;\n\t\ttext-overflow: clip !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t\tscrollbar-width: none;\n\t}\n\tdiv.text-center \u003e div.overflow-hidden.text-ellipsis.whitespace-nowrap::-webkit-scrollbar {\n\t\tdisplay: none;\n\t}\n\n\t/* 5) 给手机的手势条留位置(没有 insets 的设备 env() 为 0,等于没写) */\n\t.pi-main {\n\t\tpadding-bottom: env(safe-area-inset-bottom) !important;\n\t}\n\n\t/* 6) 左上角的抽屉按钮。原来是个悬浮的圆角方块(12px/12px、34px),盖在内容上、\n\t 和右边那排图标也不在一条水平线上。按 dsh-remote 对 DSH 左侧的做法收成贴角圆形,\n\t 尺寸和位置改成与右侧图标完全一致(实测右侧图标是 top 12 / 30×30,中心 y=27),\n\t 这样整行对齐,按钮底边 42 也在头部分隔线(y=43)之上,不会压住那条线。 */\n\t.pi-mobile-menu {\n\t\twidth: 30px !important;\n\t\theight: 30px !important;\n\t\tborder-radius: 999px !important;\n\t\tleft: calc(env(safe-area-inset-left) + 12px) !important;\n\t\ttop: calc(env(safe-area-inset-top) + 12px) !important;\n\t}\n\t.pi-main \u003e .hairline-b {\n\t\tpadding-left: 52px !important;\n\t}\n\n\t/* 7) 右侧大纲导轨是绝对定位的(right-2 + 约 34px 宽的竖条),正文和用户气泡\n\t 会从它下面穿过去。不动条宽——那是层级信息——而是把导轨贴边收窄,\n\t 再给滚动区留出它实际占的宽度。 */\n\t.outline-rail {\n\t\tright: 2px !important;\n\t}\n\t.outline-rail \u003e div {\n\t\tpadding-left: 6px !important;\n\t\tpadding-right: 2px !important;\n\t}\n\t.pi-main div.min-h-0.flex-1.overflow-y-auto {\n\t\tpadding-right: 30px !important;\n\t}\n\n\t/* 8) 设置弹窗:高度用 100vh(移动端的大视口),内容列又没有 min-height:0\n\t 也没有 overflow-y,于是 1986px 的内容被 overflow:hidden 裁在 828px 里,\n\t 手机上完全滑不动。实测 client=828 / scroll=1986。 */\n\t.settings-dialog {\n\t\theight: calc(100vh - 16px) !important;\n\t}\n\t.settings-dialog \u003e div:not(.settings-nav) {\n\t\tmin-height: 0 !important;\n\t\toverflow-y: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n\n\t/* 9) 模型 / 思考等级浮层锚在胶囊自己的 relative 容器上,right-0 是**胶囊**右缘,\n\t 312px 往左展开就顶出屏幕左边(实测 left 为负)。把胶囊容器解除定位,\n\t 浮层改用整张输入卡(上一层 relative)做定位父级,right-0 落在卡片右缘就装得下。\n\t :has() 在移动端 Safari 15.4+ / Chrome 105+ 都可用。 */\n\tdiv.relative:has(\u003e button.chip[aria-haspopup=\"menu\"]) {\n\t\tposition: static !important;\n\t}\n\n\t/* 10) 设置→模型 的提供方卡片:那一行是 nowrap,中间的名字列被右侧一串按钮挤成\n\t 0 宽(实测 w=0),于是「凭据: auth.json」竖着拆行、「编辑」拆成「编/辑」、\n\t 右侧的按钮还被裁掉。手机上让这行能换行,并给名字列一个真实的最小宽度。 */\n\t.settings-dialog div.flex.items-center.gap-3:has(\u003e div.min-w-0.flex-1) {\n\t\tflex-wrap: wrap !important;\n\t\trow-gap: 8px !important;\n\t}\n\t.settings-dialog div.flex.items-center.gap-3:has(\u003e div.min-w-0.flex-1) \u003e div.min-w-0.flex-1 {\n\t\tflex: 1 1 140px !important;\n\t\tmin-width: 140px !important;\n\t}\n\n\t/* 11) 设置里 Row 那一套([文字列][控件列] 两栏)。控件列是 flex-none,\n\t 有的行右侧很宽(「开关 + 两个数字输入框」≈272px、「繁忙时 Enter」的下拉≈233px),\n\t 于是 390px 下文字列只剩 70px 左右,中文按每行一两字竖排,行高被撑到\n\t 122px / 218px(实测 70×122、69×218)。\n\t 试过的几种写法与实测结论:\n\t - 只给文字列 flex 增长权重:完全没用(那行没有空闲空间)\n\t - 只缩小 gap:只能缓解(70×122 → 86×98)\n\t - 让所有 Row 都上下排(flex:1 1 100%):能修好,但把本来正常的行也撑高\n\t (界面语言 53→85、字体步进 95→105),是密度回退\n\t - 正解:文字列 flex-basis 设 0、只留 min-width 当\"不许被压到多窄\"的下限。\n\t 换行判断用的是 flex-basis(max-content 会很大,所以上一版全换行了),\n\t 设成 0 之后判断就只看 min-width:\n\t 控件窄的行(界面语言 77px、字体步进 116px)→ 仍并排,保持紧凑\n\t 控件宽的行(272px / 233px)→ 控件换到下一行,文字拿满 326px\n\t 实测:界面语言 53 不变、界面字体 95→71、繁忙时Enter 239→135、\n\t 自动压缩 143→111、自动重试 119→111,全部不再竖排。 */\n\t.settings-dialog div.hairline-b.gap-6 {\n\t\tflex-wrap: wrap !important;\n\t\tgap: 8px !important;\n\t}\n\t.settings-dialog div.hairline-b.gap-6 \u003e div.min-w-0 {\n\t\tflex: 1 1 0 !important;\n\t\tmin-width: 45% !important;\n\t}\n\t.settings-dialog div.hairline-b.gap-6 \u003e div.flex-none \u003e div.flex.items-center {\n\t\tflex-wrap: wrap !important;\n\t\tjustify-content: flex-end !important;\n\t\trow-gap: 6px !important;\n\t}\n\n\t/* 12) 外观主题卡片。卡片是 flex: 1 1 150px 的两列网格,但左右各留 32px 内边距,\n\t 159px 的卡片只剩 95px 给标题,于是「石灰与煤炭」「亚麻与苔绿」掉到第二行,\n\t 卡片高度变成 110px 而其余是 88px,网格参差(实测 6 张卡:4 张 88 / 2 张 110)。\n\t 把左右内边距收到 10px,6 张卡全部回到 88px,标题一行放得下。 */\n\t.settings-dialog button[aria-pressed] {\n\t\tpadding-left: 10px !important;\n\t\tpadding-right: 10px !important;\n\t}\n\n\t/* 13) 可点目标偏小(实测:字体步进器 26×26、reset 48×24、开关 36×20)。\n\t 只扩**热区**,绝不改可见尺寸:开关的滑块位置在 PiWeb 里是写死的\n\t (left: 18px / 2px),把开关拉宽会让滑块错位、变成被拉长的怪药丸\n\t —— 我第一版写的 min-width:44px 就是这个后果,用户报了\"启动按钮好丑\"。\n\t 另外这里只做**纵向**扩张(左右不扩):字体步进器 −/+/ 之间只有 8px,\n\t 横向扩到 44px 会让两个按钮的热区重叠约 10px,点 − 反而命中 +。 */\n\t.settings-dialog .icon-btn,\n\t.settings-dialog button.h-6,\n\t.settings-dialog button.h-5,\n\t.settings-dialog [role=\"switch\"] {\n\t\tposition: relative !important;\n\t}\n\t.settings-dialog .icon-btn::after,\n\t.settings-dialog button.h-6::after,\n\t.settings-dialog button.h-5::after,\n\t.settings-dialog [role=\"switch\"]::after {\n\t\tcontent: \"\" !important;\n\t\tposition: absolute !important;\n\t\tleft: 0 !important;\n\t\tright: 0 !important;\n\t\ttop: 50% !important;\n\t\theight: max(100%, 44px) !important;\n\t\ttransform: translateY(-50%) !important;\n\t\tborder-radius: 999px !important;\n\t}\n}\n\n/* 手机上的浮层统一兜底:不许比屏幕宽,不许比屏幕高,超出就自己滚。 */\n@media (max-width: 840px) {\n\t.popover {\n\t\tmax-width: calc(100vw - 16px) !important;\n\t\tmax-height: 420px !important;\n\t\toverflow-y: auto !important;\n\t\t-webkit-overflow-scrolling: touch;\n\t}\n}\n\n/* 上下文弹窗(圆环)额外收紧:它右对齐在圆环上,而圆环离屏幕右缘还有 60-70px\n (主输入框 ~71px、旁聊面板 ~63px)。上面「统一兜底」的 100vw-16px 在 360px 以下\n 的机型仍会把左边顶出屏幕(实测 344px → left −15px、320px → left −39px)。\n 这里按视口留出 96px:主输入框与旁聊面板实测 left ≥ 25px,且不越出旁聊面板左缘。 */\n@media (max-width: 840px) {\n\t.popover.pw-context-popover {\n\t\tmax-width: calc(100vw - 96px) !important;\n\t}\n}\n\n/* 11) 模型名太长时跑马灯。取值与做法照搬 dsh-remote 对 DSH 的:\n\t 名字窗口固定 28vw 并允许被压缩,装得下就完全不动;装不下时由注入的\n\t mobile-fit.js 打上 data-piweb-marquee,再由这里的两份伪元素首尾相接匀速左移。\n\t 一份走完另一份正好接上(travel 已含间隔),所以循环处看不出接缝。 */\n@media (max-width: 840px) {\n\tbutton.chip[aria-haspopup=\"menu\"] \u003e span.truncate {\n\t\tmin-width: 0 !important;\n\t\tmax-width: 28vw !important;\n\t}\n\t[data-piweb-marquee] {\n\t\tposition: relative !important;\n\t\tdisplay: block !important;\n\t\twidth: 28vw !important;\n\t\tflex: none !important;\n\t\ttext-overflow: clip !important;\n\t\twhite-space: nowrap !important;\n\t\toverflow: hidden !important;\n\t\ttext-indent: 0 !important;\n\t\tcontain: layout paint !important;\n\t\tcolor: transparent !important;\n\t\tline-height: 20px !important;\n\t}\n\t[data-piweb-marquee]::before,\n\t[data-piweb-marquee]::after {\n\t\tcontent: attr(data-piweb-text);\n\t\tposition: absolute;\n\t\ttop: 0;\n\t\tleft: 0;\n\t\tcolor: var(--piweb-marquee-color, var(--dsw-label-primary));\n\t\tfont-size: 13px;\n\t\tline-height: 20px;\n\t\twhite-space: nowrap;\n\t\twill-change: transform;\n\t\ttransform: translate3d(0, 0, 0);\n\t\tanimation: piweb-mfit-marquee var(--piweb-marquee-duration, 8s) linear infinite both;\n\t}\n\t[data-piweb-marquee]::after {\n\t\tleft: var(--piweb-marquee-text, 0px);\n\t}\n\t@keyframes piweb-mfit-marquee {\n\t\tfrom { transform: translate3d(0, 0, 0); }\n\t\tto { transform: translate3d(calc(-1 * var(--piweb-marquee-text, 0px)), 0, 0); }\n\t}\n}\n\n/* dvh 是\"实际可见高度\",比 100vh(移动 Chrome 的大视口)可靠;\n 不支持时上面那两条固定值兜底。 */\n@supports (height: 100dvh) {\n\t@media (max-width: 840px) {\n\t\ttextarea {\n\t\t\tmax-height: min(132px, 26dvh) !important;\n\t\t}\n\t\t.popover {\n\t\t\tmax-height: min(60dvh, 420px) !important;\n\t\t}\n\t\t.settings-dialog {\n\t\t\theight: calc(100dvh - 16px) !important;\n\t\t}\n\t}\n}\n",
|
|
3
3
|
"js": "/*\n * 手机端脚本(配合 mobile-fit.css)。\n *\n * 做两件事:\n * 1) 模型名太长时改成「跑马灯」。做法照搬 dsh-remote 对 DSH 的处理——\n * 名字窗口固定 28vw,装得下就完全不动;装不下时才把两份文本首尾相接匀速左移,\n * 一份走完另一份正好接上,循环没有空档。CSS 负责动画,这里只负责\"量\"和\"打标记\"。\n * 为什么必须用 JS 量:纯 CSS 判断不了\"是否真的被截断\",只能用省略号一裁了之,\n * 名字的尾巴就永远看不到了。\n * 2) 设置里「文字 + 开关」那种行,手机上点文字也能切开关。\n * 手机断点下这行是上下排(见 mobile-fit.css 第 11 条),文字占满一行、开关缩在下面,\n * 而 PiWeb 只让那个 36×20 的小开关可点 —— 用户点最显眼的标题文字毫无反应,\n * 看起来就是\"这个开关坏了\"。实测:点文字 0 次请求,点开关才发请求。\n */\n(function () {\n\t\"use strict\";\n\n\tvar ATTR = \"data-piweb-marquee\";\n\tvar TEXT_ATTR = \"data-piweb-text\";\n\tvar TEXT_VAR = \"--piweb-marquee-text\";\n\tvar DURATION_VAR = \"--piweb-marquee-duration\";\n\tvar COLOR_VAR = \"--piweb-marquee-color\";\n\tvar /** 两份文本之间留的间隔,也是 travel 里多出来的那一段 */\n\t\tGAP = 40;\n\tvar /** 每秒移动多少像素,太长就慢一点 */\n\t\tSPEED = 22;\n\tvar MIN_CYCLE = 6;\n\tvar ROW_BOUND_ATTR = \"data-piweb-row-bound\";\n\n\t/** 模型胶囊里的名字(ModelSelector 里那个 span.truncate) */\n\tfunction label() {\n\t\treturn document.querySelector(\u0027button.chip[aria-haspopup=\"menu\"] \u003e span.truncate\u0027);\n\t}\n\n\tfunction apply() {\n\t\tvar el = label();\n\t\tif (!el) return;\n\t\tvar text = el.textContent || \"\";\n\t\tif (!text) return;\n\n\t\t// 同一个名字已经标过就别再动它:重设属性会把 CSS 动画从头开始,看起来是\"一直重启\"\n\t\tif (el.hasAttribute(ATTR) \u0026\u0026 el.getAttribute(TEXT_ATTR) === text) return;\n\n\t\t// 量必须在\"静止态\"下做:先摘掉标记,否则量到的是被 28vw 框死之后的宽度\n\t\tif (el.hasAttribute(ATTR)) el.removeAttribute(ATTR);\n\t\tvar natural = el.scrollWidth;\n\t\tvar windowWidth = el.clientWidth;\n\t\tif (!windowWidth || natural \u003c= windowWidth + 1) return; // 装得下,什么都不做\n\n\t\tel.style.setProperty(COLOR_VAR, getComputedStyle(el).color);\n\t\tel.style.setProperty(TEXT_VAR, natural + GAP + \"px\");\n\t\tel.style.setProperty(DURATION_VAR, Math.max(MIN_CYCLE, (natural + GAP) / SPEED).toFixed(1) + \"s\");\n\t\tel.setAttribute(TEXT_ATTR, text);\n\t\tel.setAttribute(ATTR, \"\");\n\t}\n\n\t/**\n\t * 设置里「文字 + 开关」的行:把文字列变成开关的\"大号按钮\"。\n\t *\n\t * 只绑文字列(Row 里那个 min-w-0 的直系子元素),不碰控件列:\n\t * 控件列里有数字输入框,整行可点会把\"点输入框\"也变成切开关。\n\t * React 重渲染会换掉节点,所以每次轮询都重查;用属性做幂等标记,重复绑不会叠加。\n\t */\n\tfunction bindSwitchRows() {\n\t\tvar switches = document.querySelectorAll(\u0027.settings-dialog [role=\"switch\"]\u0027);\n\t\tfor (var i = 0; i \u003c switches.length; i++) {\n\t\t\tvar sw = switches[i];\n\t\t\tvar row = sw.closest(\".hairline-b\");\n\t\t\tif (!row || row.getAttribute(ROW_BOUND_ATTR) === \"1\") continue;\n\t\t\t// Row 的文字列:hairline-b 的直系子元素里带 min-w-0 的那个\n\t\t\tvar label = null;\n\t\t\tfor (var c = 0; c \u003c row.children.length; c += 1) {\n\t\t\t\tif (row.children[c].classList.contains(\"min-w-0\")) { label = row.children[c]; break; }\n\t\t\t}\n\t\t\tif (!label) continue;\n\t\t\trow.setAttribute(ROW_BOUND_ATTR, \"1\");\n\t\t\t(function (button, labelEl) {\n\t\t\t\tlabelEl.style.cursor = \"pointer\";\n\t\t\t\tlabelEl.addEventListener(\"click\", function (event) {\n\t\t\t\t\t// 文字里可能有链接/按钮,别抢它们\n\t\t\t\t\tif (event.target.closest(\"a, button, input, select, textarea\")) return;\n\t\t\t\t\tif (button.disabled) return;\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tbutton.click();\n\t\t\t\t});\n\t\t\t})(sw, label);\n\t\t}\n\t}\n\n\t// 模型名会随会话/选择变化,输入卡也会整体重挂载;轮询最省事,且因为有上面的\n\t// 提前返回,稳态下每次只花一个 querySelector。\n\tfunction start() {\n\t\tapply();\n\t\tbindSwitchRows();\n\t\tsetInterval(function () { apply(); bindSwitchRows(); }, 800);\n\t\twindow.addEventListener(\"resize\", apply, { passive: true });\n\t\twindow.addEventListener(\"orientationchange\", apply, { passive: true });\n\t}\n\n\tif (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start, { once: true });\n\telse start();\n})();"
|
|
4
4
|
}
|
|
@@ -99,6 +99,8 @@ export interface LanState {
|
|
|
99
99
|
running: boolean;
|
|
100
100
|
port: number | null;
|
|
101
101
|
urls: string[];
|
|
102
|
+
/** 这一个监听是否在注入手机排版。插件关掉时为 false(纯转发),端口与可达性不变 */
|
|
103
|
+
styled: boolean;
|
|
102
104
|
error: string | null;
|
|
103
105
|
}
|
|
104
106
|
|
|
@@ -118,6 +120,8 @@ export type RemoteBlocked = "auth" | "dev";
|
|
|
118
120
|
|
|
119
121
|
export interface RemoteStatus {
|
|
120
122
|
plugin: typeof PLUGIN_ID;
|
|
123
|
+
/** 内置插件是否启用。关掉后局域网入口仍在(纯转发),但没有手机排版与公网入口 */
|
|
124
|
+
pluginEnabled: boolean;
|
|
121
125
|
/** PiWeb 自己的端口(局域网入口的转发目标) */
|
|
122
126
|
targetPort: number;
|
|
123
127
|
/** 非空时两个入口都不许开,UI 应置灰按钮并说明原因 */
|
|
@@ -127,7 +131,7 @@ export interface RemoteStatus {
|
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
interface SupervisorState {
|
|
130
|
-
lan: { server: http.Server; port: number } | null;
|
|
134
|
+
lan: { server: http.Server; port: number; styled: boolean } | null;
|
|
131
135
|
tunnel: { child: ChildProcess; url: string | null; ready: boolean } | null;
|
|
132
136
|
/** 正在启动中的那一次:并发调用复用它,不能起两个监听 / 两个 cloudflared */
|
|
133
137
|
lanStarting?: Promise<LanState> | null;
|
|
@@ -250,12 +254,14 @@ export function remoteStatus(): RemoteStatus {
|
|
|
250
254
|
const lanPort = current.lan?.port ?? null;
|
|
251
255
|
return {
|
|
252
256
|
plugin: PLUGIN_ID,
|
|
257
|
+
pluginEnabled: remotePluginEnabled(),
|
|
253
258
|
targetPort: piwebPort(),
|
|
254
259
|
blocked: remoteBlocked(),
|
|
255
260
|
lan: {
|
|
256
261
|
running: Boolean(current.lan),
|
|
257
262
|
port: lanPort,
|
|
258
263
|
urls: lanPort ? lanAddresses().map((address) => `http://${address}:${lanPort}`) : [],
|
|
264
|
+
styled: Boolean(current.lan?.styled),
|
|
259
265
|
error: current.lanError,
|
|
260
266
|
},
|
|
261
267
|
public: {
|
|
@@ -308,7 +314,42 @@ export function markEngineMissing(): void {
|
|
|
308
314
|
current.enginePath = null;
|
|
309
315
|
}
|
|
310
316
|
|
|
311
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* 内置插件是否启用。关闭后**局域网入口照旧可用**,只是不再注入手机排版、也不提供公网隧道——
|
|
319
|
+
* 这样用户可以用自己写的插件或自建反代接手远程访问,而手机在同一个 WiFi 下仍然连得上。
|
|
320
|
+
*
|
|
321
|
+
* 存 `<agentDir>/remote-plugin.json`,与 web-secret 同一目录。读不到就按启用处理(默认开)。
|
|
322
|
+
*/
|
|
323
|
+
function pluginFile(): string {
|
|
324
|
+
return path.join(agentDir(), "remote-plugin.json");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function remotePluginEnabled(): boolean {
|
|
328
|
+
try {
|
|
329
|
+
const raw = JSON.parse(fs.readFileSync(pluginFile(), "utf8")) as { enabled?: unknown };
|
|
330
|
+
return raw?.enabled !== false;
|
|
331
|
+
} catch {
|
|
332
|
+
// 没写过、或文件坏了:按默认(启用)处理,不让一次读盘失败把功能关掉
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function setRemotePluginEnabled(enabled: boolean): void {
|
|
338
|
+
const file = pluginFile();
|
|
339
|
+
try {
|
|
340
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
341
|
+
fs.writeFileSync(file, `${JSON.stringify({ enabled }, null, 2)}\n`, "utf8");
|
|
342
|
+
} catch (error) {
|
|
343
|
+
throw new Error(`无法保存远程连接插件开关:${error instanceof Error ? error.message : error}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 起局域网入口;已经在跑(或正在起)就复用。targetPort 默认转发回 PiWeb 自己,测试可改成别的上游。
|
|
349
|
+
*
|
|
350
|
+
* 插件启用时是「手机排版注入代理」;关闭时同一个端口退化成**纯转发**(不注入任何东西),
|
|
351
|
+
* 这样自建反代指向 30142 的部署不会因为关掉插件而失效——用户要的是「插件可关、局域网照旧」。
|
|
352
|
+
*/
|
|
312
353
|
export function startLan(port = remoteLanPort(), host = "0.0.0.0", targetPort = piwebPort()): Promise<LanState> {
|
|
313
354
|
const current = state();
|
|
314
355
|
if (current.lan) return Promise.resolve(remoteStatus().lan);
|
|
@@ -323,10 +364,12 @@ export function startLan(port = remoteLanPort(), host = "0.0.0.0", targetPort =
|
|
|
323
364
|
async function listenLan(port: number, host: string, targetPort: number): Promise<LanState> {
|
|
324
365
|
const current = state();
|
|
325
366
|
current.lanError = null;
|
|
367
|
+
const styled = remotePluginEnabled();
|
|
326
368
|
const server = createMobileProxy({
|
|
327
369
|
targetPort,
|
|
328
|
-
|
|
329
|
-
|
|
370
|
+
// 关掉插件就不注入:仍是同一个端口、同一套转发,只是页面上不再有手机排版补丁
|
|
371
|
+
css: styled ? MOBILE_FIT_CSS : null,
|
|
372
|
+
js: styled ? MOBILE_FIT_JS : null,
|
|
330
373
|
log: (message) => console.warn(message),
|
|
331
374
|
});
|
|
332
375
|
try {
|
|
@@ -339,7 +382,7 @@ async function listenLan(port: number, host: string, targetPort: number): Promis
|
|
|
339
382
|
server.close();
|
|
340
383
|
return remoteStatus().lan;
|
|
341
384
|
}
|
|
342
|
-
current.lan = { server, port };
|
|
385
|
+
current.lan = { server, port, styled };
|
|
343
386
|
return remoteStatus().lan;
|
|
344
387
|
}
|
|
345
388
|
|
|
@@ -9,6 +9,7 @@ import { invalidateJsonFile, readJsonObject } from "./agent-config";
|
|
|
9
9
|
import { getAgentDir, getSettingsManager, reloadAllLoaders, reloadSettingsManagers, syncProjectTrust } from "./pi";
|
|
10
10
|
import { reloadSessionsForCwd } from "./agent-manager";
|
|
11
11
|
import { flushSettingsOrThrow, withSettingsWriteLock } from "./settings-write-lock";
|
|
12
|
+
import { APP_ROOT } from "./app-root";
|
|
12
13
|
|
|
13
14
|
export type ProjectTrust = "ask" | "always" | "never";
|
|
14
15
|
|
|
@@ -23,7 +24,7 @@ export async function setProjectTrust(value: ProjectTrust): Promise<void> {
|
|
|
23
24
|
if (value !== "ask" && value !== "always" && value !== "never") throw new Error("invalid defaultProjectTrust");
|
|
24
25
|
await withSettingsWriteLock(path.join(getAgentDir(), "settings.json"), async () => {
|
|
25
26
|
await getSecurity(); // ENOENT only; malformed or unreadable settings must fail closed.
|
|
26
|
-
const manager = getSettingsManager(
|
|
27
|
+
const manager = getSettingsManager(APP_ROOT);
|
|
27
28
|
await flushSettingsOrThrow(manager);
|
|
28
29
|
await manager.reload();
|
|
29
30
|
await flushSettingsOrThrow(manager);
|
|
@@ -7,9 +7,10 @@ import path from "node:path";
|
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { SessionSummary, WebMessage } from "./types";
|
|
10
|
-
import {
|
|
10
|
+
import { sessionDisplayMessages, syncSessionsFromDisk } from "./agent-manager";
|
|
11
11
|
import { getAgentDir } from "./pi";
|
|
12
12
|
import { BoundaryError, isPathInside } from "./path-security";
|
|
13
|
+
import { promptTitleText } from "./chat/skill-expansion";
|
|
13
14
|
|
|
14
15
|
function toSummary(info: {
|
|
15
16
|
path: string;
|
|
@@ -31,6 +32,11 @@ function toSummary(info: {
|
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** Date 能表示的毫秒数(±8.64e15):超出范围的 toISOString 会抛 RangeError */
|
|
36
|
+
function isValidTime(ms: number): boolean {
|
|
37
|
+
return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
/** firstMessage 只当侧栏标题与搜索用:整段贴进来的长提问不该随每 3 秒一次的列表轮询反复下发 */
|
|
35
41
|
export const FIRST_MESSAGE_MAX_CHARS = 300;
|
|
36
42
|
|
|
@@ -76,22 +82,25 @@ async function summarize(filePath: string, stat: { mtime: Date }): Promise<Sessi
|
|
|
76
82
|
if (!message || typeof message.role !== "string" || !("content" in message)) continue;
|
|
77
83
|
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
78
84
|
const at = typeof message.timestamp === "number" ? message.timestamp : new Date(entry.timestamp).getTime();
|
|
79
|
-
if (
|
|
85
|
+
if (isValidTime(at)) lastActivity = Math.max(lastActivity ?? 0, at);
|
|
80
86
|
if (!firstMessage && message.role === "user") {
|
|
81
87
|
const content = message.content;
|
|
82
|
-
firstMessage = typeof content === "string"
|
|
88
|
+
firstMessage = promptTitleText(typeof content === "string"
|
|
83
89
|
? content
|
|
84
|
-
: Array.isArray(content) ? content.filter((b: any) => b?.type === "text").map((b: any) => b.text).join(" ") : "";
|
|
90
|
+
: Array.isArray(content) ? content.filter((b: any) => b?.type === "text").map((b: any) => b.text).join(" ") : "");
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
if (!header) return null;
|
|
88
94
|
const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
|
|
89
|
-
const
|
|
95
|
+
const created = isValidTime(headerTime) ? new Date(headerTime) : stat.mtime;
|
|
96
|
+
const modified = lastActivity && lastActivity > 0 ? new Date(lastActivity) : created;
|
|
90
97
|
return toSummary({
|
|
91
98
|
path: filePath,
|
|
92
99
|
name,
|
|
93
100
|
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
94
|
-
|
|
101
|
+
// 头里缺 timestamp 或写坏了:回落 mtime。以前直接 new Date(String(...)),Invalid Date 的 toISOString 一抛,
|
|
102
|
+
// 整个会话就被 catch 吞成 null,从侧栏里悄悄消失
|
|
103
|
+
created,
|
|
95
104
|
modified,
|
|
96
105
|
messageCount,
|
|
97
106
|
firstMessage: clipFirstMessage(firstMessage) || "(no messages)",
|
|
@@ -118,8 +127,12 @@ interface CachedSummary {
|
|
|
118
127
|
* 按 mtime + 大小校验,命中就沿用摘要——侧栏首次渲染从"全量解析"变成"读一个小 JSON"。
|
|
119
128
|
* 索引只是加速:读不到、版本不符、写不进去都退化成全量扫描,绝不影响正确性。
|
|
120
129
|
*/
|
|
121
|
-
/**
|
|
122
|
-
|
|
130
|
+
/**
|
|
131
|
+
* 2:firstMessage 开始截断,旧索引里的整段原文要重新摘要
|
|
132
|
+
* 3:技能展开的首条消息还原成 `/skill:name 参数`,旧索引里存的是 `<skill name=…>` 加 SKILL.md 正文
|
|
133
|
+
* 4:头时间戳坏掉的会话不再摘要成 null,旧索引里缓存的 null 要重读才能回到侧栏
|
|
134
|
+
*/
|
|
135
|
+
const INDEX_VERSION = 4;
|
|
123
136
|
const INDEX_FILENAME = "piweb-session-index.json";
|
|
124
137
|
const INDEX_MAX_ENTRIES = 5000;
|
|
125
138
|
const INDEX_SAVE_DELAY_MS = 1500;
|
|
@@ -246,6 +259,8 @@ export async function listSessions(query?: string): Promise<SessionSummary[]> {
|
|
|
246
259
|
scanning ??= scanSessions().finally(() => { scanning = null; });
|
|
247
260
|
list = await scanning;
|
|
248
261
|
listCache = { at: Date.now(), list };
|
|
262
|
+
// 顺带看看开着的会话有没有被终端 pi 写过(见 agent-manager syncWithDisk)
|
|
263
|
+
syncSessionsFromDisk();
|
|
249
264
|
}
|
|
250
265
|
if (query?.trim()) {
|
|
251
266
|
const q = query.trim().toLowerCase();
|
|
@@ -281,7 +296,7 @@ export async function deleteSession(sessionPath: string): Promise<void> {
|
|
|
281
296
|
}
|
|
282
297
|
}
|
|
283
298
|
|
|
284
|
-
/**
|
|
299
|
+
/** 冷读取:还原消息(含被压缩掉的历史)+ 会话名(不启动 agent) */
|
|
285
300
|
export async function readSession(sessionPath: string): Promise<{
|
|
286
301
|
cwd: string;
|
|
287
302
|
name: string;
|
|
@@ -290,10 +305,7 @@ export async function readSession(sessionPath: string): Promise<{
|
|
|
290
305
|
const sm = SessionManager.open(sessionPath);
|
|
291
306
|
let messages: WebMessage[] = [];
|
|
292
307
|
try {
|
|
293
|
-
|
|
294
|
-
messages = entries
|
|
295
|
-
.filter((e: any) => e.type === "message" && e.message)
|
|
296
|
-
.map((e: any) => toWebMessage(e.message, e.id));
|
|
308
|
+
messages = sessionDisplayMessages(sm);
|
|
297
309
|
} catch {
|
|
298
310
|
messages = [];
|
|
299
311
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 归档会话保留期:从归档那一刻起算满 N 天就自动删除;归档后又用过的,从最后一次活动(文件 mtime)起算。
|
|
3
|
+
* 以前只看 mtime,于是把一个很久没动的旧会话归档,几小时内就会被直接删掉。
|
|
3
4
|
*
|
|
4
5
|
* 只会碰「在归档列表里」的会话:没归档的会话即使很久没动也不会被动。
|
|
5
6
|
* 正在被本进程管理的会话(打开中/运行中)一律跳过——交给它自己空闲回收。
|
|
@@ -7,15 +8,16 @@
|
|
|
7
8
|
*/
|
|
8
9
|
import fs from "node:fs/promises";
|
|
9
10
|
import path from "node:path";
|
|
10
|
-
import { forgetSession,
|
|
11
|
+
import { forgetSession, getArchivedEntries } from "./workspace-store";
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
+
/** 归档会话保留多久(天);侧栏「已归档」的提示文案(i18n archiveRetentionHint)写的是同一个数 */
|
|
13
14
|
export const ARCHIVED_RETENTION_DAYS = 30;
|
|
14
15
|
/** 两次清理之间的间隔 */
|
|
15
16
|
export const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
16
17
|
|
|
17
18
|
export interface RetentionReport {
|
|
18
19
|
scanned: number;
|
|
20
|
+
/** idleDays:距归档或最后一次活动(取较晚者)的天数 */
|
|
19
21
|
deleted: { path: string; idleDays: number }[];
|
|
20
22
|
/** 文件已不在磁盘上:只清掉归档列表里的记录 */
|
|
21
23
|
missing: string[];
|
|
@@ -44,9 +46,9 @@ export async function sweepArchivedSessions({
|
|
|
44
46
|
const report: RetentionReport = { scanned: 0, deleted: [], missing: [], skippedActive: [], kept: 0 };
|
|
45
47
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return report;
|
|
46
48
|
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
|
47
|
-
const
|
|
49
|
+
const entries = await getArchivedEntries({ now, persist: !dryRun });
|
|
48
50
|
|
|
49
|
-
for (const sessionPath of
|
|
51
|
+
for (const { path: sessionPath, archivedAt } of entries) {
|
|
50
52
|
report.scanned += 1;
|
|
51
53
|
const target = path.resolve(sessionPath);
|
|
52
54
|
if (isActive(target)) {
|
|
@@ -66,18 +68,19 @@ export async function sweepArchivedSessions({
|
|
|
66
68
|
if (!dryRun) await forgetSession(target);
|
|
67
69
|
continue;
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
const since = Math.max(archivedAt, mtimeMs);
|
|
72
|
+
if (since > cutoff) {
|
|
70
73
|
report.kept += 1;
|
|
71
74
|
continue;
|
|
72
75
|
}
|
|
73
|
-
const idleDays = Math.floor((now -
|
|
76
|
+
const idleDays = Math.floor((now - since) / (24 * 60 * 60 * 1000));
|
|
74
77
|
report.deleted.push({ path: target, idleDays });
|
|
75
78
|
if (dryRun) continue;
|
|
76
79
|
// 运行时再取:session-reader → agent-manager → 本模块 构成环,静态导入会在初始化期踩空
|
|
77
80
|
const { deleteSession } = await import("./session-reader");
|
|
78
81
|
await deleteSession(target);
|
|
79
82
|
await forgetSession(target);
|
|
80
|
-
log(`[piweb]
|
|
83
|
+
log(`[piweb] 归档会话已满 ${retentionDays} 天(归档后 ${idleDays} 天没有活动),已删除:${target}`);
|
|
81
84
|
}
|
|
82
85
|
return report;
|
|
83
86
|
}
|
|
@@ -8,6 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import { getAgentDir, getResourceLoader, reloadAllLoaders, resourceLoaderReady } from "./pi";
|
|
9
9
|
import { reloadSessionsForCwd } from "./agent-manager";
|
|
10
10
|
import { resolveDiscoveredPath } from "./path-security";
|
|
11
|
+
import { APP_ROOT } from "./app-root";
|
|
11
12
|
|
|
12
13
|
export interface SkillView {
|
|
13
14
|
name: string;
|
|
@@ -67,7 +68,7 @@ async function classifyScope(filePath: string, context: ScopeContext): Promise<S
|
|
|
67
68
|
export async function listSkills(cwd?: string): Promise<SkillView[]> {
|
|
68
69
|
const seen = new Set<string>();
|
|
69
70
|
const out: SkillView[] = [];
|
|
70
|
-
const targets: string[] = cwd ? [path.resolve(cwd)] : [
|
|
71
|
+
const targets: string[] = cwd ? [path.resolve(cwd)] : [APP_ROOT];
|
|
71
72
|
for (const dir of targets) {
|
|
72
73
|
await resourceLoaderReady(dir);
|
|
73
74
|
const loader = getResourceLoader(dir);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 光标处的斜杠命令:在开头或空白之后才算,路径和 URL 不算。
|
|
2
|
+
* 采纳时替换整段 token(光标停在中间也一样),前后的正文原样保留。
|
|
3
|
+
*/
|
|
4
|
+
export function slashCommandToken(text: string, caret: number | null): { start: number; tokenEnd: number; query: string } | null {
|
|
5
|
+
if (caret === null || caret < 0 || caret > text.length) return null;
|
|
6
|
+
const match = /(?:^|\s)\/([^\s/]*)$/.exec(text.slice(0, caret));
|
|
7
|
+
if (!match) return null;
|
|
8
|
+
const query = match[1];
|
|
9
|
+
const start = caret - query.length - 1;
|
|
10
|
+
let tokenEnd = caret;
|
|
11
|
+
while (tokenEnd < text.length && !/\s/.test(text[tokenEnd])) tokenEnd += 1;
|
|
12
|
+
if (text.slice(start + 1, tokenEnd).includes("/")) return null;
|
|
13
|
+
return { start, tokenEnd, query };
|
|
14
|
+
}
|
package/src/lib/trajectory.ts
CHANGED
|
@@ -33,6 +33,83 @@ function textOf(content: any): string {
|
|
|
33
33
|
return "";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** 工具参数里最像“这一行在干什么”的那个字段,给侧栏“最后一步”用 */
|
|
37
|
+
const SUMMARY_KEYS = ["command", "cmd", "script", "file_path", "path", "pattern", "query", "url"];
|
|
38
|
+
|
|
39
|
+
/** 工具参数 JSON 的上限:write 的 content 动辄整份文件,账本只保留可读的头部 */
|
|
40
|
+
const TOOL_ARGS_MAX_CHARS = 8_000;
|
|
41
|
+
/** 工具最终输出的上限:与 SSE 帧的 coercePartial 同量级,inspector 看内容也够用 */
|
|
42
|
+
const TOOL_RESULT_MAX_CHARS = 8_000;
|
|
43
|
+
/** assistant 正文 / 思考全文的上限:这些只是轨迹副本,会话 JSONL 里另有权威全文 */
|
|
44
|
+
const MESSAGE_TEXT_MAX_CHARS = 100_000;
|
|
45
|
+
|
|
46
|
+
function clipText(value: string, maxChars: number): string {
|
|
47
|
+
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}…`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 参数对象 → 侧栏摘要(先把每个字符串值截短再挑可读字段;预计算一次存进条目) */
|
|
51
|
+
function summarizeArgs(args: unknown): string {
|
|
52
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return "";
|
|
53
|
+
try {
|
|
54
|
+
const shallow = Object.fromEntries(
|
|
55
|
+
Object.entries(args as Record<string, unknown>).map(([k, v]) => [k, typeof v === "string" ? v.slice(0, 100) : v]),
|
|
56
|
+
);
|
|
57
|
+
const parsed = JSON.parse(JSON.stringify(shallow));
|
|
58
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
59
|
+
for (const key of SUMMARY_KEYS) {
|
|
60
|
+
const value = (parsed as Record<string, unknown>)[key];
|
|
61
|
+
if (typeof value === "string" && value.trim()) return value.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* 序列化失败(超大/循环引用)就当没有摘要 */
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 参数对象 → 供 inspector 展示的 JSON 文本;超长的字符串值先截断再序列化 */
|
|
70
|
+
function argsToDetail(args: unknown): string {
|
|
71
|
+
const clip = (value: unknown): unknown =>
|
|
72
|
+
typeof value === "string" && value.length > 2_000 ? `${value.slice(0, 2_000)}…` : value;
|
|
73
|
+
const shallow =
|
|
74
|
+
args && typeof args === "object" && !Array.isArray(args)
|
|
75
|
+
? Object.fromEntries(Object.entries(args as Record<string, unknown>).map(([k, v]) => [k, clip(v)]))
|
|
76
|
+
: args;
|
|
77
|
+
try {
|
|
78
|
+
return clipText(JSON.stringify(shallow ?? {}, null, 2), TOOL_ARGS_MAX_CHARS);
|
|
79
|
+
} catch {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 侧栏「最后一步」的正文:优先真实输出(preview),没有就用事件到达时预计算的参数摘要,
|
|
86
|
+
* 最后才是这行本来就有的 title。
|
|
87
|
+
* 不能回落到 title —— 工具条目的 title 就是工具名(`trajectory.ts` 里 `title` / `tool_execution_start`),
|
|
88
|
+
* 而 preview 只在工具返回文本时才写,于是 `bash · bash` 这种重复会一直显示到结果回来。
|
|
89
|
+
*/
|
|
90
|
+
export function toolStepPreview(entry: TrajEntry): string {
|
|
91
|
+
const firstLine = (value: string | undefined) => (value ?? "").split("\n")[0].trim();
|
|
92
|
+
const fromResult = firstLine(entry.preview);
|
|
93
|
+
if (fromResult) return fromResult.slice(0, 80);
|
|
94
|
+
// 新条目带 argSummary(事件到达时预计算);旧快照没有则退回解析 detail 的老路径
|
|
95
|
+
if (entry.argSummary) return entry.argSummary.slice(0, 80);
|
|
96
|
+
let args: Record<string, unknown> | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(entry.detail ?? "");
|
|
99
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) args = parsed as Record<string, unknown>;
|
|
100
|
+
} catch {
|
|
101
|
+
/* detail 可能是「args JSON + --- + 输出」的拼接,解析不了就当没有 */
|
|
102
|
+
}
|
|
103
|
+
if (args) {
|
|
104
|
+
for (const key of SUMMARY_KEYS) {
|
|
105
|
+
const value = args[key];
|
|
106
|
+
if (typeof value === "string" && value.trim()) return value.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 参数里没有可读字段:保持只有工具名,不重复拼一遍
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
36
113
|
/** 活跃会话的账本:消费 pi 事件流,维护 cell 列表与计时 */
|
|
37
114
|
export class TrajLedger {
|
|
38
115
|
entries: TrajEntry[];
|
|
@@ -137,10 +214,11 @@ export class TrajLedger {
|
|
|
137
214
|
const durationMs = storedDuration(m.durationMs);
|
|
138
215
|
if (durationMs !== undefined) e.timing = { ...e.timing, durationMs };
|
|
139
216
|
e.tokens = toTrajTokens(m.usage);
|
|
140
|
-
e.thinking =
|
|
141
|
-
(m.content ?? []).filter((c: any) => c.type === "thinking").map((c: any) => c.thinking),
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
e.thinking = clipText(
|
|
218
|
+
textOf((m.content ?? []).filter((c: any) => c.type === "thinking").map((c: any) => c.thinking)),
|
|
219
|
+
MESSAGE_TEXT_MAX_CHARS,
|
|
220
|
+
) || undefined;
|
|
221
|
+
e.detail = clipText(textOf((m.content ?? []).filter((c: any) => c.type === "text")), MESSAGE_TEXT_MAX_CHARS) || undefined;
|
|
144
222
|
e.isError = m.stopReason === "error";
|
|
145
223
|
if (m.stopReason === "error") e.title = "error";
|
|
146
224
|
this.touch(e);
|
|
@@ -155,7 +233,8 @@ export class TrajLedger {
|
|
|
155
233
|
toolName: c.name,
|
|
156
234
|
toolCallId: c.id,
|
|
157
235
|
title: c.name,
|
|
158
|
-
detail:
|
|
236
|
+
detail: argsToDetail(c.arguments),
|
|
237
|
+
argSummary: summarizeArgs(c.arguments),
|
|
159
238
|
});
|
|
160
239
|
}
|
|
161
240
|
}
|
|
@@ -174,11 +253,13 @@ export class TrajLedger {
|
|
|
174
253
|
toolName: evt.toolName,
|
|
175
254
|
toolCallId: evt.toolCallId,
|
|
176
255
|
title: evt.toolName,
|
|
177
|
-
detail:
|
|
256
|
+
detail: argsToDetail(evt.args),
|
|
257
|
+
argSummary: summarizeArgs(evt.args),
|
|
178
258
|
});
|
|
179
259
|
} else {
|
|
180
260
|
const e = this.entries[idx];
|
|
181
|
-
e.detail =
|
|
261
|
+
e.detail = argsToDetail(evt.args);
|
|
262
|
+
e.argSummary = summarizeArgs(evt.args);
|
|
182
263
|
this.touch(e);
|
|
183
264
|
}
|
|
184
265
|
break;
|
|
@@ -199,7 +280,10 @@ export class TrajLedger {
|
|
|
199
280
|
if (idx !== undefined) {
|
|
200
281
|
const e = this.entries[idx];
|
|
201
282
|
const result = sanitizeToolOutput(textOf(evt.result));
|
|
202
|
-
|
|
283
|
+
// 输出截尾保留(新内容在尾部);args 部分已在 start 时截过,两段加起来不会超限太多
|
|
284
|
+
const keptArgs = e.detail?.includes("\n\n---\n\n") ? e.detail.slice(0, e.detail.indexOf("\n\n---\n\n")) : e.detail;
|
|
285
|
+
const tailText = clipText(result.text, TOOL_RESULT_MAX_CHARS);
|
|
286
|
+
e.detail = [keptArgs, tailText].filter(Boolean).join("\n\n---\n\n") || undefined;
|
|
203
287
|
e.encodingLoss = result.encodingLoss || e.encodingLoss;
|
|
204
288
|
e.isError = evt.isError === true;
|
|
205
289
|
const startedAt = this.toolStart.get(evt.toolCallId);
|
|
@@ -235,22 +319,33 @@ export class TrajLedger {
|
|
|
235
319
|
export function buildTrajectoryFromEntries(entries: any[]): TrajEntry[] {
|
|
236
320
|
const out: TrajEntry[] = [];
|
|
237
321
|
let seq = 1;
|
|
238
|
-
|
|
322
|
+
// 消息体的 timestamp 是毫秒数,会话条目(compaction / branch_summary 等)的是 ISO 字符串。
|
|
323
|
+
// 以前只认数字,压缩与分支摘要每次冷加载都被记成「现在」,在时间线上排到最后。
|
|
324
|
+
// 都取不到时沿用上一条的时间,保持顺序
|
|
325
|
+
let lastTs: number | undefined;
|
|
326
|
+
const tsOf = (...candidates: unknown[]): number => {
|
|
327
|
+
for (const value of candidates) {
|
|
328
|
+
const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
|
|
329
|
+
if (Number.isFinite(ms)) return (lastTs = ms);
|
|
330
|
+
}
|
|
331
|
+
return lastTs ?? Date.now();
|
|
332
|
+
};
|
|
239
333
|
|
|
240
334
|
for (const raw of entries) {
|
|
241
335
|
if (raw.type === "message") {
|
|
242
336
|
const m = raw.message;
|
|
243
337
|
if (!m) continue;
|
|
244
338
|
if (m.role === "user") {
|
|
245
|
-
out.push({ seq: seq++, kind: "user", ts: tsOf(m), detail: textOf(m.content) });
|
|
339
|
+
out.push({ seq: seq++, kind: "user", ts: tsOf(m.timestamp, raw.timestamp), detail: textOf(m.content) });
|
|
246
340
|
} else if (m.role === "assistant") {
|
|
247
341
|
const thinking = textOf((m.content ?? []).filter((c: any) => c.type === "thinking"));
|
|
248
342
|
const text = textOf((m.content ?? []).filter((c: any) => c.type === "text"));
|
|
249
343
|
const durationMs = storedDuration(m.durationMs);
|
|
344
|
+
const ts = tsOf(m.timestamp, raw.timestamp);
|
|
250
345
|
out.push({
|
|
251
346
|
seq: seq++,
|
|
252
347
|
kind: "message",
|
|
253
|
-
ts
|
|
348
|
+
ts,
|
|
254
349
|
title: m.stopReason === "error" ? "error" : "assistant",
|
|
255
350
|
detail: text,
|
|
256
351
|
thinking: thinking || undefined,
|
|
@@ -263,11 +358,12 @@ export function buildTrajectoryFromEntries(entries: any[]): TrajEntry[] {
|
|
|
263
358
|
out.push({
|
|
264
359
|
seq: seq++,
|
|
265
360
|
kind: "tool",
|
|
266
|
-
ts
|
|
361
|
+
ts,
|
|
267
362
|
toolName: c.name,
|
|
268
363
|
toolCallId: c.id,
|
|
269
364
|
title: c.name,
|
|
270
|
-
detail:
|
|
365
|
+
detail: argsToDetail(c.arguments),
|
|
366
|
+
argSummary: summarizeArgs(c.arguments),
|
|
271
367
|
});
|
|
272
368
|
}
|
|
273
369
|
}
|
|
@@ -289,7 +385,7 @@ export function buildTrajectoryFromEntries(entries: any[]): TrajEntry[] {
|
|
|
289
385
|
out.push({
|
|
290
386
|
seq: seq++,
|
|
291
387
|
kind: "compacted",
|
|
292
|
-
ts: tsOf(raw),
|
|
388
|
+
ts: tsOf(raw.timestamp),
|
|
293
389
|
title: "compact",
|
|
294
390
|
tokens: raw.tokensBefore != null ? { input: raw.tokensBefore } : toTrajTokens(raw.usage),
|
|
295
391
|
});
|
|
@@ -297,7 +393,7 @@ export function buildTrajectoryFromEntries(entries: any[]): TrajEntry[] {
|
|
|
297
393
|
out.push({
|
|
298
394
|
seq: seq++,
|
|
299
395
|
kind: "compacted",
|
|
300
|
-
ts: tsOf(raw),
|
|
396
|
+
ts: tsOf(raw.timestamp),
|
|
301
397
|
title: "branch summary",
|
|
302
398
|
tokens: toTrajTokens(raw.usage),
|
|
303
399
|
detail: raw.summary,
|