@rexvane/piweb 0.3.17 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +6 -4
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +23 -9
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- package/.next/routes-manifest.json +12 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +3 -3
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/server/app/api/fs/route.js +1 -0
- package/.next/server/app/api/fs/route.js.nft.json +1 -0
- package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
- 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/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/health/route.js +1 -1
- package/.next/server/app/api/models/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 +8 -0
- package/.next/server/app/api/remote/route.js.nft.json +1 -0
- package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
- 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 +1 -1
- 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 +3 -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/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +6 -4
- package/.next/server/chunks/229.js +1 -1
- package/.next/server/chunks/243.js +1 -0
- package/.next/server/chunks/393.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/538.js +1 -0
- package/.next/server/chunks/686.js +1 -4
- package/.next/server/chunks/802.js +2 -0
- package/.next/server/chunks/807.js +1 -0
- package/.next/server/chunks/920.js +4 -0
- package/.next/server/chunks/924.js +6 -0
- package/.next/server/instrumentation.js +1 -0
- package/.next/server/instrumentation.js.nft.json +1 -0
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/middleware.js +4 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/JDGSR-TIbk9qZWcUNYIX3/_buildManifest.js +1 -0
- package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
- package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
- package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
- package/.next/static/chunks/4686-e3825f3b6a384aa1.js +1 -0
- package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
- package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
- package/.next/static/chunks/839.7403c6bdace4c56c.js +1 -0
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
- package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +1 -0
- package/.next/static/css/7391c42e1ae617f9.css +1 -0
- package/.next/types/app/api/fs/route.ts +353 -0
- package/.next/types/app/api/remote/route.ts +353 -0
- package/.next/types/routes.d.ts +3 -1
- package/.next/types/validator.ts +18 -0
- package/README.md +56 -10
- package/next.config.ts +3 -2
- package/package.json +3 -3
- package/scripts/launcher.mjs +194 -13
- package/scripts/release.mjs +0 -2
- package/src/app/api/agent/[id]/events/route.ts +11 -6
- package/src/app/api/fs/route.ts +119 -0
- package/src/app/api/growth/route.ts +11 -6
- package/src/app/api/models/route.ts +21 -9
- package/src/app/api/remote/route.ts +67 -0
- package/src/app/api/sessions/[id]/export/route.ts +11 -1
- package/src/app/api/sessions/route.ts +3 -1
- package/src/app/api/web-auth/route.ts +45 -49
- package/src/app/api/workspaces/route.ts +16 -0
- package/src/app/globals.css +11 -3
- package/src/app/login/page.tsx +8 -60
- package/src/components/AppShell.tsx +202 -155
- package/src/components/ChatInput.tsx +61 -6
- package/src/components/ChatWindow.tsx +274 -132
- package/src/components/DirectoryPicker.tsx +189 -0
- package/src/components/DrawerPanel.tsx +164 -0
- package/src/components/FileViewer.tsx +2 -2
- package/src/components/GrowthTree.tsx +6 -5
- package/src/components/MarkdownBlock.tsx +134 -0
- package/src/components/OutlineRail.tsx +22 -1
- package/src/components/ProjectPanel.tsx +197 -14
- package/src/components/RemoteSection.tsx +232 -0
- package/src/components/SessionImportSection.tsx +60 -38
- package/src/components/SettingsPanel.tsx +104 -57
- package/src/components/icons.tsx +23 -0
- package/src/hooks/useGrowth.ts +47 -25
- package/src/hooks/usePiWeb.ts +100 -14
- package/src/i18n.tsx +115 -28
- package/src/instrumentation-node.ts +35 -0
- package/src/instrumentation.ts +18 -0
- package/src/lib/agent-config.ts +134 -0
- package/src/lib/agent-manager.ts +243 -61
- package/src/lib/auth-throttle.ts +102 -0
- package/src/lib/auth.ts +35 -27
- package/src/lib/files-service.ts +61 -1
- package/src/lib/font.ts +84 -0
- package/src/lib/git-service.ts +5 -3
- package/src/lib/growth-service.ts +78 -30
- package/src/lib/growth-tracker.ts +91 -9
- package/src/lib/models-service.ts +36 -11
- package/src/lib/pi-settings.ts +10 -12
- package/src/lib/pi.ts +145 -18
- package/src/lib/plugins-service.ts +67 -35
- package/src/lib/remote/assets.json +4 -0
- package/src/lib/remote/mobile-proxy.ts +149 -0
- package/src/lib/remote/supervisor.ts +482 -0
- package/src/lib/security-service.ts +6 -11
- package/src/lib/session-import/claude.ts +45 -21
- package/src/lib/session-reader.ts +106 -3
- package/src/lib/skills-service.ts +34 -12
- package/src/lib/text-sanitize.ts +12 -0
- package/src/lib/theme.ts +3 -0
- package/src/lib/types.ts +16 -2
- package/src/lib/update-service.ts +0 -1
- package/src/lib/web-auth-shared.ts +5 -1
- package/src/lib/web-auth.ts +139 -22
- package/src/lib/web-launch-token.ts +55 -0
- package/src/lib/web-secret.ts +182 -0
- package/src/lib/workspace-store.ts +91 -7
- package/src/proxy.ts +46 -12
- package/.next/server/chunks/532.js +0 -1
- package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
- package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
- package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
- package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
- package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
- package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
- package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
- package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
- package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
- package/.next/static/css/850c2fd130ffa2fc.css +0 -1
- package/.next/static/zsynuO_9Kx6nrXgkTUJ04/_buildManifest.js +0 -1
- /package/.next/static/{zsynuO_9Kx6nrXgkTUJ04 → JDGSR-TIbk9qZWcUNYIX3}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 应用内目录浏览器:给手机等远程设备用。
|
|
5
|
+
*
|
|
6
|
+
* 本机回环上仍然走系统原生对话框(/api/workspaces 的 pick 动作),只有远程请求才落到这里
|
|
7
|
+
* —— 原生对话框会弹在运行 PiWeb 的那台机器的屏幕上,手机这边等于没反应。
|
|
8
|
+
* 只列目录;选出的路径仍由服务端的 resolveWorkspacePath 校验后才写进工作区。
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useState, type CSSProperties } from "react";
|
|
11
|
+
import { folderIconSrc } from "@/lib/file-icons";
|
|
12
|
+
import { IconChevronLeft14 } from "@/components/icons";
|
|
13
|
+
import { useI18n } from "@/i18n";
|
|
14
|
+
|
|
15
|
+
interface FsEntry {
|
|
16
|
+
name: string;
|
|
17
|
+
path: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Listing {
|
|
21
|
+
path: string;
|
|
22
|
+
parent: string | null;
|
|
23
|
+
entries: FsEntry[];
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function post(action: "roots" | "list", target?: string): Promise<Record<string, unknown>> {
|
|
28
|
+
const r = await fetch("/api/fs", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify(target === undefined ? { action } : { action, path: target }),
|
|
32
|
+
});
|
|
33
|
+
const j = (await r.json()) as { success?: boolean; data?: Record<string, unknown>; error?: string };
|
|
34
|
+
if (!r.ok || !j.success) throw new Error(j.error || `request failed (${r.status})`);
|
|
35
|
+
return j.data ?? {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function DirectoryPicker({
|
|
39
|
+
open,
|
|
40
|
+
onConfirm,
|
|
41
|
+
onCancel,
|
|
42
|
+
}: {
|
|
43
|
+
open: boolean;
|
|
44
|
+
onConfirm: (path: string) => void;
|
|
45
|
+
onCancel: () => void;
|
|
46
|
+
}) {
|
|
47
|
+
const { t } = useI18n();
|
|
48
|
+
const [roots, setRoots] = useState<FsEntry[]>([]);
|
|
49
|
+
const [listing, setListing] = useState<Listing | null>(null);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
|
|
53
|
+
const load = useCallback(async (target?: string) => {
|
|
54
|
+
setBusy(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
try {
|
|
57
|
+
setListing((await post("list", target)) as unknown as Listing);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
60
|
+
} finally {
|
|
61
|
+
setBusy(false);
|
|
62
|
+
}
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!open) return;
|
|
67
|
+
setListing(null);
|
|
68
|
+
setError(null);
|
|
69
|
+
setRoots([]);
|
|
70
|
+
void (async () => {
|
|
71
|
+
try {
|
|
72
|
+
const data = await post("roots");
|
|
73
|
+
setRoots((data.roots as FsEntry[]) ?? []);
|
|
74
|
+
} catch {
|
|
75
|
+
// 拿不到起点不影响从主目录开始
|
|
76
|
+
}
|
|
77
|
+
await load();
|
|
78
|
+
})();
|
|
79
|
+
}, [open, load]);
|
|
80
|
+
|
|
81
|
+
if (!open) return null;
|
|
82
|
+
|
|
83
|
+
const rowStyle: CSSProperties = { fontSize: 13, color: "var(--dsw-label-secondary)" };
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="modal-mask fixed inset-0 z-[100] flex items-center justify-center"
|
|
87
|
+
onMouseDown={(e) => {
|
|
88
|
+
if (e.target === e.currentTarget) onCancel();
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<div
|
|
92
|
+
className="glass-modal flex flex-col overflow-hidden"
|
|
93
|
+
style={{
|
|
94
|
+
width: 560,
|
|
95
|
+
maxWidth: "calc(100vw - 32px)",
|
|
96
|
+
height: "min(620px, calc(100vh - 32px))",
|
|
97
|
+
borderRadius: 24,
|
|
98
|
+
boxShadow: "var(--dsw-elevation-prominent)",
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<div className="px-5 pb-2 pt-4" style={{ fontSize: 15, fontWeight: 500 }}>
|
|
102
|
+
{t.pickFolderTitle}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{roots.length > 0 && (
|
|
106
|
+
<div className="flex flex-wrap gap-1.5 px-5 pb-2">
|
|
107
|
+
{roots.map((root) => (
|
|
108
|
+
<button key={root.path} type="button" className="pw-chip" title={root.path} onClick={() => void load(root.path)}>
|
|
109
|
+
{root.name}
|
|
110
|
+
</button>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<div className="flex items-center gap-1.5 px-5 pb-2">
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
className="icon-btn"
|
|
119
|
+
style={{ width: 24, height: 24, flex: "none" }}
|
|
120
|
+
disabled={!listing?.parent || busy}
|
|
121
|
+
title={t.pickFolderUp}
|
|
122
|
+
aria-label={t.pickFolderUp}
|
|
123
|
+
onClick={() => listing?.parent && void load(listing.parent)}
|
|
124
|
+
>
|
|
125
|
+
<IconChevronLeft14 size={13} />
|
|
126
|
+
</button>
|
|
127
|
+
<span
|
|
128
|
+
className="truncate"
|
|
129
|
+
style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--dsw-label-caption)" }}
|
|
130
|
+
title={listing?.path}
|
|
131
|
+
>
|
|
132
|
+
{listing?.path ?? t.pickFolderLoading}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-3">
|
|
137
|
+
{error && <p className="px-2 py-2" style={{ fontSize: 13, color: "var(--dsw-danger)" }}>{error}</p>}
|
|
138
|
+
{listing?.entries.map((entry) => (
|
|
139
|
+
<button
|
|
140
|
+
key={entry.path}
|
|
141
|
+
type="button"
|
|
142
|
+
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors"
|
|
143
|
+
style={rowStyle}
|
|
144
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--dsw-hover)"; }}
|
|
145
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
|
146
|
+
onClick={() => void load(entry.path)}
|
|
147
|
+
>
|
|
148
|
+
<img src={folderIconSrc(entry.name, false)} alt="" width={15} height={15} style={{ flex: "none" }} draggable={false} />
|
|
149
|
+
<span className="truncate">{entry.name}</span>
|
|
150
|
+
</button>
|
|
151
|
+
))}
|
|
152
|
+
{!error && listing && listing.entries.length === 0 && (
|
|
153
|
+
<p className="px-2 py-2" style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>{t.pickFolderEmpty}</p>
|
|
154
|
+
)}
|
|
155
|
+
{listing?.truncated && (
|
|
156
|
+
<p className="px-2 py-2" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>
|
|
157
|
+
{t.pickFolderTruncated.replace("{n}", String(listing.entries.length))}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3.5">
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className="rounded-xl px-3.5 py-1.5 transition-colors"
|
|
166
|
+
style={{ fontSize: 13, color: "var(--dsw-label-secondary)", background: "var(--dsw-hover)" }}
|
|
167
|
+
onClick={onCancel}
|
|
168
|
+
>
|
|
169
|
+
{t.cancel}
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
className="rounded-xl px-3.5 py-1.5 transition-colors"
|
|
174
|
+
style={{
|
|
175
|
+
fontSize: 13,
|
|
176
|
+
color: "white",
|
|
177
|
+
background: "var(--dsw-accent)",
|
|
178
|
+
opacity: !listing || busy ? 0.5 : 1,
|
|
179
|
+
}}
|
|
180
|
+
disabled={!listing || busy}
|
|
181
|
+
onClick={() => listing && onConfirm(listing.path)}
|
|
182
|
+
>
|
|
183
|
+
{t.pickFolderConfirm}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 右侧抽屉:顶部一列入口(可点选),下面渲染所选入口的内容。
|
|
5
|
+
*
|
|
6
|
+
* 抽屉是网格里的一列(不是浮层):打开时对话区变窄,和项目栏占用一列的方式一样;
|
|
7
|
+
* 窄屏时由 AppShell 摆成贴右边的 fixed 覆盖层。
|
|
8
|
+
* 这一列与「轨迹详情」(TrajInspector)互斥:点对话里的工具行时整列切成轨迹详情,
|
|
9
|
+
* 关掉后回到抽屉,并回到抽屉原本选中的那个入口——所以选中项由 AppShell 持有,
|
|
10
|
+
* 不能放在本组件的局部 state 里,否则轨迹详情来回切换会丢掉入口。
|
|
11
|
+
*/
|
|
12
|
+
import { PromptPanel } from "@/components/PromptPanel";
|
|
13
|
+
import { GitPanel } from "@/components/GitPanel";
|
|
14
|
+
import { ProjectPanel } from "@/components/ProjectPanel";
|
|
15
|
+
import { SessionImportSection } from "@/components/SessionImportSection";
|
|
16
|
+
import { IconCloseOutline14, IconDownloadOutline16, IconAgentPresetOutline16, IconGitOutline16, IconFolderOpenOutline16 } from "@/components/icons";
|
|
17
|
+
import type { GrowthApi } from "@/hooks/useGrowth";
|
|
18
|
+
import { useI18n } from "@/i18n";
|
|
19
|
+
import type { TreeNode } from "@/lib/growth-tree";
|
|
20
|
+
|
|
21
|
+
/** 抽屉里的入口,顺序即顶部入口列表的显示顺序:项目 → 提示词 → Git 详情 → 会话导入 */
|
|
22
|
+
export type DrawerSection = "project" | "prompts" | "git" | "import";
|
|
23
|
+
|
|
24
|
+
export function DrawerPanel({
|
|
25
|
+
cwd,
|
|
26
|
+
section,
|
|
27
|
+
onSectionChange,
|
|
28
|
+
onClose,
|
|
29
|
+
sessionId,
|
|
30
|
+
promptRefreshKey,
|
|
31
|
+
gitRefreshKey,
|
|
32
|
+
growth,
|
|
33
|
+
workspaceName,
|
|
34
|
+
hasSession,
|
|
35
|
+
running,
|
|
36
|
+
onOpenFileNode,
|
|
37
|
+
onReference,
|
|
38
|
+
onOpenEditor,
|
|
39
|
+
onJumpToChat,
|
|
40
|
+
onError,
|
|
41
|
+
onOpenContent,
|
|
42
|
+
onAskCommit,
|
|
43
|
+
onOpenFile,
|
|
44
|
+
}: {
|
|
45
|
+
cwd: string;
|
|
46
|
+
/** 当前入口(由 AppShell 持有:轨迹详情关掉后要回到这个入口) */
|
|
47
|
+
section: DrawerSection;
|
|
48
|
+
onSectionChange: (section: DrawerSection) => void;
|
|
49
|
+
onClose: () => void;
|
|
50
|
+
sessionId: string | null;
|
|
51
|
+
/** 提示词面板重新拉取的键(切换会话) */
|
|
52
|
+
promptRefreshKey?: string | number | null;
|
|
53
|
+
/** Git 面板重新拉取的键(回合结束 / pi 改了文件) */
|
|
54
|
+
gitRefreshKey?: number;
|
|
55
|
+
/** 项目入口用的生长视图状态(AppShell 里 useGrowth 不随抽屉卸载) */
|
|
56
|
+
growth: GrowthApi;
|
|
57
|
+
workspaceName: string;
|
|
58
|
+
hasSession: boolean;
|
|
59
|
+
/** 本会话正在运行:时间轴末尾显示「本轮进行中」 */
|
|
60
|
+
running?: boolean;
|
|
61
|
+
/** 项目树打开文件(打开居中查看器) */
|
|
62
|
+
onOpenFileNode: (node: TreeNode) => void;
|
|
63
|
+
/** 项目树的「引用到输入框」 */
|
|
64
|
+
onReference?: (path: string) => void;
|
|
65
|
+
/** 项目树的「在本机编辑器打开」(相对仓库根的路径) */
|
|
66
|
+
onOpenEditor?: (path: string) => void;
|
|
67
|
+
/** 跳到对话里这一轮的提问(用户消息 entryId) */
|
|
68
|
+
onJumpToChat?: (messageId: string) => void;
|
|
69
|
+
onError: (message: string) => void;
|
|
70
|
+
/** 提示词正文交给居中查看器 */
|
|
71
|
+
onOpenContent?: (path: string, content: string) => void;
|
|
72
|
+
/** 把「请提交当前变更」填进输入框 */
|
|
73
|
+
onAskCommit?: () => void;
|
|
74
|
+
/** 在本机编辑器打开(Git 文件) */
|
|
75
|
+
onOpenFile?: (absPath: string) => void;
|
|
76
|
+
}) {
|
|
77
|
+
const { t } = useI18n();
|
|
78
|
+
|
|
79
|
+
const entries: { id: DrawerSection; label: string; icon: React.ReactNode }[] = [
|
|
80
|
+
// 入口只用现有的 i18n 键;「提示词」是入口短名,和面板标题「提示词来源」区分开
|
|
81
|
+
{ id: "project", label: t.projectPanel, icon: <IconFolderOpenOutline16 size={15} /> },
|
|
82
|
+
{ id: "prompts", label: t.drawerPrompts, icon: <IconAgentPresetOutline16 size={15} /> },
|
|
83
|
+
{ id: "git", label: t.gitDetails, icon: <IconGitOutline16 size={15} /> },
|
|
84
|
+
// 「导入会话」的设置栏已移除,文案复用在这里
|
|
85
|
+
{ id: "import", label: t.importSessions, icon: <IconDownloadOutline16 size={15} /> },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex h-full min-h-0 flex-col" data-testid="drawer-panel">
|
|
90
|
+
{/* 头部:与 PromptPanel / ProjectPanel 同一套排布。
|
|
91
|
+
提示词 / Git 两个面板自带同款标题行(含关闭),这里就不再叠一层;
|
|
92
|
+
「导入会话」段落没有标题行,抽屉自己出这行,并关掉整个抽屉。 */}
|
|
93
|
+
{section === "import" && (
|
|
94
|
+
<div className="hairline-b flex items-center gap-1.5 px-3 pb-2 pt-3">
|
|
95
|
+
<span className="min-w-0 flex-1 truncate" style={{ fontSize: 13, fontWeight: 600 }}>
|
|
96
|
+
{t.drawerPanel}
|
|
97
|
+
</span>
|
|
98
|
+
<button className="icon-btn" style={{ width: 22, height: 22 }} title={t.close} aria-label={t.close} onClick={onClose}>
|
|
99
|
+
<IconCloseOutline14 size={13} />
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* 入口列表:横向一排图标,不显示文字(名字放 title / aria-label)。
|
|
105
|
+
激活态复用设置页那套下划线(.settings-tab-underline),不自创样式。 */}
|
|
106
|
+
<div className="hairline-b flex items-stretch gap-1 px-2" role="tablist" aria-label={t.drawerSections}>
|
|
107
|
+
{entries.map((entry) => (
|
|
108
|
+
<button
|
|
109
|
+
key={entry.id}
|
|
110
|
+
type="button"
|
|
111
|
+
role="tab"
|
|
112
|
+
aria-selected={section === entry.id}
|
|
113
|
+
className="settings-tab-underline flex flex-1 items-center justify-center rounded-t-lg"
|
|
114
|
+
data-active={section === entry.id}
|
|
115
|
+
title={entry.label}
|
|
116
|
+
aria-label={entry.label}
|
|
117
|
+
onMouseEnter={(e) => {
|
|
118
|
+
if (section !== entry.id) e.currentTarget.style.background = "var(--dsw-hover)";
|
|
119
|
+
}}
|
|
120
|
+
onMouseLeave={(e) => {
|
|
121
|
+
if (section !== entry.id) e.currentTarget.style.background = "transparent";
|
|
122
|
+
}}
|
|
123
|
+
onClick={() => onSectionChange(entry.id)}
|
|
124
|
+
data-testid={`drawer-entry-${entry.id}`}
|
|
125
|
+
>
|
|
126
|
+
{entry.icon}
|
|
127
|
+
</button>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* 所选入口的内容:项目 / 提示词 / Git 三个面板自带标题行与滚动,导入段落沿用抽屉的留白 */}
|
|
132
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
133
|
+
{section === "import" ? (
|
|
134
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
|
135
|
+
<SessionImportSection cwd={cwd} />
|
|
136
|
+
</div>
|
|
137
|
+
) : section === "project" ? (
|
|
138
|
+
<ProjectPanel
|
|
139
|
+
growth={growth}
|
|
140
|
+
workspaceName={workspaceName}
|
|
141
|
+
cwd={cwd}
|
|
142
|
+
hasSession={hasSession}
|
|
143
|
+
running={running}
|
|
144
|
+
onOpenFile={onOpenFileNode}
|
|
145
|
+
onReference={onReference}
|
|
146
|
+
onOpenEditor={onOpenEditor}
|
|
147
|
+
onJumpToChat={onJumpToChat}
|
|
148
|
+
gitRefreshKey={gitRefreshKey}
|
|
149
|
+
onAskCommit={onAskCommit}
|
|
150
|
+
// 「Git 详情」按钮切到抽屉的 git 入口(项目栏不再自己开一个面板)
|
|
151
|
+
onOpenGit={() => onSectionChange("git")}
|
|
152
|
+
onClose={onClose}
|
|
153
|
+
onError={onError}
|
|
154
|
+
/>
|
|
155
|
+
) : section === "prompts" ? (
|
|
156
|
+
// 三个面板的关闭按钮都收起整个抽屉(它们自己不再有"关闭"以外的语义)
|
|
157
|
+
<PromptPanel cwd={cwd} sessionId={sessionId} refreshKey={promptRefreshKey} onOpenContent={onOpenContent} onClose={onClose} />
|
|
158
|
+
) : (
|
|
159
|
+
<GitPanel cwd={cwd} refreshKey={gitRefreshKey} onClose={onClose} onAskCommit={onAskCommit} onOpenFile={onOpenFile} />
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -13,7 +13,7 @@ import remarkGfm from "remark-gfm";
|
|
|
13
13
|
import { parseUnifiedDiff, type DiffLine } from "@/components/DiffView";
|
|
14
14
|
import { FullDiff, type FullDiffHandle } from "@/components/FullDiff";
|
|
15
15
|
import { GrowthTree } from "@/components/GrowthTree";
|
|
16
|
-
import { IconChevronDown14, IconChevronUp14, IconCloseOutline14, IconEditOutline16, IconFileOutline16, IconPlusOutline16, IconSearchOutline16 } from "@/components/icons";
|
|
16
|
+
import { IconAtOutline16, IconChevronDown14, IconChevronUp14, IconCloseOutline14, IconEditOutline16, IconFileOutline16, IconPlusOutline16, IconSearchOutline16 } from "@/components/icons";
|
|
17
17
|
import type { GrowthApi } from "@/hooks/useGrowth";
|
|
18
18
|
import { useI18n } from "@/i18n";
|
|
19
19
|
import { fileIconSrc } from "@/lib/file-icons";
|
|
@@ -422,7 +422,7 @@ export function FileViewer({
|
|
|
422
422
|
)}
|
|
423
423
|
{onReference && (
|
|
424
424
|
<button type="button" className="icon-btn" style={{ width: 24, height: 24 }} title={t.fileReference} onClick={() => onReference(activeTab.path)}>
|
|
425
|
-
<
|
|
425
|
+
<IconAtOutline16 size={13} />
|
|
426
426
|
</button>
|
|
427
427
|
)}
|
|
428
428
|
{onOpenEditor && (
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 生成中呼吸态、新步入场动画、窗口化渲染、键盘导航(↑↓ 移动,←→ 收展,Enter 打开)。
|
|
7
7
|
*/
|
|
8
8
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
-
import {
|
|
9
|
+
import { IconAtOutline16, IconFileOutline16, IconTriangleRightFill14 } from "@/components/icons";
|
|
10
10
|
import { useI18n } from "@/i18n";
|
|
11
11
|
import { fileIconSrc, folderIconSrc } from "@/lib/file-icons";
|
|
12
12
|
import { flattenTree, treeNodeKey, type FlatRow, type TreeNode } from "@/lib/growth-tree";
|
|
@@ -264,7 +264,7 @@ const TreeRow = memo(function TreeRow({
|
|
|
264
264
|
{node.status === "R" && node.from && <span className="pw-tree-from">← {node.from.split("/").pop()}</span>}
|
|
265
265
|
</span>
|
|
266
266
|
{node.pending && <span className="pw-tree-pulse" aria-label={pendingLabel} />}
|
|
267
|
-
{!
|
|
267
|
+
{!node.lazy && (onReference || (!isDir && onOpenEditor)) && (
|
|
268
268
|
<span className="pw-tree-actions">
|
|
269
269
|
{onReference && (
|
|
270
270
|
<button
|
|
@@ -274,13 +274,14 @@ const TreeRow = memo(function TreeRow({
|
|
|
274
274
|
title={referenceLabel}
|
|
275
275
|
onClick={(e) => {
|
|
276
276
|
e.stopPropagation();
|
|
277
|
-
|
|
277
|
+
// 目录带尾斜杠:发送时是 @dir/,和文件引用区分得开
|
|
278
|
+
onReference(isDir ? `${node.path.replace(/[\\/]+$/, "")}/` : node.path);
|
|
278
279
|
}}
|
|
279
280
|
>
|
|
280
|
-
<
|
|
281
|
+
<IconAtOutline16 size={11} />
|
|
281
282
|
</button>
|
|
282
283
|
)}
|
|
283
|
-
{onOpenEditor && (
|
|
284
|
+
{!isDir && onOpenEditor && (
|
|
284
285
|
<button
|
|
285
286
|
type="button"
|
|
286
287
|
className="icon-btn"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown 正文渲染(消息、流式回答、用户气泡共用)。
|
|
5
|
+
*
|
|
6
|
+
* 单独成模块是为了让它成为**懒加载分块**:react-markdown + remark-gfm + rehype-highlight
|
|
7
|
+
* 带着 lowlight/highlight.js 的常用语言集,实测占首屏 JS 的 472KB(全部首屏 JS 1272KB),
|
|
8
|
+
* 而 Hero 页(还没开会话)根本用不到它。ChatWindow 用 next/dynamic 引用本模块,
|
|
9
|
+
* 首屏不再下载这 472KB;AppShell 在空闲时预取,真正打开会话时通常已经就绪。
|
|
10
|
+
*/
|
|
11
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
12
|
+
import ReactMarkdown from "react-markdown";
|
|
13
|
+
import rehypeHighlight from "rehype-highlight";
|
|
14
|
+
import remarkGfm from "remark-gfm";
|
|
15
|
+
import { IconCheckOutline14, IconCopyOutline16 } from "@/components/icons";
|
|
16
|
+
import { copyText } from "@/lib/clipboard";
|
|
17
|
+
import { useI18n } from "@/i18n";
|
|
18
|
+
|
|
19
|
+
type MarkdownTextNode = { value?: unknown; children?: MarkdownTextNode[] };
|
|
20
|
+
|
|
21
|
+
function markdownNodeText(node: MarkdownTextNode | undefined): string {
|
|
22
|
+
if (!node) return "";
|
|
23
|
+
if (typeof node.value === "string") return node.value;
|
|
24
|
+
return node.children?.map(markdownNodeText).join("") ?? "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CopyButton({ text }: { text: string }) {
|
|
28
|
+
const [copied, setCopied] = useState(false);
|
|
29
|
+
const [copyFailed, setCopyFailed] = useState(false);
|
|
30
|
+
const { t } = useI18n();
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
className="icon-btn"
|
|
36
|
+
title={copyFailed ? t.copyFailed : t.copy}
|
|
37
|
+
aria-label={t.copy}
|
|
38
|
+
onClick={() => {
|
|
39
|
+
void copyText(text).then((ok) => {
|
|
40
|
+
setCopied(ok);
|
|
41
|
+
setCopyFailed(!ok);
|
|
42
|
+
setTimeout(() => { setCopied(false); setCopyFailed(false); }, 1200);
|
|
43
|
+
});
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{copied ? <IconCheckOutline14 size={14} /> : <IconCopyOutline16 size={14} />}
|
|
47
|
+
</button>
|
|
48
|
+
{copyFailed && <span role="alert" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{t.copyFailed}</span>}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 同源图片可以直接显示;外部地址不行(见 SafeImage)。data: 早被 react-markdown 的默认 URL 过滤掉了 */
|
|
54
|
+
function isInlineImage(src: string): boolean {
|
|
55
|
+
if (typeof window === "undefined") return false;
|
|
56
|
+
try {
|
|
57
|
+
return new URL(src, window.location.href).origin === window.location.origin;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 外部图片默认不加载,点了才加载。
|
|
65
|
+
* 模型输出可能被提示词注入(读到的网页、文件里藏着指令),让它写一张
|
|
66
|
+
* ``,浏览器一渲染就把内容发出去了 —— 不需要用户点任何东西。
|
|
67
|
+
*/
|
|
68
|
+
function SafeImage({ src, alt, title }: { src?: unknown; alt?: string; title?: string }) {
|
|
69
|
+
const [allowed, setAllowed] = useState(false);
|
|
70
|
+
const { t } = useI18n();
|
|
71
|
+
const source = typeof src === "string" ? src : "";
|
|
72
|
+
if (!source) return null;
|
|
73
|
+
if (allowed || isInlineImage(source)) return <img src={source} alt={alt ?? ""} title={title} />;
|
|
74
|
+
let host = source;
|
|
75
|
+
try {
|
|
76
|
+
host = new URL(source).host || source;
|
|
77
|
+
} catch {
|
|
78
|
+
/* 保留原样 */
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
<button type="button" className="btn-outline" style={{ height: 26, padding: "0 10px", fontSize: 12 }} title={source} onClick={() => setAllowed(true)}>
|
|
82
|
+
{t.externalImage}{alt ? `:${alt}` : ""}({host})
|
|
83
|
+
</button>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const MarkdownBlock = memo(function MarkdownBlock({ text }: { text: string }) {
|
|
88
|
+
return (
|
|
89
|
+
<div className="md">
|
|
90
|
+
<ReactMarkdown
|
|
91
|
+
remarkPlugins={[remarkGfm]}
|
|
92
|
+
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
|
|
93
|
+
components={{
|
|
94
|
+
a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
|
95
|
+
img: ({ src, alt, title }) => <SafeImage src={src} alt={alt} title={title} />,
|
|
96
|
+
pre: ({ node, children, ...props }) => (
|
|
97
|
+
<div className="md-code-block">
|
|
98
|
+
<div className="md-code-copy"><CopyButton text={markdownNodeText(node)} /></div>
|
|
99
|
+
<pre {...props}>{children}</pre>
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{text}
|
|
105
|
+
</ReactMarkdown>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** 流式正文的节流渲染(见 ChatWindow 的说明):这里只负责延迟把稳定文本交给 MarkdownBlock */
|
|
111
|
+
const STREAM_MARKDOWN_THROTTLE_MS = 100;
|
|
112
|
+
|
|
113
|
+
export const StreamingMarkdownBlock = memo(function StreamingMarkdownBlock({ text }: { text: string }) {
|
|
114
|
+
const [shown, setShown] = useState(text);
|
|
115
|
+
const latest = useRef(text);
|
|
116
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
latest.current = text;
|
|
119
|
+
// 节流而不是防抖:计时器只在空闲时才设,期间到来的新文本只更新 latest、不重置计时。
|
|
120
|
+
// 若每次变化都清掉重来,文本一直在流(约 16ms 一次)时计时器永远到不了点,正文就冻住了。
|
|
121
|
+
if (text === shown || timer.current) return;
|
|
122
|
+
timer.current = setTimeout(() => {
|
|
123
|
+
timer.current = null;
|
|
124
|
+
setShown(latest.current);
|
|
125
|
+
}, STREAM_MARKDOWN_THROTTLE_MS);
|
|
126
|
+
}, [text, shown]);
|
|
127
|
+
useEffect(() => () => {
|
|
128
|
+
if (timer.current) clearTimeout(timer.current);
|
|
129
|
+
timer.current = null;
|
|
130
|
+
}, []);
|
|
131
|
+
return <MarkdownBlock text={shown} />;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export default MarkdownBlock;
|
|
@@ -36,7 +36,10 @@ export function OutlineRail({ scrollRef, revision }: { scrollRef: React.RefObjec
|
|
|
36
36
|
for (const node of roots) {
|
|
37
37
|
if (node.dataset.role === "user") {
|
|
38
38
|
userIndex += 1;
|
|
39
|
-
|
|
39
|
+
// 只取气泡里的正文:用户行里还有时间戳和操作按钮,整行 textContent 会混进「10月7日 16:49」;
|
|
40
|
+
// 正文还没加载出来时宁可先不出这一项,等下面的 MutationObserver 再收一次
|
|
41
|
+
const bubble = node.querySelector<HTMLElement>(".msg-user-bubble") ?? node;
|
|
42
|
+
const text = (bubble.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
40
43
|
if (text) out.push({ key: `u${userIndex}`, level: 0, text: text.slice(0, 80), el: node });
|
|
41
44
|
continue;
|
|
42
45
|
}
|
|
@@ -58,6 +61,24 @@ export function OutlineRail({ scrollRef, revision }: { scrollRef: React.RefObjec
|
|
|
58
61
|
return () => clearTimeout(timer);
|
|
59
62
|
}, [collect, revision]);
|
|
60
63
|
|
|
64
|
+
// 正文是按需加载的 Markdown(见 ChatWindow 的 dynamic import):刷新后直接恢复会话时,快照到了
|
|
65
|
+
// 正文可能还没出来,这时收到的用户项只剩时间戳、标题一个都没有;懒渲染的回合滚到附近才升级。
|
|
66
|
+
// 这些都不改 revision,所以盯 DOM 结构变化再收一次。只看 childList,流式逐字改文本不触发;防抖合并成一次。
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const root = scrollRef.current;
|
|
69
|
+
if (!root) return;
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
71
|
+
const observer = new MutationObserver(() => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
timer = setTimeout(collect, 300);
|
|
74
|
+
});
|
|
75
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
76
|
+
return () => {
|
|
77
|
+
observer.disconnect();
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
};
|
|
80
|
+
}, [collect, scrollRef]);
|
|
81
|
+
|
|
61
82
|
// 随滚动高亮:最后一个顶部已越过视口上沿 + 80px 的锚点
|
|
62
83
|
useEffect(() => {
|
|
63
84
|
const root = scrollRef.current;
|