dashboard-nanobot/frontend/src/shared/workspace/useWorkspacePreview.ts

316 lines
11 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../config/env';
import { resolveApiErrorMessage } from '../http/apiErrors';
import type { WorkspaceFileResponse, WorkspacePreviewMode, WorkspacePreviewState } from './types';
import {
buildWorkspaceDownloadHref,
buildWorkspacePreviewHref,
buildWorkspaceRawHref,
resolveWorkspaceDocumentPath,
} from './workspaceMarkdown';
import {
isAudioPath,
isHtmlPath,
isImagePath,
isVideoPath,
workspaceFileAction,
} from './utils';
import type { WorkspaceNotifyOptions } from './workspaceShared';
export interface WorkspacePreviewLabels {
fileReadFail: string;
fileEditDisabled: string;
fileSaveFail: string;
fileSaved: string;
urlCopied: string;
urlCopyFail: string;
}
interface UseWorkspacePreviewOptions {
selectedBotId: string;
workspaceCurrentPath: string;
workspaceDownloadExtensionSet: ReadonlySet<string>;
loadWorkspaceTree: (botId: string, path?: string) => Promise<void>;
notify: (message: string, options?: WorkspaceNotifyOptions) => void;
t: WorkspacePreviewLabels;
isZh: boolean;
}
function buildMediaPreviewState(path: string, mode: 'image' | 'html' | 'video' | 'audio'): WorkspacePreviewState {
const fileExt = (path.split('.').pop() || '').toLowerCase();
return {
path,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: mode === 'image',
isHtml: mode === 'html',
isVideo: mode === 'video',
isAudio: mode === 'audio',
};
}
export function useWorkspacePreview({
selectedBotId,
workspaceCurrentPath,
workspaceDownloadExtensionSet,
loadWorkspaceTree,
notify,
t,
isZh,
}: UseWorkspacePreviewOptions) {
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
const [workspacePreviewMode, setWorkspacePreviewMode] = useState<WorkspacePreviewMode>('preview');
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false);
const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState('');
const workspacePreviewCanEdit = Boolean(workspacePreview?.isMarkdown && !workspacePreview?.truncated);
const workspacePreviewEditorEnabled = workspacePreviewCanEdit && workspacePreviewMode === 'edit';
const workspacePreviewPath = workspacePreview?.path || '';
const workspacePreviewContent = workspacePreview?.content || '';
const getWorkspaceDownloadHref = useCallback((filePath: string, forceDownload: boolean = true) =>
buildWorkspaceDownloadHref(selectedBotId, filePath, forceDownload),
[selectedBotId]);
const getWorkspaceRawHref = useCallback((filePath: string, forceDownload: boolean = false) =>
buildWorkspaceRawHref(selectedBotId, filePath, forceDownload),
[selectedBotId]);
const getWorkspacePreviewHref = useCallback((filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return '';
return buildWorkspacePreviewHref(selectedBotId, normalized, { preferRaw: isHtmlPath(normalized) });
}, [selectedBotId]);
const closeWorkspacePreview = useCallback(() => {
setWorkspacePreview(null);
setWorkspacePreviewMode('preview');
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
}, []);
const copyTextToClipboard = useCallback(async (textRaw: string, successMsg: string, failMsg: string) => {
const text = String(textRaw || '');
if (!text.trim()) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(successMsg, { tone: 'success' });
} catch {
notify(failMsg, { tone: 'error' });
}
}, [notify]);
const openWorkspaceFilePreview = useCallback(async (path: string) => {
const normalizedPath = String(path || '').trim();
if (!selectedBotId || !normalizedPath) return;
if (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') {
window.open(getWorkspaceDownloadHref(normalizedPath, true), '_blank', 'noopener,noreferrer');
return;
}
if (isImagePath(normalizedPath)) {
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'image'));
return;
}
if (isHtmlPath(normalizedPath)) {
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'html'));
return;
}
if (isVideoPath(normalizedPath)) {
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'video'));
return;
}
if (isAudioPath(normalizedPath)) {
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'audio'));
return;
}
setWorkspaceFileLoading(true);
try {
const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, {
params: { path, max_bytes: 400000 },
});
const filePath = res.data.path || path;
const textExt = (filePath.split('.').pop() || '').toLowerCase();
let content = res.data.content || '';
if (textExt === 'json') {
try {
content = JSON.stringify(JSON.parse(content), null, 2);
} catch {
// Keep original content when JSON is not strictly parseable.
}
}
setWorkspacePreview({
path: filePath,
content,
truncated: Boolean(res.data.truncated),
ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
isImage: false,
isHtml: false,
isVideo: false,
isAudio: false,
});
} catch (error: unknown) {
const msg = resolveApiErrorMessage(error, t.fileReadFail);
notify(msg, { tone: 'error' });
} finally {
setWorkspaceFileLoading(false);
}
}, [getWorkspaceDownloadHref, notify, selectedBotId, t.fileReadFail, workspaceDownloadExtensionSet]);
const saveWorkspacePreviewMarkdown = useCallback(async () => {
if (!selectedBotId || !workspacePreview?.isMarkdown) return;
if (workspacePreview.truncated) {
notify(t.fileEditDisabled, { tone: 'warning' });
return;
}
setWorkspacePreviewSaving(true);
try {
const res = await axios.put<WorkspaceFileResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`,
{ content: workspacePreviewDraft },
{ params: { path: workspacePreview.path } },
);
const filePath = res.data.path || workspacePreview.path;
const textExt = (filePath.split('.').pop() || '').toLowerCase();
const content = res.data.content || workspacePreviewDraft;
setWorkspacePreview({
...workspacePreview,
path: filePath,
content,
truncated: false,
ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown),
});
notify(t.fileSaved, { tone: 'success' });
void loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
} catch (error: unknown) {
notify(resolveApiErrorMessage(error, t.fileSaveFail), { tone: 'error' });
} finally {
setWorkspacePreviewSaving(false);
}
}, [
loadWorkspaceTree,
notify,
selectedBotId,
t.fileEditDisabled,
t.fileSaveFail,
t.fileSaved,
workspaceCurrentPath,
workspacePreview,
workspacePreviewDraft,
]);
const triggerWorkspaceFileDownload = useCallback((filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
if (!normalized) return;
const filename = normalized.split('/').pop() || 'workspace-file';
const link = document.createElement('a');
link.href = getWorkspaceDownloadHref(normalized, true);
link.download = filename;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}, [getWorkspaceDownloadHref, selectedBotId]);
const copyWorkspacePreviewUrl = useCallback(async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return;
const hrefRaw = getWorkspacePreviewHref(normalized);
const href = (() => {
try {
return new URL(hrefRaw, window.location.origin).href;
} catch {
return hrefRaw;
}
})();
await copyTextToClipboard(href, t.urlCopied, t.urlCopyFail);
}, [copyTextToClipboard, getWorkspacePreviewHref, selectedBotId, t.urlCopied, t.urlCopyFail]);
const copyWorkspacePreviewPath = useCallback(async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!normalized) return;
await copyTextToClipboard(
normalized,
isZh ? '文件路径已复制' : 'File path copied',
isZh ? '文件路径复制失败' : 'Failed to copy file path',
);
}, [copyTextToClipboard, isZh]);
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => {
const src = String(srcRaw || '').trim();
if (!src || !selectedBotId) return src;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
if (resolvedWorkspacePath) {
return getWorkspacePreviewHref(resolvedWorkspacePath);
}
const lower = src.toLowerCase();
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
return src;
}
return src;
}, [getWorkspacePreviewHref, selectedBotId]);
const resetWorkspacePreviewState = useCallback(() => {
setWorkspaceFileLoading(false);
setWorkspacePreview(null);
setWorkspacePreviewMode('preview');
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
}, []);
useEffect(() => {
if (!workspacePreviewPath) {
setWorkspacePreviewMode('preview');
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
return;
}
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft(workspacePreviewContent);
}, [workspacePreviewContent, workspacePreviewPath]);
return {
closeWorkspacePreview,
copyWorkspacePreviewPath,
copyWorkspacePreviewUrl,
getWorkspaceDownloadHref,
getWorkspacePreviewHref,
getWorkspaceRawHref,
openWorkspaceFilePreview,
resetWorkspacePreviewState,
resolveWorkspaceMediaSrc,
saveWorkspacePreviewMarkdown,
setWorkspacePreviewDraft,
setWorkspacePreviewFullscreen,
setWorkspacePreviewMode,
triggerWorkspaceFileDownload,
workspaceFileLoading,
workspacePreview,
workspacePreviewCanEdit,
workspacePreviewDraft,
workspacePreviewEditorEnabled,
workspacePreviewFullscreen,
workspacePreviewMode,
workspacePreviewSaving,
};
}