@rexvane/piweb 0.3.22 → 0.3.24

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.
Files changed (251) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +8 -7
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +11 -4
  6. package/.next/routes-manifest.json +8 -0
  7. package/.next/server/app/_global-error/page.js +2 -2
  8. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  9. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/server/app/_global-error.html +1 -1
  11. package/.next/server/app/_global-error.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page.js +1 -1
  16. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/side-chat/route.js +5 -0
  28. package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +1 -0
  29. package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +1 -0
  30. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  31. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  32. package/.next/server/app/api/agent/new/route.js +1 -1
  33. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  34. package/.next/server/app/api/config/route.js +1 -1
  35. package/.next/server/app/api/config/route.js.nft.json +1 -1
  36. package/.next/server/app/api/files/route.js +1 -1
  37. package/.next/server/app/api/files/route.js.nft.json +1 -1
  38. package/.next/server/app/api/fs/route.js +1 -1
  39. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  40. package/.next/server/app/api/git/route.js +2 -2
  41. package/.next/server/app/api/git/route.js.nft.json +1 -1
  42. package/.next/server/app/api/github/route.js +2 -2
  43. package/.next/server/app/api/github/route.js.nft.json +1 -1
  44. package/.next/server/app/api/growth/route.js +1 -1
  45. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  46. package/.next/server/app/api/health/route.js +1 -1
  47. package/.next/server/app/api/health/route.js.nft.json +1 -1
  48. package/.next/server/app/api/models/defaults/route.js +1 -1
  49. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
  50. package/.next/server/app/api/models/route.js +1 -1
  51. package/.next/server/app/api/models/route.js.nft.json +1 -1
  52. package/.next/server/app/api/pi-settings/route.js +1 -1
  53. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  54. package/.next/server/app/api/plugins/route.js +1 -1
  55. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  56. package/.next/server/app/api/prompts/route.js +1 -1
  57. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  58. package/.next/server/app/api/remote/route.js +3 -3
  59. package/.next/server/app/api/remote/route.js.nft.json +1 -1
  60. package/.next/server/app/api/security/route.js +1 -1
  61. package/.next/server/app/api/security/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  63. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  64. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  65. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  66. package/.next/server/app/api/sessions/import/route.js +2 -2
  67. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  68. package/.next/server/app/api/sessions/route.js +1 -1
  69. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  70. package/.next/server/app/api/skills/route.js +2 -2
  71. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  72. package/.next/server/app/api/update/route.js +2 -2
  73. package/.next/server/app/api/update/route.js.nft.json +1 -1
  74. package/.next/server/app/api/version/route.js +1 -1
  75. package/.next/server/app/api/version/route.js.nft.json +1 -1
  76. package/.next/server/app/api/web-auth/route.js +2 -2
  77. package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
  78. package/.next/server/app/api/workspaces/route.js +1 -1
  79. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  80. package/.next/server/app/icon.svg/route.js +1 -1
  81. package/.next/server/app/icon.svg/route.js.nft.json +1 -1
  82. package/.next/server/app/index.html +1 -1
  83. package/.next/server/app/index.rsc +7 -6
  84. package/.next/server/app/index.segments/__PAGE__.segment.rsc +7 -6
  85. package/.next/server/app/index.segments/_full.segment.rsc +7 -6
  86. package/.next/server/app/index.segments/_tree.segment.rsc +3 -2
  87. package/.next/server/app/login/page.js +1 -1
  88. package/.next/server/app/login/page.js.nft.json +1 -1
  89. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  90. package/.next/server/app/login.html +1 -1
  91. package/.next/server/app/login.rsc +3 -3
  92. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  93. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  94. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  95. package/.next/server/app/page.js +1 -1
  96. package/.next/server/app/page.js.nft.json +1 -1
  97. package/.next/server/app/page_client-reference-manifest.js +1 -1
  98. package/.next/server/app-paths-manifest.json +8 -7
  99. package/.next/server/chunks/{813.js → 1813.js} +1 -1
  100. package/.next/server/chunks/{430.js → 2430.js} +1 -1
  101. package/.next/server/chunks/{970.js → 2970.js} +1 -1
  102. package/.next/server/chunks/3421.js +1 -0
  103. package/.next/server/chunks/3924.js +6 -0
  104. package/.next/server/chunks/4807.js +1 -0
  105. package/.next/server/chunks/5393.js +1 -0
  106. package/.next/server/chunks/5802.js +2 -0
  107. package/.next/server/chunks/6229.js +1 -0
  108. package/.next/server/chunks/7496.js +1 -0
  109. package/.next/server/chunks/7662.js +2 -0
  110. package/.next/server/chunks/8686.js +1 -0
  111. package/.next/server/chunks/{593.js → 974.js} +1 -1
  112. package/.next/server/chunks/{920.js → 9920.js} +1 -1
  113. package/.next/server/functions-config-manifest.json +2 -1
  114. package/.next/server/instrumentation.js +1 -1
  115. package/.next/server/instrumentation.js.nft.json +1 -1
  116. package/.next/server/middleware-build-manifest.js +1 -1
  117. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  118. package/.next/server/middleware.js +1 -1
  119. package/.next/server/pages/404.html +1 -1
  120. package/.next/server/pages/500.html +1 -1
  121. package/.next/server/preview-props.json +3 -3
  122. package/.next/server/server-reference-manifest.json +1 -1
  123. package/.next/server/webpack-runtime.js +1 -1
  124. package/.next/static/chunks/2716.44fdf1b21454b961.js +1 -0
  125. package/.next/static/chunks/4673.e261707dce391be7.js +1 -0
  126. package/.next/static/chunks/4686-0cd87513f9ea3538.js +1 -0
  127. package/.next/static/chunks/7411.5f79671208209b13.js +1 -0
  128. package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +1 -0
  129. package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +1 -0
  130. package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +1 -0
  131. package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +1 -0
  132. package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +1 -0
  133. package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +1 -0
  134. package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +1 -0
  135. package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +1 -0
  136. package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +1 -0
  137. package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +1 -0
  138. package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +1 -0
  139. package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +1 -0
  140. package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +1 -0
  141. package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +1 -0
  142. package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +1 -0
  143. package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +1 -0
  144. package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +1 -0
  145. package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +1 -0
  146. package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +1 -0
  147. package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +1 -0
  148. package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +1 -0
  149. package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +1 -0
  150. package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +1 -0
  151. package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +1 -0
  152. package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +1 -0
  153. package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +1 -0
  154. package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +1 -0
  155. package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +1 -0
  156. package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +1 -0
  157. package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +1 -0
  158. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +1 -0
  159. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +1 -0
  160. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +1 -0
  161. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +1 -0
  162. package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +1 -0
  163. package/.next/static/css/9fc559c2c4ad1d0f.css +1 -0
  164. package/.next/static/css/c6e2227c36ec2fd6.css +1 -0
  165. package/.next/types/app/api/agent/[id]/side-chat/route.ts +355 -0
  166. package/.next/types/routes.d.ts +2 -1
  167. package/.next/types/validator.ts +10 -0
  168. package/next.config.ts +2 -0
  169. package/package.json +9 -3
  170. package/scripts/release.mjs +3 -7
  171. package/scripts/runtime/check-installed.mjs +66 -0
  172. package/scripts/runtime/pi-packages.mjs +52 -0
  173. package/scripts/verify-package.mjs +29 -7
  174. package/src/app/api/agent/[id]/side-chat/route.ts +77 -0
  175. package/src/app/globals.css +1576 -14
  176. package/src/components/AppShell.tsx +63 -13
  177. package/src/components/ChatInput.tsx +33 -29
  178. package/src/components/ChatWindow.tsx +6 -7
  179. package/src/components/ContextMeter.tsx +35 -16
  180. package/src/components/DrawerPanel.tsx +1 -1
  181. package/src/components/FileViewer.tsx +89 -8
  182. package/src/components/GrowthTree.tsx +30 -5
  183. package/src/components/ModelSelector.tsx +21 -5
  184. package/src/components/SettingsPanel.tsx +25 -16
  185. package/src/components/chat/reading-anchor.ts +81 -0
  186. package/src/components/composer/ComposerImages.tsx +31 -11
  187. package/src/components/side-chat/SideChatPanel.module.css +131 -0
  188. package/src/components/side-chat/SideChatPanel.tsx +189 -0
  189. package/src/components/side-chat/icons.tsx +8 -0
  190. package/src/components/side-chat/request-history.ts +29 -0
  191. package/src/components/side-chat/store.ts +248 -0
  192. package/src/hooks/useFileViewer.ts +6 -4
  193. package/src/i18n.tsx +50 -8
  194. package/src/lib/files-service.ts +66 -2
  195. package/src/lib/path-security.ts +24 -32
  196. package/src/lib/session-reader.ts +1 -1
  197. package/src/lib/side-chat/context.ts +123 -0
  198. package/src/lib/side-chat/service.ts +191 -0
  199. package/src/lib/side-chat/types.ts +61 -0
  200. package/src/lib/types.ts +1 -1
  201. package/src/lib/update-service.ts +4 -6
  202. package/.next/server/chunks/229.js +0 -1
  203. package/.next/server/chunks/393.js +0 -1
  204. package/.next/server/chunks/421.js +0 -1
  205. package/.next/server/chunks/496.js +0 -1
  206. package/.next/server/chunks/662.js +0 -2
  207. package/.next/server/chunks/686.js +0 -1
  208. package/.next/server/chunks/802.js +0 -2
  209. package/.next/server/chunks/807.js +0 -1
  210. package/.next/server/chunks/924.js +0 -6
  211. package/.next/static/chunks/2716.7ad073038e94a802.js +0 -1
  212. package/.next/static/chunks/4673.4cf66cbdd8364c57.js +0 -1
  213. package/.next/static/chunks/4686-09fcd0028079006c.js +0 -1
  214. package/.next/static/chunks/7411.67366dc5032a141d.js +0 -1
  215. package/.next/static/chunks/app/_global-error/page-f977af7f858990a2.js +0 -1
  216. package/.next/static/chunks/app/api/agent/[id]/events/route-f977af7f858990a2.js +0 -1
  217. package/.next/static/chunks/app/api/agent/[id]/route-f977af7f858990a2.js +0 -1
  218. package/.next/static/chunks/app/api/agent/[id]/tree/route-f977af7f858990a2.js +0 -1
  219. package/.next/static/chunks/app/api/agent/new/route-f977af7f858990a2.js +0 -1
  220. package/.next/static/chunks/app/api/config/route-f977af7f858990a2.js +0 -1
  221. package/.next/static/chunks/app/api/files/route-f977af7f858990a2.js +0 -1
  222. package/.next/static/chunks/app/api/fs/route-f977af7f858990a2.js +0 -1
  223. package/.next/static/chunks/app/api/git/route-f977af7f858990a2.js +0 -1
  224. package/.next/static/chunks/app/api/github/route-f977af7f858990a2.js +0 -1
  225. package/.next/static/chunks/app/api/growth/route-f977af7f858990a2.js +0 -1
  226. package/.next/static/chunks/app/api/health/route-f977af7f858990a2.js +0 -1
  227. package/.next/static/chunks/app/api/models/defaults/route-f977af7f858990a2.js +0 -1
  228. package/.next/static/chunks/app/api/models/route-f977af7f858990a2.js +0 -1
  229. package/.next/static/chunks/app/api/pi-settings/route-f977af7f858990a2.js +0 -1
  230. package/.next/static/chunks/app/api/plugins/route-f977af7f858990a2.js +0 -1
  231. package/.next/static/chunks/app/api/prompts/route-f977af7f858990a2.js +0 -1
  232. package/.next/static/chunks/app/api/remote/route-f977af7f858990a2.js +0 -1
  233. package/.next/static/chunks/app/api/security/route-f977af7f858990a2.js +0 -1
  234. package/.next/static/chunks/app/api/sessions/[id]/export/route-f977af7f858990a2.js +0 -1
  235. package/.next/static/chunks/app/api/sessions/[id]/route-f977af7f858990a2.js +0 -1
  236. package/.next/static/chunks/app/api/sessions/import/route-f977af7f858990a2.js +0 -1
  237. package/.next/static/chunks/app/api/sessions/route-f977af7f858990a2.js +0 -1
  238. package/.next/static/chunks/app/api/skills/route-f977af7f858990a2.js +0 -1
  239. package/.next/static/chunks/app/api/update/route-f977af7f858990a2.js +0 -1
  240. package/.next/static/chunks/app/api/version/route-f977af7f858990a2.js +0 -1
  241. package/.next/static/chunks/app/api/web-auth/route-f977af7f858990a2.js +0 -1
  242. package/.next/static/chunks/app/api/workspaces/route-f977af7f858990a2.js +0 -1
  243. package/.next/static/chunks/app/page-fc4ed2ff29b6235e.js +0 -1
  244. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f977af7f858990a2.js +0 -1
  245. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f977af7f858990a2.js +0 -1
  246. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f977af7f858990a2.js +0 -1
  247. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f977af7f858990a2.js +0 -1
  248. package/.next/static/chunks/webpack-22ebffc20adb44ae.js +0 -1
  249. package/.next/static/css/6671d0d1589fdc0e.css +0 -1
  250. /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → lvKCJmjFj8LuqSojRVLRF}/_buildManifest.js +0 -0
  251. /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → lvKCJmjFj8LuqSojRVLRF}/_ssgManifest.js +0 -0
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ import { SIDE_CHAT_BODY_LIMIT, SIDE_CHAT_HISTORY_LIMIT, SIDE_CHAT_TEXT_LIMIT, type SideChatHistoryMessage } from "@/lib/side-chat/types";
2
+
3
+ /** Bound only the transmitted context; the browser retains the original transcript. */
4
+ export function requestHistory(messages: SideChatHistoryMessage[]): { history: SideChatHistoryMessage[]; truncated: boolean } {
5
+ const encoder = new TextEncoder();
6
+ const history: SideChatHistoryMessage[] = [];
7
+ // Reserve the other half for the question (including JSON escapes), model and envelope.
8
+ let remaining = Math.floor(SIDE_CHAT_BODY_LIMIT / 2) - 2;
9
+ let truncated = false;
10
+ for (let i = messages.length - 1; i >= 0; i--) {
11
+ if (history.length === SIDE_CHAT_HISTORY_LIMIT) { truncated = true; break; }
12
+ const message = messages[i];
13
+ let text = message.text;
14
+ if (text.length > SIDE_CHAT_TEXT_LIMIT) {
15
+ text = text.slice(-SIDE_CHAT_TEXT_LIMIT);
16
+ if (/^[\uDC00-\uDFFF]/.test(text)) text = text.slice(1);
17
+ truncated = true;
18
+ }
19
+ const entry = { role: message.role, text };
20
+ const size = encoder.encode(JSON.stringify(entry)).byteLength + 1;
21
+ if (size > remaining) { truncated = true; break; }
22
+ remaining -= size;
23
+ history.push(entry);
24
+ }
25
+ history.reverse();
26
+ // A retained transcript must begin with a question, not an orphaned answer.
27
+ while (history[0]?.role === "assistant") { history.shift(); truncated = true; }
28
+ return { history, truncated };
29
+ }
@@ -0,0 +1,248 @@
1
+ "use client";
2
+
3
+ import { isSideChatId, isSideChatModel, type SideChatModel, type SideChatHistoryMessage, type SideChatProgress, type SideChatRequest, type SideChatUsage } from "@/lib/side-chat/types";
4
+
5
+ import { requestHistory } from "./request-history";
6
+
7
+ export interface SideChatMessage {
8
+ id: string;
9
+ role: "user" | "assistant";
10
+ text: string;
11
+ status?: "pending" | "done" | "error" | "aborted";
12
+ error?: string;
13
+ contextTruncated?: boolean;
14
+ lengthLimited?: boolean;
15
+ }
16
+ export interface SideChatThread {
17
+ id: string;
18
+ title: string;
19
+ messages: SideChatMessage[];
20
+ draft: string;
21
+ model?: SideChatModel;
22
+ request?: { id: string; revision: number };
23
+ /** 最近一次回答的真实用量与窗口:面板右下角的上下文环用它 */
24
+ usage?: SideChatUsage;
25
+ contextWindow?: number;
26
+ /** 这次上下文的真实字符分项(系统提示词 / 引用的主会话背景) */
27
+ characters?: { systemPrompt: number; background: number };
28
+ connectionLost?: boolean;
29
+ }
30
+ export interface SideChatState {
31
+ currentThreadId: string;
32
+ threads: SideChatThread[];
33
+ storageFailed: boolean;
34
+ }
35
+ export const EMPTY_SIDE_CHAT: SideChatState = { currentThreadId: "", threads: [], storageFailed: false };
36
+ const PREFIX = "piweb.sidechat.v1.";
37
+
38
+ class ApiError extends Error {
39
+ constructor(message: string, readonly status: number) { super(message); }
40
+ }
41
+
42
+ function pause(ms: number, signal: AbortSignal): Promise<void> {
43
+ return new Promise((resolve) => {
44
+ const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
45
+ const timer = setTimeout(finish, ms);
46
+ signal.addEventListener("abort", finish, { once: true });
47
+ if (signal.aborted) finish();
48
+ });
49
+ }
50
+
51
+ /** Browser-owned threads outlive panel unmounts. Only their own subscribers render on an answer update. */
52
+ export class SideChatStore {
53
+ private states = new Map<string, SideChatState>();
54
+ private listeners = new Map<string, Set<() => void>>();
55
+ private requests = new Map<string, AbortController>();
56
+ private saves = new Map<string, ReturnType<typeof setTimeout>>();
57
+ constructor(private options: { fetch: typeof fetch; storage: () => Storage; id: () => string } = {
58
+ fetch: (...args) => fetch(...args), storage: () => localStorage, id: () => crypto.randomUUID(),
59
+ }) {}
60
+
61
+ get = (sessionId: string | null): SideChatState => {
62
+ if (!sessionId) return EMPTY_SIDE_CHAT;
63
+ let state = this.states.get(sessionId);
64
+ if (state) return state;
65
+ try {
66
+ const raw = this.options.storage().getItem(PREFIX + sessionId);
67
+ if (raw) {
68
+ const saved = JSON.parse(raw) as SideChatState;
69
+ if (isSideChatId(saved.currentThreadId) && Array.isArray(saved.threads) && saved.threads.length && saved.threads.every((thread) =>
70
+ isSideChatId(thread.id) && typeof thread.title === "string" && typeof thread.draft === "string" && Array.isArray(thread.messages) && thread.messages.every((message) =>
71
+ isSideChatId(message.id) && (message.role === "user" || message.role === "assistant") && typeof message.text === "string") &&
72
+ (!thread.request || (isSideChatId(thread.request.id) && Number.isSafeInteger(thread.request.revision)))) && saved.threads.some((thread) => thread.id === saved.currentThreadId)) {
73
+ state = { ...saved, storageFailed: false, threads: saved.threads.map((thread) => ({ ...thread, model: isSideChatModel(thread.model) ? thread.model : undefined })) };
74
+ }
75
+ }
76
+ } catch { /* A unavailable or full browser store must not disable questions. */ }
77
+ if (!state) {
78
+ const thread = this.newThread();
79
+ state = { currentThreadId: thread.id, threads: [thread], storageFailed: false };
80
+ }
81
+ this.states.set(sessionId, state);
82
+ return state;
83
+ };
84
+
85
+ flush = (): void => { for (const sessionId of this.states.keys()) this.save(sessionId); };
86
+
87
+ subscribe = (sessionId: string | null, listener: () => void): (() => void) => {
88
+ if (!sessionId) return () => {};
89
+ const listeners = this.listeners.get(sessionId) ?? new Set();
90
+ listeners.add(listener);
91
+ this.listeners.set(sessionId, listeners);
92
+ queueMicrotask(() => {
93
+ for (const thread of this.get(sessionId).threads) {
94
+ if (thread.request && !this.requests.has(thread.request.id)) void this.run(sessionId, thread.id, thread.request.id);
95
+ }
96
+ });
97
+ return () => { listeners.delete(listener); };
98
+ };
99
+
100
+ private newThread(model?: SideChatModel): SideChatThread { return { id: this.options.id(), title: "", messages: [], draft: "", ...(model ? { model: { ...model } } : {}) }; }
101
+ private notify(sessionId: string): void { for (const listener of this.listeners.get(sessionId) ?? []) listener(); }
102
+ private save(sessionId: string): void {
103
+ const timer = this.saves.get(sessionId);
104
+ if (timer) clearTimeout(timer);
105
+ this.saves.delete(sessionId);
106
+ const state = this.get(sessionId);
107
+ let failed = false;
108
+ try { this.options.storage().setItem(PREFIX + sessionId, JSON.stringify(state)); }
109
+ catch { failed = true; }
110
+ if (state.storageFailed !== failed) {
111
+ this.states.set(sessionId, { ...state, storageFailed: failed });
112
+ this.notify(sessionId);
113
+ }
114
+ }
115
+ private update(sessionId: string, state: SideChatState, immediately = false): void {
116
+ this.states.set(sessionId, state);
117
+ this.notify(sessionId);
118
+ if (immediately) this.save(sessionId);
119
+ else if (!this.saves.has(sessionId)) this.saves.set(sessionId, setTimeout(() => this.save(sessionId), 300));
120
+ }
121
+ private updateThread(sessionId: string, threadId: string, change: (thread: SideChatThread) => SideChatThread, immediately = false): void {
122
+ const state = this.get(sessionId);
123
+ this.update(sessionId, { ...state, threads: state.threads.map((thread) => thread.id === threadId ? change(thread) : thread) }, immediately);
124
+ }
125
+
126
+ setDraft(sessionId: string, text: string): void {
127
+ this.updateThread(sessionId, this.get(sessionId).currentThreadId, (thread) => ({ ...thread, draft: text }));
128
+ }
129
+ setModel(sessionId: string, model?: SideChatModel): void {
130
+ this.updateThread(sessionId, this.get(sessionId).currentThreadId, (thread) => ({ ...thread, model: model ? { ...model } : undefined }), true);
131
+ }
132
+ createThread(sessionId: string): void {
133
+ const state = this.get(sessionId);
134
+ const current = state.threads.find((thread) => thread.id === state.currentThreadId);
135
+ if (current && !current.messages.length && !current.draft) return;
136
+ const thread = this.newThread(current?.model);
137
+ this.update(sessionId, { ...state, currentThreadId: thread.id, threads: [...state.threads, thread] }, true);
138
+ }
139
+ selectThread(sessionId: string, threadId: string): void {
140
+ const state = this.get(sessionId);
141
+ if (state.threads.some((thread) => thread.id === threadId)) this.update(sessionId, { ...state, currentThreadId: threadId }, true);
142
+ }
143
+
144
+ send(sessionId: string, retry = false): void {
145
+ const state = this.get(sessionId);
146
+ const thread = state.threads.find((item) => item.id === state.currentThreadId);
147
+ if (!thread || thread.request) return;
148
+ const previous = thread.messages.at(-1);
149
+ const retrying = retry && previous?.role === "assistant" && previous.status === "error" && thread.messages.at(-2)?.role === "user";
150
+ const text = (retrying ? thread.messages.at(-2)!.text : thread.draft).trim();
151
+ if (!text) return;
152
+ const requestId = this.options.id();
153
+ const historyMessages = retrying ? thread.messages.slice(0, -2) : thread.messages;
154
+ const availableHistory: SideChatHistoryMessage[] = historyMessages.filter((message) => message.text && (message.role === "user" || message.status === "done" || message.status === "aborted"))
155
+ .map(({ role, text }) => ({ role, text }));
156
+ const { history, truncated } = requestHistory(availableHistory);
157
+ const messages: SideChatMessage[] = retrying
158
+ ? [...thread.messages.slice(0, -1), { id: previous!.id, role: "assistant", text: "", status: "pending", contextTruncated: truncated }]
159
+ : [...thread.messages, { id: this.options.id(), role: "user", text }, { id: this.options.id(), role: "assistant", text: "", status: "pending", contextTruncated: truncated }];
160
+ this.updateThread(sessionId, thread.id, (item) => ({ ...item, title: item.title || text.slice(0, 80), messages, draft: retrying ? item.draft : "", request: { id: requestId, revision: -1 }, connectionLost: false }), true);
161
+ void this.run(sessionId, thread.id, requestId, { requestId, threadId: thread.id, text, history, ...(thread.model ? { model: { ...thread.model } } : {}) });
162
+ }
163
+
164
+ private apply(sessionId: string, threadId: string, requestId: string, progress: SideChatProgress): void {
165
+ this.updateThread(sessionId, threadId, (thread) => {
166
+ if (thread.request?.id !== requestId) return thread;
167
+ const previousText = thread.messages.at(-1)?.text ?? "";
168
+ if (progress.textOffset !== undefined && progress.textOffset > previousText.length) throw new Error("side-chat answer cursor is out of sync");
169
+ const text = progress.textOffset === undefined ? progress.text : previousText.slice(0, progress.textOffset) + progress.text;
170
+ const status = !progress.done ? "pending" : progress.error ? "error" : progress.aborted ? "aborted" : "done";
171
+ return {
172
+ ...thread, connectionLost: false, request: progress.done ? undefined : { id: requestId, revision: progress.revision },
173
+ // 只在这次响应真的报了就更新;没报就保持上一次的值(不回落成 0)
174
+ ...(progress.usage ? { usage: progress.usage } : {}),
175
+ ...(progress.contextWindow ? { contextWindow: progress.contextWindow } : {}),
176
+ ...(progress.characters ? { characters: progress.characters } : {}),
177
+ messages: thread.messages.map((message, index) => index === thread.messages.length - 1 ? {
178
+ ...message, text, status, error: progress.error, contextTruncated: message.contextTruncated || progress.contextTruncated, lengthLimited: progress.lengthLimited,
179
+ } : message),
180
+ };
181
+ }, progress.done);
182
+ }
183
+ private fail(sessionId: string, threadId: string, requestId: string, error: string): void {
184
+ const thread = this.get(sessionId).threads.find((item) => item.id === threadId);
185
+ this.apply(sessionId, threadId, requestId, { requestId, revision: 0, text: thread?.messages.at(-1)?.text ?? "", done: true, aborted: false, error });
186
+ }
187
+ private async response(response: Response): Promise<SideChatProgress> {
188
+ const json = await response.json();
189
+ if (!response.ok || !json.success) throw new ApiError(json.error || `side-chat request failed (${response.status})`, response.status);
190
+ const data = json.data as SideChatProgress;
191
+ if (!data || !isSideChatId(data.requestId) || !Number.isSafeInteger(data.revision) || typeof data.text !== "string" || typeof data.done !== "boolean" || (data.textOffset !== undefined && (!Number.isSafeInteger(data.textOffset) || data.textOffset < 0))) throw new Error("invalid side-chat response");
192
+ return data;
193
+ }
194
+ private async run(sessionId: string, threadId: string, requestId: string, initial?: SideChatRequest): Promise<void> {
195
+ if (this.requests.has(requestId)) return;
196
+ const controller = new AbortController();
197
+ this.requests.set(requestId, controller);
198
+ const url = `/api/agent/${sessionId}/side-chat`;
199
+ let revision = this.get(sessionId).threads.find((thread) => thread.id === threadId)?.request?.revision ?? -1;
200
+ try {
201
+ if (initial) {
202
+ const progress = await this.response(await this.options.fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial), signal: controller.signal }));
203
+ if (progress.requestId !== requestId) throw new Error("side-chat response belongs to a different request");
204
+ if (controller.signal.aborted) return;
205
+ this.apply(sessionId, threadId, requestId, progress);
206
+ if (progress.done) return;
207
+ revision = progress.revision;
208
+ }
209
+ let failures = 0;
210
+ while (!controller.signal.aborted) {
211
+ let progress: SideChatProgress;
212
+ try {
213
+ progress = await this.response(await this.options.fetch(`${url}?requestId=${encodeURIComponent(requestId)}&after=${revision}`, { cache: "no-store", signal: controller.signal }));
214
+ if (progress.requestId !== requestId) throw new Error("side-chat response belongs to a different request");
215
+ } catch (error) {
216
+ if (controller.signal.aborted) return;
217
+ if (error instanceof ApiError || ++failures >= 6) throw error;
218
+ this.updateThread(sessionId, threadId, (thread) => ({ ...thread, connectionLost: true }));
219
+ await pause(Math.min(500 * 2 ** (failures - 1), 5000), controller.signal);
220
+ continue;
221
+ }
222
+ if (controller.signal.aborted) return;
223
+ failures = 0;
224
+ this.apply(sessionId, threadId, requestId, progress);
225
+ if (progress.done) return;
226
+ revision = progress.revision;
227
+ }
228
+ } catch (error) {
229
+ if (!controller.signal.aborted) this.fail(sessionId, threadId, requestId, error instanceof Error ? error.message : "side-chat request failed");
230
+ } finally {
231
+ if (this.requests.get(requestId) === controller) this.requests.delete(requestId);
232
+ }
233
+ }
234
+
235
+ stop(sessionId: string): void {
236
+ const thread = this.get(sessionId).threads.find((item) => item.id === this.get(sessionId).currentThreadId);
237
+ const requestId = thread?.request?.id;
238
+ if (!thread || !requestId) return;
239
+ this.requests.get(requestId)?.abort();
240
+ this.apply(sessionId, thread.id, requestId, { requestId, revision: 0, text: thread.messages.at(-1)?.text ?? "", done: true, aborted: true });
241
+ void this.options.fetch(`/api/agent/${sessionId}/side-chat?requestId=${encodeURIComponent(requestId)}`, { method: "DELETE" })
242
+ .then(async (response) => { if (!response.ok) throw new Error((await response.json()).error || "failed to stop side-chat answer"); })
243
+ .catch((error) => this.updateThread(sessionId, thread.id, (current) => ({ ...current, messages: current.messages.map((message) => message.id === thread.messages.at(-1)?.id ? { ...message, error: error instanceof Error ? error.message : "failed to stop side-chat answer" } : message) }), true));
244
+ }
245
+ }
246
+
247
+ export const sideChatStore = new SideChatStore();
248
+ if (typeof window !== "undefined") window.addEventListener("pagehide", sideChatStore.flush);
@@ -8,11 +8,13 @@ import { useCallback, useState } from "react";
8
8
 
9
9
  export interface ViewerTab {
10
10
  path: string;
11
- /** 重命名前的路径(取 diff 用) */
11
+ /** 重命名前的路径(取 diff 用) */
12
12
  from?: string;
13
- /** 快照之外的文件(node_modules 等):只能看磁盘上的当前内容 */
13
+ /** 快照之外的文件(node_modules 等):只能看磁盘上的当前内容 */
14
14
  lazy?: boolean;
15
- /** 内容由调用方直接提供(如技能文档):不取 diff、不读磁盘 */
15
+ /** dir = 打开的是文件夹:正文区列目录(可逐层进入),不是文件内容 */
16
+ kind?: "dir" | "file";
17
+ /** 内容由调用方直接提供(如技能文档):不取 diff、不读磁盘 */
16
18
  staticContent?: string;
17
19
  }
18
20
 
@@ -25,7 +27,7 @@ export interface ViewerState {
25
27
 
26
28
  export function useFileViewer() {
27
29
  const [state, setState] = useState<ViewerState>({ open: false, tabs: [], active: null });
28
- const open = useCallback((path: string, opts: { from?: string; lazy?: boolean } = {}) => {
30
+ const open = useCallback((path: string, opts: { from?: string; lazy?: boolean; kind?: "dir" | "file" } = {}) => {
29
31
  setState((s) => {
30
32
  const exists = s.tabs.some((tab) => tab.path === path);
31
33
  const tabs = exists ? s.tabs.map((tab) => (tab.path === path ? { ...tab, ...opts } : tab)) : [...s.tabs, { path, ...opts }];
package/src/i18n.tsx CHANGED
@@ -43,10 +43,15 @@ const zh = {
43
43
  visionLabel: "视觉",
44
44
  visionHint: "该模型能否接收图片。未声明时 pi 按纯文本处理:你贴的截图会被换成一行占位文字(提示语还会说成「模型不支持图像」)",
45
45
  visionUnset: "未声明",
46
+ visionInputText: "文本",
47
+ visionInputTextHint: "pi 总是接受文本输入;贴图能不能发由「图片」决定",
48
+ visionInputImage: "图片",
49
+ visionInputImageHint: "勾上表示该模型能收图(写进 models.json 的 input);不勾则按纯文本处理",
46
50
  visionOn: "有视觉",
47
51
  visionOff: "纯文本",
48
52
  imagePreview: "点击放大预览",
49
53
  imageViewer: "图片预览",
54
+ imageClearAll: "清空图片",
50
55
  imagePrev: "上一张",
51
56
  imageNext: "下一张",
52
57
  chipExpandMore: "展开 {n}",
@@ -314,9 +319,9 @@ const zh = {
314
319
  contextUsed: "上下文已用",
315
320
  contextUnknown: "上下文用量未知",
316
321
  contextEstimated: "上下文估算",
322
+ contextMeasured: "上下文用量",
323
+ contextBackground: "Main Conversation",
317
324
  contextApiInput: "最近请求输入(API)",
318
- contextCharacterBreakdown: "内容分类(字符数)",
319
- contextCharacterHint: "按当前文本统计,图片不计。API 未提供分类 token 用量。",
320
325
  contextChars: "字符",
321
326
  contextSystem: "系统提示词",
322
327
  contextMessages: "对话消息",
@@ -381,8 +386,6 @@ const zh = {
381
386
  cacheHits: "缓存命中",
382
387
  statsCounts: "{turns} 轮 · {steps} 步",
383
388
  ttftAverage: "首 token 平均",
384
- statsMeasuredSteps: "实时测量 {n} 步",
385
- statsTimedCoverage: "{n}/{total} 条有计时",
386
389
  inputTokens: "输入",
387
390
  outputTokens: "输出",
388
391
  // 视图
@@ -465,6 +468,21 @@ const zh = {
465
468
  drawerPanel: "右侧抽屉",
466
469
  drawerSections: "抽屉入口",
467
470
  drawerPrompts: "提示词",
471
+ sideChat: "旁聊",
472
+ sideChatModel: "旁聊模型",
473
+ sideChatFollowMain: "跟随主会话",
474
+ sideChatNew: "新旁聊",
475
+ sideChatHistory: "旁聊记录",
476
+ sideChatMessages: "旁聊消息",
477
+ sideChatQuestion: "旁聊提问",
478
+ sideChatPlaceholder: "读到哪里有疑问,就在这里问…",
479
+ sideChatEmpty: "边读边问,主会话停在原处",
480
+ sideChatHint: "参考主会话,问答独立。记录仅保存在此浏览器。",
481
+ sideChatPickSession: "打开一个主会话后,就可以旁聊",
482
+ sideChatAnswering: "正在回答…",
483
+ sideChatContextTruncated: "背景较长,本次仅参考了部分主会话或旁聊记录。",
484
+ sideChatLengthLimited: "已达到本次输出上限,可以继续追问。",
485
+ sideChatStorageFailed: "浏览器未能保存旁聊记录,请先保留需要的内容。",
468
486
  setPlugins: "插件",
469
487
  setRemote: "远程连接",
470
488
  remoteBuiltin: "内置插件",
@@ -721,6 +739,9 @@ const zh = {
721
739
  viewerNotInSnapshot: "此文件不在快照里(超过 1MB 或被排除),显示磁盘上的当前内容",
722
740
  viewerLoadFailed: "加载失败",
723
741
  viewerEmpty: "从左侧树里选择一个文件",
742
+ viewerOpenFolder: "打开文件夹预览",
743
+ viewerDirHint: "文件夹:共 {n} 项(已按 .gitignore 过滤)",
744
+ viewerDirMore: "加载更多",
724
745
  // 流式输出
725
746
  streamStyleTitle: "流式输出样式",
726
747
  streamStyleDesc: "回答逐字出现的方式;改完立刻生效,正在流出的回答也一并换",
@@ -781,10 +802,15 @@ const en: Dict = {
781
802
  visionLabel: "Vision",
782
803
  visionHint: "Whether this model accepts images. Left unset, pi treats it as text-only and replaces pasted screenshots with a placeholder line.",
783
804
  visionUnset: "Not declared",
805
+ visionInputText: "Text",
806
+ visionInputTextHint: "pi always accepts text; whether images can be sent is decided by the Image box",
807
+ visionInputImage: "Image",
808
+ visionInputImageHint: "Checked means this model accepts images (written into models.json input); unchecked is treated as text-only",
784
809
  visionOn: "Vision",
785
810
  visionOff: "Text only",
786
811
  imagePreview: "Click to preview",
787
812
  imageViewer: "Image preview",
813
+ imageClearAll: "Clear images",
788
814
  imagePrev: "Previous image",
789
815
  imageNext: "Next image",
790
816
  chipExpandMore: "Show {n} more",
@@ -1050,9 +1076,9 @@ const en: Dict = {
1050
1076
  contextUsed: "context used",
1051
1077
  contextUnknown: "Context usage unknown",
1052
1078
  contextEstimated: "estimated context usage",
1079
+ contextMeasured: "context usage",
1080
+ contextBackground: "Main Conversation",
1053
1081
  contextApiInput: "Last request input (API)",
1054
- contextCharacterBreakdown: "Content breakdown (characters)",
1055
- contextCharacterHint: "Counts current text; excludes images. The API does not report tokens by category.",
1056
1082
  contextChars: "chars",
1057
1083
  contextSystem: "System prompt",
1058
1084
  contextMessages: "Messages",
@@ -1116,8 +1142,6 @@ const en: Dict = {
1116
1142
  cacheHits: "cache hits",
1117
1143
  statsCounts: "{turns} turns · {steps} steps",
1118
1144
  ttftAverage: "TTFT avg",
1119
- statsMeasuredSteps: "{n} steps measured live",
1120
- statsTimedCoverage: "{n}/{total} entries timed",
1121
1145
  inputTokens: "Input",
1122
1146
  outputTokens: "Output",
1123
1147
  tabChat: "Chat",
@@ -1196,6 +1220,21 @@ const en: Dict = {
1196
1220
  drawerPanel: "Drawer",
1197
1221
  drawerSections: "Drawer sections",
1198
1222
  drawerPrompts: "Prompts",
1223
+ sideChat: "Side chat",
1224
+ sideChatModel: "Side-chat model",
1225
+ sideChatFollowMain: "Follow main chat",
1226
+ sideChatNew: "New side chat",
1227
+ sideChatHistory: "Side-chat history",
1228
+ sideChatMessages: "Side-chat messages",
1229
+ sideChatQuestion: "Side-chat question",
1230
+ sideChatPlaceholder: "Ask about anything you're reading…",
1231
+ sideChatEmpty: "Ask as you read. Keep your place.",
1232
+ sideChatHint: "Uses the main chat as background. Separate answers, saved only in this browser.",
1233
+ sideChatPickSession: "Open a main conversation to start a side chat",
1234
+ sideChatAnswering: "Answering…",
1235
+ sideChatContextTruncated: "The background is long; this answer uses part of the main or side-chat history.",
1236
+ sideChatLengthLimited: "This answer reached its output limit. You can ask it to continue.",
1237
+ sideChatStorageFailed: "Your browser could not save the side chat. Keep a copy of anything you need.",
1199
1238
  setPlugins: "Plugins",
1200
1239
  setRemote: "Remote access",
1201
1240
  remoteBuiltin: "Built-in plugin",
@@ -1451,6 +1490,9 @@ const en: Dict = {
1451
1490
  viewerNotInSnapshot: "Not in the snapshot (over 1MB or excluded); showing the current content from disk",
1452
1491
  viewerLoadFailed: "Failed to load",
1453
1492
  viewerEmpty: "Pick a file from the tree on the left",
1493
+ viewerOpenFolder: "Preview folder",
1494
+ viewerDirHint: "Folder: {n} items (ignored files hidden)",
1495
+ viewerDirMore: "Load more",
1454
1496
  // Streaming text
1455
1497
  streamStyleTitle: "Streaming text",
1456
1498
  streamStyleDesc: "How the answer appears while it arrives; applies to the stream already running",