增加html支持

main
mula.liu 2026-03-02 15:51:47 +08:00
parent 20ab8b28e7
commit 5060c250c0
5 changed files with 214 additions and 62 deletions

View File

@ -794,6 +794,31 @@ body {
gap: 4px; gap: 4px;
} }
.modal-title-row.modal-title-with-close {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
position: relative;
padding-right: 42px;
min-height: 28px;
}
.modal-title-main {
min-width: 0;
display: grid;
gap: 4px;
}
.modal-title-actions {
position: absolute;
right: 0;
top: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
.modal-sub { .modal-sub {
color: var(--subtitle); color: var(--subtitle);
font-size: 12px; font-size: 12px;

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Boxes, MoonStar, Sparkles, SunMedium } from 'lucide-react'; import { MoonStar, SunMedium, X } from 'lucide-react';
import { import {
useAppStore, useAppStore,
} from './store/appStore'; } from './store/appStore';
@ -106,11 +106,15 @@ function App() {
{!urlView.compactMode && showImageFactory && ( {!urlView.compactMode && showImageFactory && (
<div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}> <div className="modal-mask app-modal-mask" onClick={() => setShowImageFactory(false)}>
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
<div className="row-between"> <div className="modal-title-row modal-title-with-close">
<h3>{t.nav.images.title}</h3> <div className="modal-title-main">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowImageFactory(false)} title={t.close} aria-label={t.close}> <h3>{t.nav.images.title}</h3>
<Boxes size={14} /> </div>
</button> <div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowImageFactory(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div> </div>
<div className="app-modal-body"> <div className="app-modal-body">
<ImageFactoryModule /> <ImageFactoryModule />
@ -122,11 +126,15 @@ function App() {
{!urlView.compactMode && showCreateWizard && ( {!urlView.compactMode && showCreateWizard && (
<div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}> <div className="modal-mask app-modal-mask" onClick={() => setShowCreateWizard(false)}>
<div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card app-modal-card" onClick={(e) => e.stopPropagation()}>
<div className="row-between"> <div className="modal-title-row modal-title-with-close">
<h3>{t.nav.onboarding.title}</h3> <div className="modal-title-main">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCreateWizard(false)} title={t.close} aria-label={t.close}> <h3>{t.nav.onboarding.title}</h3>
<Sparkles size={14} /> </div>
</button> <div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCreateWizard(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div> </div>
<div className="app-modal-body"> <div className="app-modal-body">
<BotWizardModule <BotWizardModule

View File

@ -121,6 +121,9 @@ export const dashboardEn = {
filePreview: 'File Preview', filePreview: 'File Preview',
fileTruncated: 'Large file: preview is truncated.', fileTruncated: 'Large file: preview is truncated.',
download: 'Download', download: 'Download',
copyAddress: 'Copy URL',
urlCopied: 'URL copied.',
urlCopyFail: 'Failed to copy URL.',
close: 'Close', close: 'Close',
cronViewer: 'Scheduled Jobs', cronViewer: 'Scheduled Jobs',
cronReload: 'Reload jobs', cronReload: 'Reload jobs',

View File

@ -121,6 +121,9 @@ export const dashboardZhCn = {
filePreview: '文件预览', filePreview: '文件预览',
fileTruncated: '文件较大,当前内容为截断预览。', fileTruncated: '文件较大,当前内容为截断预览。',
download: '下载', download: '下载',
copyAddress: '复制地址',
urlCopied: '地址已复制。',
urlCopyFail: '复制地址失败。',
close: '关闭', close: '关闭',
cronViewer: '定时任务', cronViewer: '定时任务',
cronReload: '刷新任务', cronReload: '刷新任务',

View File

@ -65,6 +65,7 @@ interface WorkspacePreviewState {
ext: string; ext: string;
isMarkdown: boolean; isMarkdown: boolean;
isImage: boolean; isImage: boolean;
isHtml: boolean;
} }
interface WorkspaceUploadResponse { interface WorkspaceUploadResponse {
@ -208,7 +209,7 @@ function normalizeRuntimeState(s?: string) {
function isPreviewableWorkspaceFile(node: WorkspaceNode) { function isPreviewableWorkspaceFile(node: WorkspaceNode) {
if (node.type !== 'file') return false; if (node.type !== 'file') return false;
const ext = (node.ext || '').toLowerCase(); const ext = (node.ext || '').toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext); return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].includes(ext);
} }
function isPdfPath(path: string) { function isPdfPath(path: string) {
@ -220,6 +221,11 @@ function isImagePath(path: string) {
return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp'); return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp');
} }
function isHtmlPath(path: string) {
const normalized = String(path || '').trim().toLowerCase();
return normalized.endsWith('.html') || normalized.endsWith('.htm');
}
function isOfficePath(path: string) { function isOfficePath(path: string) {
const normalized = String(path || '').trim().toLowerCase(); const normalized = String(path || '').trim().toLowerCase();
return ['.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => return ['.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
@ -229,7 +235,7 @@ function isOfficePath(path: string) {
function isPreviewableWorkspacePath(path: string) { function isPreviewableWorkspacePath(path: string) {
const normalized = String(path || '').trim().toLowerCase(); const normalized = String(path || '').trim().toLowerCase();
return ['.md', '.json', '.log', '.txt', '.csv', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) => return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
normalized.endsWith(ext), normalized.endsWith(ext),
); );
} }
@ -238,7 +244,7 @@ function workspaceFileAction(path: string): 'preview' | 'download' | 'unsupporte
const normalized = String(path || '').trim(); const normalized = String(path || '').trim();
if (!normalized) return 'unsupported'; if (!normalized) return 'unsupported';
if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download'; if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download';
if (isImagePath(normalized)) return 'preview'; if (isImagePath(normalized) || isHtmlPath(normalized)) return 'preview';
const lower = normalized.toLowerCase(); const lower = normalized.toLowerCase();
if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview'; if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview';
return 'unsupported'; return 'unsupported';
@ -269,7 +275,7 @@ function decorateWorkspacePathsForMarkdown(text: string) {
'[$1]($2)', '[$1]($2)',
); );
const workspacePathPattern = const workspacePathPattern =
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi; /\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi;
return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => { return normalizedExistingLinks.replace(workspacePathPattern, (fullPath) => {
const normalized = normalizeDashboardAttachmentPath(fullPath); const normalized = normalizeDashboardAttachmentPath(fullPath);
if (!normalized) return fullPath; if (!normalized) return fullPath;
@ -433,6 +439,33 @@ export function BotDashboardModule({
link.click(); link.click();
link.remove(); link.remove();
}; };
const copyWorkspacePreviewUrl = async (filePath: string) => {
const normalized = String(filePath || '').trim();
if (!selectedBotId || !normalized) return;
const hrefRaw = buildWorkspaceDownloadHref(normalized, false);
const href = (() => {
try {
return new URL(hrefRaw, window.location.origin).href;
} catch {
return hrefRaw;
}
})();
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(href);
} else {
const ta = document.createElement('textarea');
ta.value = href;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
notify(t.urlCopied, { tone: 'success' });
} catch {
notify(t.urlCopyFail, { tone: 'error' });
}
};
const openWorkspacePathFromChat = (path: string) => { const openWorkspacePathFromChat = (path: string) => {
const normalized = String(path || '').trim(); const normalized = String(path || '').trim();
if (!normalized) return; if (!normalized) return;
@ -454,7 +487,7 @@ export function BotDashboardModule({
const source = String(text || ''); const source = String(text || '');
if (!source) return [source]; if (!source) return [source];
const pattern = const pattern =
/\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi; /\[(\/root\/\.nanobot\/workspace\/[^\]]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))\]\((https:\/\/workspace\.local\/open\/[^)\s]+)\)|\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|json|log|txt|csv|html|htm|pdf|png|jpg|jpeg|webp|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b|https:\/\/workspace\.local\/open\/[^\s)]+/gi;
const nodes: ReactNode[] = []; const nodes: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let matchIndex = 0; let matchIndex = 0;
@ -885,6 +918,20 @@ export function BotDashboardModule({
ext: fileExt ? `.${fileExt}` : '', ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false, isMarkdown: false,
isImage: true, isImage: true,
isHtml: false,
});
return;
}
if (isHtmlPath(normalizedPath)) {
const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
setWorkspacePreview({
path: normalizedPath,
content: '',
truncated: false,
ext: fileExt ? `.${fileExt}` : '',
isMarkdown: false,
isImage: false,
isHtml: true,
}); });
return; return;
} }
@ -910,6 +957,7 @@ export function BotDashboardModule({
ext: textExt ? `.${textExt}` : '', ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown), isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
isImage: false, isImage: false,
isHtml: false,
}); });
} catch (error: any) { } catch (error: any) {
const msg = error?.response?.data?.detail || t.fileReadFail; const msg = error?.response?.data?.detail || t.fileReadFail;
@ -2102,9 +2150,16 @@ export function BotDashboardModule({
{showBaseModal && ( {showBaseModal && (
<div className="modal-mask" onClick={() => setShowBaseModal(false)}> <div className="modal-mask" onClick={() => setShowBaseModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row"> <div className="modal-title-row modal-title-with-close">
<h3>{t.baseConfig}</h3> <div className="modal-title-main">
<span className="modal-sub">{t.baseConfigSub}</span> <h3>{t.baseConfig}</h3>
<span className="modal-sub">{t.baseConfigSub}</span>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowBaseModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div> </div>
<label className="field-label">{t.botIdReadonly}</label> <label className="field-label">{t.botIdReadonly}</label>
@ -2153,7 +2208,16 @@ export function BotDashboardModule({
{showParamModal && ( {showParamModal && (
<div className="modal-mask" onClick={() => setShowParamModal(false)}> <div className="modal-mask" onClick={() => setShowParamModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}> <div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>{t.modelParams}</h3> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.modelParams}</h3>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowParamModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div>
<div className="slider-row"> <div className="slider-row">
<label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label> <label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} /> <input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
@ -2175,7 +2239,16 @@ export function BotDashboardModule({
{showChannelModal && ( {showChannelModal && (
<div className="modal-mask" onClick={() => setShowChannelModal(false)}> <div className="modal-mask" onClick={() => setShowChannelModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{lc.wizardSectionTitle}</h3> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{lc.wizardSectionTitle}</h3>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowChannelModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div>
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}> <div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{lc.wizardSectionDesc} {lc.wizardSectionDesc}
</div> </div>
@ -2278,10 +2351,6 @@ export function BotDashboardModule({
<Plus size={14} /> <Plus size={14} />
</button> </button>
</div> </div>
<div className="row-between">
<span className="field-label">{lc.wizardSectionDesc}</span>
<button className="btn btn-primary" onClick={() => setShowChannelModal(false)}>{lc.close}</button>
</div>
</div> </div>
</div> </div>
)} )}
@ -2289,7 +2358,16 @@ export function BotDashboardModule({
{showSkillsModal && ( {showSkillsModal && (
<div className="modal-mask" onClick={() => setShowSkillsModal(false)}> <div className="modal-mask" onClick={() => setShowSkillsModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{t.skillsPanel}</h3> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.skillsPanel}</h3>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowSkillsModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div>
<div className="wizard-channel-list"> <div className="wizard-channel-list">
{botSkills.length === 0 ? ( {botSkills.length === 0 ? (
<div className="ops-empty-inline">{t.skillsEmpty}</div> <div className="ops-empty-inline">{t.skillsEmpty}</div>
@ -2338,10 +2416,6 @@ export function BotDashboardModule({
</button> </button>
<span className="field-label">{t.zipOnlyHint}</span> <span className="field-label">{t.zipOnlyHint}</span>
</div> </div>
<div className="row-between">
<span className="field-label"> </span>
<button className="btn btn-primary" onClick={() => setShowSkillsModal(false)}>{t.close}</button>
</div>
</div> </div>
</div> </div>
)} )}
@ -2349,7 +2423,16 @@ export function BotDashboardModule({
{showEnvParamsModal && ( {showEnvParamsModal && (
<div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}> <div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{t.envParams}</h3> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.envParams}</h3>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowEnvParamsModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div>
<div className="field-label" style={{ marginBottom: 8 }}>{t.envParamsDesc}</div> <div className="field-label" style={{ marginBottom: 8 }}>{t.envParamsDesc}</div>
<div className="wizard-channel-list"> <div className="wizard-channel-list">
{envEntries.length === 0 ? ( {envEntries.length === 0 ? (
@ -2438,17 +2521,24 @@ export function BotDashboardModule({
{showCronModal && ( {showCronModal && (
<div className="modal-mask" onClick={() => setShowCronModal(false)}> <div className="modal-mask" onClick={() => setShowCronModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="row-between"> <div className="modal-title-row modal-title-with-close">
<h3>{t.cronViewer}</h3> <div className="modal-title-main">
<button <h3>{t.cronViewer}</h3>
className="btn btn-secondary btn-sm icon-btn" </div>
onClick={() => selectedBot && void loadCronJobs(selectedBot.id)} <div className="modal-title-actions">
title={t.cronReload} <button
aria-label={t.cronReload} className="btn btn-secondary btn-sm icon-btn"
disabled={cronLoading} onClick={() => selectedBot && void loadCronJobs(selectedBot.id)}
> title={t.cronReload}
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} /> aria-label={t.cronReload}
</button> disabled={cronLoading}
>
<RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
</button>
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCronModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div> </div>
{cronLoading ? ( {cronLoading ? (
<div className="ops-empty-inline">{t.cronLoading}</div> <div className="ops-empty-inline">{t.cronLoading}</div>
@ -2502,10 +2592,6 @@ export function BotDashboardModule({
})} })}
</div> </div>
)} )}
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowCronModal(false)}>{t.close}</button>
<span />
</div>
</div> </div>
</div> </div>
)} )}
@ -2513,7 +2599,16 @@ export function BotDashboardModule({
{showAgentModal && ( {showAgentModal && (
<div className="modal-mask" onClick={() => setShowAgentModal(false)}> <div className="modal-mask" onClick={() => setShowAgentModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{t.agentFiles}</h3> <div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{t.agentFiles}</h3>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowAgentModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div>
<div className="wizard-agent-layout"> <div className="wizard-agent-layout">
<div className="agent-tabs-vertical"> <div className="agent-tabs-vertical">
{(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => ( {(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => (
@ -2533,16 +2628,19 @@ export function BotDashboardModule({
{showRuntimeActionModal && ( {showRuntimeActionModal && (
<div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}> <div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
<div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}> <div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row"> <div className="modal-title-row modal-title-with-close">
<h3>{t.lastAction}</h3> <div className="modal-title-main">
<h3>{t.lastAction}</h3>
</div>
<div className="modal-title-actions">
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowRuntimeActionModal(false)} title={t.close} aria-label={t.close}>
<X size={14} />
</button>
</div>
</div> </div>
<div className="workspace-preview-body"> <div className="workspace-preview-body">
<pre>{runtimeAction}</pre> <pre>{runtimeAction}</pre>
</div> </div>
<div className="row-between">
<span />
<button className="btn btn-primary" onClick={() => setShowRuntimeActionModal(false)}>{t.close}</button>
</div>
</div> </div>
</div> </div>
)} )}
@ -2581,6 +2679,12 @@ export function BotDashboardModule({
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`} src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`}
alt={workspacePreview.path.split('/').pop() || 'workspace-image'} alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
/> />
) : workspacePreview.isHtml ? (
<iframe
className="workspace-preview-embed"
src={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}`}
title={workspacePreview.path}
/>
) : workspacePreview.isMarkdown ? ( ) : workspacePreview.isMarkdown ? (
<div className="workspace-markdown"> <div className="workspace-markdown">
<ReactMarkdown <ReactMarkdown
@ -2601,15 +2705,24 @@ export function BotDashboardModule({
<div className="row-between"> <div className="row-between">
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span> <span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<a {workspacePreview.isHtml ? (
className="btn btn-secondary" <button
href={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}&download=1`} className="btn btn-secondary"
target="_blank" onClick={() => void copyWorkspacePreviewUrl(workspacePreview.path)}
rel="noopener noreferrer" >
download={workspacePreview.path.split('/').pop() || 'workspace-file'} {t.copyAddress}
> </button>
{t.download} ) : (
</a> <a
className="btn btn-secondary"
href={`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/download?path=${encodeURIComponent(workspacePreview.path)}&download=1`}
target="_blank"
rel="noopener noreferrer"
download={workspacePreview.path.split('/').pop() || 'workspace-file'}
>
{t.download}
</a>
)}
</div> </div>
</div> </div>
</div> </div>