mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-28 17:14:44 +08:00
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:
@@ -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'>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user