diff --git a/src/frontend/client/src/components/Chat/ChatView.tsx b/src/frontend/client/src/components/Chat/ChatView.tsx index 65837dac1..5892f6c5e 100644 --- a/src/frontend/client/src/components/Chat/ChatView.tsx +++ b/src/frontend/client/src/components/Chat/ChatView.tsx @@ -363,7 +363,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? // generated deliverables. The drawer only opens on the header button — no // auto-expand (the entry icon appearing is enough). const { getLinsight, updateLinsight } = useLinsightManager(); - const taskArtifacts = useWorkspacePanel(); + const taskArtifacts = useWorkspacePanel(latestTaskVersionId); // F035: enter/exit animation for the fullscreen workspace overlay. The overlay // is a separate instance from the docked panel (PreviewBody isn't cached, so we diff --git a/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.test.ts b/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.test.ts new file mode 100644 index 000000000..2c99b3049 --- /dev/null +++ b/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.test.ts @@ -0,0 +1,53 @@ +import { openHtmlArtifactViewer } from './artifactUtils'; + +describe('openHtmlArtifactViewer', () => { + const origEnv = (global as any).__APP_ENV__; + let openSpy: jest.SpyInstance; + + beforeEach(() => { + (global as any).__APP_ENV__ = { ...origEnv, BASE_URL: '/workspace' }; + openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + }); + + afterEach(() => { + (global as any).__APP_ENV__ = origEnv; + openSpy.mockRestore(); + }); + + // Regression: file_url is a MinIO object key (no leading slash). The old code + // concatenated it straight onto BASE_URL → `/workspacelinsight/...` (missing + // slash 404). It must instead go through the /html viewer as a query param, + // carrying the vid so the viewer can resolve a presigned link. + it('passes url + vid as query params and never concatenates the key onto BASE_URL', () => { + openHtmlArtifactViewer( + { + file_id: '1', + file_name: '减肥计划.html', + file_url: 'linsight/final_result/abc/减肥计划.html', + }, + 'SV-1', + ); + + expect(openSpy).toHaveBeenCalledTimes(1); + const opened = openSpy.mock.calls[0][0] as string; + + expect(opened).not.toContain('/workspacelinsight'); + expect(opened.startsWith('/workspace/html?')).toBe(true); + + const qs = new URLSearchParams(opened.split('?')[1]); + expect(qs.get('url')).toBe('linsight/final_result/abc/减肥计划.html'); + expect(qs.get('vid')).toBe('SV-1'); + expect(openSpy.mock.calls[0][1]).toBe('_blank'); + }); + + it('tolerates an empty versionId (vid present but blank)', () => { + openHtmlArtifactViewer( + { file_id: '2', file_name: 'a.html', file_url: 'linsight/final_result/x/a.html' }, + '', + ); + const opened = openSpy.mock.calls[0][0] as string; + const qs = new URLSearchParams(opened.split('?')[1]); + expect(qs.get('vid')).toBe(''); + expect(qs.get('url')).toBe('linsight/final_result/x/a.html'); + }); +}); diff --git a/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.ts b/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.ts index 9e37d1890..f563a969e 100644 --- a/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.ts +++ b/src/frontend/client/src/components/Linsight/Artifacts/artifactUtils.ts @@ -91,6 +91,22 @@ export async function resolveArtifactUrl(fileUrl: string, versionId: string): Pr return `${__APP_ENV__.BASE_URL}${res.data.file_path}`; } +/** + * Open an HTML artifact in the standalone sandboxed viewer tab (`/html`). + * + * `file.file_url` is a MinIO OBJECT KEY (e.g. `linsight/final_result//x.html`), + * not a directly servable URL — it must be resolved into a presigned share link + * via the file_download API (see resolveArtifactUrl). The viewer therefore needs + * the session_version_id to resolve it, so we pass it as `vid`. Building the query + * with URLSearchParams also fixes the old bug where the raw key was concatenated + * straight onto BASE_URL (`/workspace` + `linsight/...` → `/workspacelinsight/...`, + * a missing-slash 404). + */ +export function openHtmlArtifactViewer(file: ArtifactFile, versionId: string): void { + const params = new URLSearchParams({ url: file.file_url, vid: versionId || '' }); + window.open(`${__APP_ENV__.BASE_URL}/html?${params.toString()}`, '_blank'); +} + /** Download the original artifact file ("save as" action). */ export async function downloadArtifactFile(file: ArtifactFile, versionId: string): Promise { const url = await resolveArtifactUrl(file.file_url, versionId); diff --git a/src/frontend/client/src/components/Linsight/Artifacts/useArtifactsPanel.ts b/src/frontend/client/src/components/Linsight/Artifacts/useArtifactsPanel.ts index cd54b1449..44a7d87c2 100644 --- a/src/frontend/client/src/components/Linsight/Artifacts/useArtifactsPanel.ts +++ b/src/frontend/client/src/components/Linsight/Artifacts/useArtifactsPanel.ts @@ -6,9 +6,9 @@ * so no Recoil atom is needed. */ import { useState } from 'react'; -import type { ArtifactFile } from './artifactUtils'; +import { openHtmlArtifactViewer, type ArtifactFile } from './artifactUtils'; -export function useArtifactsPanel() { +export function useArtifactsPanel(versionId: string) { const [workspaceOpen, setWorkspaceOpen] = useState(false); const [previewFile, setPreviewFile] = useState(null); const [fromWorkspace, setFromWorkspace] = useState(false); @@ -19,9 +19,11 @@ export function useArtifactsPanel() { }; const openPreview = (file: ArtifactFile, viaWorkspace = false) => { - // html artifacts open in the standalone viewer tab (same as legacy) + // html artifacts open in the standalone sandboxed viewer tab (the side + // panel can't render a full HTML document); needs versionId to resolve + // the MinIO object key into a presigned link. if (file.file_name?.toLowerCase().endsWith('.html')) { - window.open(`${__APP_ENV__.BASE_URL}/html?url=${encodeURIComponent(file.file_url)}`, '_blank'); + openHtmlArtifactViewer(file, versionId); return; } setWorkspaceOpen(false); diff --git a/src/frontend/client/src/components/Linsight/Artifacts/useWorkspacePanel.ts b/src/frontend/client/src/components/Linsight/Artifacts/useWorkspacePanel.ts index 265006bd4..cda01f7a3 100644 --- a/src/frontend/client/src/components/Linsight/Artifacts/useWorkspacePanel.ts +++ b/src/frontend/client/src/components/Linsight/Artifacts/useWorkspacePanel.ts @@ -11,9 +11,9 @@ * - open && previewFile → in-place preview view (fig. preview) */ import { useState } from 'react'; -import type { ArtifactFile } from './artifactUtils'; +import { openHtmlArtifactViewer, type ArtifactFile } from './artifactUtils'; -export function useWorkspacePanel() { +export function useWorkspacePanel(versionId: string) { const [open, setOpen] = useState(false); const [previewFile, setPreviewFile] = useState(null); const [fullscreen, setFullscreen] = useState(false); @@ -32,10 +32,11 @@ export function useWorkspacePanel() { setOpen(false); }; - /** Preview a file in place. html artifacts still open in the standalone tab. */ + /** Preview a file in place. html artifacts still open in the standalone tab + * (needs versionId to resolve the MinIO object key into a presigned link). */ const openPreview = (file: ArtifactFile) => { if (file.file_name?.toLowerCase().endsWith('.html')) { - window.open(`${__APP_ENV__.BASE_URL}/html?url=${encodeURIComponent(file.file_url)}`, '_blank'); + openHtmlArtifactViewer(file, versionId); return; } setOpen(true); diff --git a/src/frontend/client/src/components/Sop/index.tsx b/src/frontend/client/src/components/Sop/index.tsx index 7129e478d..95158cffb 100644 --- a/src/frontend/client/src/components/Sop/index.tsx +++ b/src/frontend/client/src/components/Sop/index.tsx @@ -23,7 +23,7 @@ export default function index({ id = '', vid = '', shareToken = '' }) { const { loading, versionId, setVersionId, switchVersion, versions, setVersions } = useLinsightData({ conversationId, sopId, vid, shareToken }); const [isLoading, error] = useLinsightSubmit(versionId, setVersionId, setVersions) const { getLinsight } = useLinsightManager() - const artifactsPanel = useArtifactsPanel(); + const artifactsPanel = useArtifactsPanel(versionId); return (
diff --git a/src/frontend/client/src/components/WebView.tsx b/src/frontend/client/src/components/WebView.tsx index 62e4a5973..f590c3248 100644 --- a/src/frontend/client/src/components/WebView.tsx +++ b/src/frontend/client/src/components/WebView.tsx @@ -1,31 +1,49 @@ import { useEffect, useState } from "react"; -import { useParams, useSearchParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; +import { resolveArtifactUrl } from "~/components/Linsight/Artifacts/artifactUtils"; export default function WebView() { const [searchParams] = useSearchParams(); const url = searchParams.get('url'); + // Linsight HTML artifacts pass the owning session_version_id so the MinIO + // object key can be resolved into a presigned share link (the key itself is + // not directly servable). + const vid = searchParams.get('vid'); const [content, setContent] = useState(''); useEffect(() => { - const baseUrl = `${__APP_ENV__.BASE_URL}${decodeURIComponent(url || '')}` + const fileUrl = decodeURIComponent(url || ''); + if (!fileUrl) return; + + let cancelled = false; const fetchTextFile = async () => { try { - const response = await fetch(baseUrl); + // With a vid, resolve the object key -> presigned link (same path + // the side preview panel uses). Without it, fall back to a plain + // BASE_URL join, guarding the leading slash so we never produce + // `/workspacelinsight/...` (the old missing-slash 404). + const fetchUrl = vid + ? await resolveArtifactUrl(fileUrl, vid) + : `${__APP_ENV__.BASE_URL}/${fileUrl.replace(/^\/+/, '')}`; + const response = await fetch(fetchUrl); if (!response.ok) { throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); } - const text = await response.text(); - setContent(text); + if (!cancelled) setContent(text); } catch (err) { - setContent(''); + console.error('WebView failed to load html artifact:', err); + if (!cancelled) setContent(''); } }; fetchTextFile(); - }, [url]); + return () => { + cancelled = true; + }; + }, [url, vid]); return ; };