@rexvane/piweb 0.3.22 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +5 -4
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +11 -4
- package/.next/routes-manifest.json +8 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.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.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.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]/side-chat/route.js +5 -0
- package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +1 -0
- package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/server/app/api/fs/route.js +1 -1
- package/.next/server/app/api/fs/route.js.nft.json +1 -1
- package/.next/server/app/api/git/route.js +2 -2
- package/.next/server/app/api/git/route.js.nft.json +1 -1
- package/.next/server/app/api/github/route.js +2 -2
- 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/health/route.js.nft.json +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 +3 -3
- package/.next/server/app/api/remote/route.js.nft.json +1 -1
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +2 -2
- package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/version/route.js.nft.json +1 -1
- package/.next/server/app/api/web-auth/route.js +2 -2
- package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg/route.js +1 -1
- package/.next/server/app/icon.svg/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +7 -6
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +7 -6
- package/.next/server/app/index.segments/_full.segment.rsc +7 -6
- package/.next/server/app/index.segments/_tree.segment.rsc +3 -2
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.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 +5 -4
- package/.next/server/chunks/{813.js → 1813.js} +1 -1
- package/.next/server/chunks/{430.js → 2430.js} +1 -1
- package/.next/server/chunks/{970.js → 2970.js} +1 -1
- package/.next/server/chunks/3421.js +1 -0
- package/.next/server/chunks/3924.js +6 -0
- package/.next/server/chunks/4807.js +1 -0
- package/.next/server/chunks/5393.js +1 -0
- package/.next/server/chunks/5802.js +2 -0
- package/.next/server/chunks/6229.js +1 -0
- package/.next/server/chunks/7496.js +1 -0
- package/.next/server/chunks/7662.js +2 -0
- package/.next/server/chunks/8686.js +1 -0
- package/.next/server/chunks/{593.js → 974.js} +1 -1
- package/.next/server/chunks/{920.js → 9920.js} +1 -1
- package/.next/server/functions-config-manifest.json +2 -1
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/middleware.js +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/server/webpack-runtime.js +1 -1
- package/.next/static/chunks/2716.44fdf1b21454b961.js +1 -0
- package/.next/static/chunks/4673.e261707dce391be7.js +1 -0
- package/.next/static/chunks/4686-0cd87513f9ea3538.js +1 -0
- package/.next/static/chunks/7411.5f79671208209b13.js +1 -0
- package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +1 -0
- package/.next/static/css/9fc559c2c4ad1d0f.css +1 -0
- package/.next/static/css/c6e2227c36ec2fd6.css +1 -0
- package/.next/types/app/api/agent/[id]/side-chat/route.ts +355 -0
- package/.next/types/routes.d.ts +2 -1
- package/.next/types/validator.ts +10 -0
- package/next.config.ts +2 -0
- package/package.json +7 -1
- package/scripts/release.mjs +3 -7
- package/scripts/runtime/check-installed.mjs +66 -0
- package/scripts/runtime/pi-packages.mjs +51 -0
- package/scripts/verify-package.mjs +29 -7
- package/src/app/api/agent/[id]/side-chat/route.ts +77 -0
- package/src/app/globals.css +1576 -14
- package/src/components/AppShell.tsx +63 -13
- package/src/components/ChatInput.tsx +33 -29
- package/src/components/ChatWindow.tsx +6 -7
- package/src/components/ContextMeter.tsx +35 -16
- package/src/components/DrawerPanel.tsx +1 -1
- package/src/components/FileViewer.tsx +89 -8
- package/src/components/GrowthTree.tsx +30 -5
- package/src/components/ModelSelector.tsx +21 -5
- package/src/components/SettingsPanel.tsx +25 -16
- package/src/components/chat/reading-anchor.ts +81 -0
- package/src/components/composer/ComposerImages.tsx +31 -11
- package/src/components/side-chat/SideChatPanel.module.css +131 -0
- package/src/components/side-chat/SideChatPanel.tsx +189 -0
- package/src/components/side-chat/icons.tsx +8 -0
- package/src/components/side-chat/request-history.ts +29 -0
- package/src/components/side-chat/store.ts +248 -0
- package/src/hooks/useFileViewer.ts +6 -4
- package/src/i18n.tsx +50 -8
- package/src/lib/files-service.ts +66 -2
- package/src/lib/path-security.ts +24 -32
- package/src/lib/session-reader.ts +1 -1
- package/src/lib/side-chat/context.ts +123 -0
- package/src/lib/side-chat/service.ts +191 -0
- package/src/lib/side-chat/types.ts +61 -0
- package/src/lib/types.ts +1 -1
- package/src/lib/update-service.ts +4 -6
- package/.next/server/chunks/229.js +0 -1
- package/.next/server/chunks/393.js +0 -1
- package/.next/server/chunks/421.js +0 -1
- package/.next/server/chunks/496.js +0 -1
- package/.next/server/chunks/662.js +0 -2
- package/.next/server/chunks/686.js +0 -1
- package/.next/server/chunks/802.js +0 -2
- package/.next/server/chunks/807.js +0 -1
- package/.next/server/chunks/924.js +0 -6
- package/.next/static/chunks/2716.7ad073038e94a802.js +0 -1
- package/.next/static/chunks/4673.4cf66cbdd8364c57.js +0 -1
- package/.next/static/chunks/4686-09fcd0028079006c.js +0 -1
- package/.next/static/chunks/7411.67366dc5032a141d.js +0 -1
- package/.next/static/chunks/app/_global-error/page-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/config/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/files/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/fs/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/git/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/github/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/growth/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/health/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/models/defaults/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/models/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/remote/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/security/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/skills/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/update/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/version/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/page-fc4ed2ff29b6235e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/webpack-22ebffc20adb44ae.js +0 -1
- package/.next/static/css/6671d0d1589fdc0e.css +0 -1
- /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → hYPL1-dmhKFjLS3f5yoqN}/_buildManifest.js +0 -0
- /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → hYPL1-dmhKFjLS3f5yoqN}/_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
|
-
/**
|
|
11
|
+
/** 重命名前的路径(取 diff 用) */
|
|
12
12
|
from?: string;
|
|
13
|
-
/**
|
|
13
|
+
/** 快照之外的文件(node_modules 等):只能看磁盘上的当前内容 */
|
|
14
14
|
lazy?: boolean;
|
|
15
|
-
/**
|
|
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",
|