fix(linsight): resolve task-mode HTML artifact preview (broken viewer URL)

Clicking a generated .html deliverable opened /html?url=<key> and the viewer
fetched ${BASE_URL}<key> directly. file_url is a MinIO object key, not a
servable URL, so this produced /workspacelinsight/... (missing slash) and, even
with the slash, lacked the bucket prefix + presigned params — the HTML never
loaded. md/txt/image previews avoid this by resolving through file_download
(resolveArtifactUrl).

- Add shared openHtmlArtifactViewer(file, versionId): builds /html?url=&vid= via
  URLSearchParams (no manual concat) and threads the session_version_id.
- useArtifactsPanel/useWorkspacePanel take versionId; Sop/index + ChatView pass it.
- WebView resolves the object key via resolveArtifactUrl(url, vid) (same presigned
  link path as the side panel) when vid is present; slash-safe fallback otherwise.

Test: artifactUtils.test.ts (2). No new type errors.
This commit is contained in:
GuoQing Zhang
2026-06-18 15:52:46 +08:00
parent 13e811d55a
commit 76dd5469c6
7 changed files with 107 additions and 17 deletions
@@ -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
@@ -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');
});
});
@@ -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/<svid>/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<void> {
const url = await resolveArtifactUrl(file.file_url, versionId);
@@ -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<ArtifactFile | null>(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);
@@ -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<ArtifactFile | null>(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);
@@ -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 (
<div className='relative h-full bg-white'>
+25 -7
View File
@@ -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 <iframe srcDoc={content} sandbox="allow-scripts" width="100%" height="100%" style={{ border: "none" }}></iframe>;
};