v0.1.4-p2

main
mula.liu 2026-03-16 11:08:25 +08:00
parent e6e46478fc
commit c5b88d50df
4 changed files with 95 additions and 83 deletions

View File

@ -513,6 +513,15 @@ body {
overflow: auto; overflow: auto;
} }
.wizard-image-list {
display: grid;
gap: 10px;
}
.wizard-image-list .card {
margin: 0;
}
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -532,6 +541,21 @@ body {
font-weight: 700; font-weight: 700;
} }
.image-factory-table th,
.image-factory-table td {
padding-top: 11px;
padding-bottom: 11px;
line-height: 1.55;
vertical-align: top;
}
.image-factory-meta-line {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
.mono { .mono {
font-family: 'SF Mono', Menlo, Consolas, monospace; font-family: 'SF Mono', Menlo, Consolas, monospace;
} }

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type ImgHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios'; import axios from 'axios';
import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react'; import { Activity, ArrowUp, Boxes, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageCircle, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Reply, RotateCcw, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
@ -198,17 +198,10 @@ interface NanobotImage {
status: string; status: string;
} }
interface DockerImage {
tag: string;
version?: string;
image_id?: string;
}
interface BaseImageOption { interface BaseImageOption {
tag: string; tag: string;
label: string; label: string;
disabled: boolean; disabled: boolean;
needsRegister: boolean;
} }
interface WorkspaceSkillOption { interface WorkspaceSkillOption {
@ -1048,7 +1041,6 @@ export function BotDashboardModule({
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false); const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [isBatchOperating, setIsBatchOperating] = useState(false); const [isBatchOperating, setIsBatchOperating] = useState(false);
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]); const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({ const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: false, sendProgress: false,
sendToolHints: false, sendToolHints: false,
@ -1228,6 +1220,28 @@ export function BotDashboardModule({
} }
} }
}; };
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string): string => {
const src = String(srcRaw || '').trim();
if (!src || !selectedBotId) return src;
const lower = src.toLowerCase();
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
const workspacePathFromLink = parseWorkspaceLink(src);
if (workspacePathFromLink) {
return buildWorkspaceDownloadHref(workspacePathFromLink, false);
}
return src;
}
if (src.startsWith('/root/.nanobot/workspace/')) {
const normalized = normalizeDashboardAttachmentPath(src);
if (normalized) return buildWorkspaceDownloadHref(normalized, false);
return src;
}
const workspacePathFromLink = parseWorkspaceLink(src);
if (workspacePathFromLink) {
return buildWorkspaceDownloadHref(workspacePathFromLink, false);
}
return src;
}, [selectedBotId]);
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => { const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
const source = String(text || ''); const source = String(text || '');
if (!source) return [source]; if (!source) return [source];
@ -1329,6 +1343,17 @@ export function BotDashboardModule({
</a> </a>
); );
}, },
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
const resolvedSrc = resolveWorkspaceMediaSrc(String(src || ''));
return (
<img
src={resolvedSrc}
alt={String(alt || '')}
loading="lazy"
{...props}
/>
);
},
p: ({ children, ...props }: { children?: ReactNode }) => ( p: ({ children, ...props }: { children?: ReactNode }) => (
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p> <p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
), ),
@ -1339,7 +1364,7 @@ export function BotDashboardModule({
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code> <code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
), ),
}), }),
[fileNotPreviewableLabel, notify, selectedBotId], [fileNotPreviewableLabel, notify, resolveWorkspaceMediaSrc, selectedBotId],
); );
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
@ -1449,48 +1474,32 @@ export function BotDashboardModule({
}, [activeTopicOptions, topics]); }, [activeTopicOptions, topics]);
const lc = isZh ? channelsZhCn : channelsEn; const lc = isZh ? channelsZhCn : channelsEn;
const baseImageOptions = useMemo<BaseImageOption[]>(() => { const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const readyTags = new Set( const imagesByTag = new Map<string, NanobotImage>();
availableImages
.filter((img) => String(img.status || '').toUpperCase() === 'READY')
.map((img) => String(img.tag || '').trim())
.filter(Boolean),
);
const allTags = new Set<string>();
localDockerImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (tag) allTags.add(tag);
});
availableImages.forEach((img) => { availableImages.forEach((img) => {
const tag = String(img.tag || '').trim(); const tag = String(img.tag || '').trim();
if (tag) allTags.add(tag); if (!tag || imagesByTag.has(tag)) return;
imagesByTag.set(tag, img);
}); });
if (editForm.image_tag) { const options = Array.from(imagesByTag.entries())
allTags.add(editForm.image_tag); .sort((a, b) => a[0].localeCompare(b[0]))
} .map(([tag, img]) => {
return Array.from(allTags) const status = String(img.status || '').toUpperCase() || 'UNKNOWN';
.sort((a, b) => a.localeCompare(b))
.map((tag) => {
const isReady = readyTags.has(tag);
if (isReady) {
return { tag, label: `${tag} · READY`, disabled: false, needsRegister: false };
}
const hasInDocker = localDockerImages.some((row) => String(row.tag || '').trim() === tag);
if (hasInDocker) {
return { return {
tag, tag,
label: isZh ? `${tag} · 本地镜像(未登记)` : `${tag} · local image (unregistered)`, label: `${tag} · ${status}`,
disabled: false, disabled: status !== 'READY',
needsRegister: true,
}; };
} });
return { const currentTag = String(editForm.image_tag || '').trim();
tag, if (currentTag && !options.some((opt) => opt.tag === currentTag)) {
label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`, options.unshift({
tag: currentTag,
label: isZh ? `${currentTag} · 未登记(只读)` : `${currentTag} · unregistered (read-only)`,
disabled: true, disabled: true,
needsRegister: false,
};
}); });
}, [availableImages, localDockerImages, editForm.image_tag, isZh]); }
return options;
}, [availableImages, editForm.image_tag, isZh]);
const runtimeMoreLabel = isZh ? '更多' : 'More'; const runtimeMoreLabel = isZh ? '更多' : 'More';
const effectiveTopicPresetTemplates = useMemo( const effectiveTopicPresetTemplates = useMemo(
() => (topicPresetTemplates.length > 0 ? topicPresetTemplates : DEFAULT_TOPIC_PRESET_TEMPLATES), () => (topicPresetTemplates.length > 0 ? topicPresetTemplates : DEFAULT_TOPIC_PRESET_TEMPLATES),
@ -2080,20 +2089,12 @@ export function BotDashboardModule({
}, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]); }, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]);
const loadImageOptions = async () => { const loadImageOptions = async () => {
const [imagesRes, dockerImagesRes] = await Promise.allSettled([ const [imagesRes] = await Promise.allSettled([axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`)]);
axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`),
axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`),
]);
if (imagesRes.status === 'fulfilled') { if (imagesRes.status === 'fulfilled') {
setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []); setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []);
} else { } else {
setAvailableImages([]); setAvailableImages([]);
} }
if (dockerImagesRes.status === 'fulfilled') {
setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []);
} else {
setLocalDockerImages([]);
}
}; };
const refresh = async () => { const refresh = async () => {
@ -4388,12 +4389,6 @@ export function BotDashboardModule({
if (selectedImageOption?.disabled) { if (selectedImageOption?.disabled) {
throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.'); throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.');
} }
if (selectedImageOption?.needsRegister) {
await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, {
tag: editForm.image_tag,
source_dir: 'manual',
});
}
const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores)); const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb)); const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb)); const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
@ -5578,11 +5573,6 @@ export function BotDashboardModule({
</option> </option>
))} ))}
</LucentSelect> </LucentSelect>
{baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
<div className="field-label" style={{ color: 'var(--warning)' }}>
{isZh ? '该镜像尚未登记,保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
</div>
) : null}
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label> <label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input <input
@ -5999,15 +5989,6 @@ export function BotDashboardModule({
/> />
{t.topicActive} {t.topicActive}
</label> </label>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingTopic}
onClick={() => void saveTopic(topic)}
tooltip={t.save}
aria-label={t.save}
>
<Save size={14} />
</LucentIconButton>
<LucentIconButton <LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn" className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingTopic} disabled={isSavingTopic}
@ -6112,6 +6093,13 @@ export function BotDashboardModule({
/> />
</div> </div>
</div> </div>
<div className="row-between ops-config-footer">
<span className="field-label">{t.topicAddHint}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => void saveTopic(topic)}>
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</div>
</> </>
) : null} ) : null}
</div> </div>

View File

@ -159,7 +159,7 @@ export function ImageFactoryModule() {
</div> </div>
<div className="list-scroll" style={{ maxHeight: '62vh' }}> <div className="list-scroll" style={{ maxHeight: '62vh' }}>
<table className="table"> <table className="table image-factory-table">
<thead> <thead>
<tr> <tr>
<th>Tag</th> <th>Tag</th>
@ -173,8 +173,8 @@ export function ImageFactoryModule() {
<tr key={img.tag}> <tr key={img.tag}>
<td> <td>
<div className="mono">{img.tag}</div> <div className="mono">{img.tag}</div>
<div style={{ color: 'var(--muted)', fontSize: 11 }}>source: {img.source_dir || 'manual'}</div> <div className="image-factory-meta-line">source: {img.source_dir || 'manual'}</div>
{img.image_id && <div className="mono" style={{ color: 'var(--muted)', fontSize: 11 }}>id: {img.image_id}</div>} {img.image_id && <div className="mono image-factory-meta-line">id: {img.image_id}</div>}
</td> </td>
<td>{img.version}</td> <td>{img.version}</td>
<td><span className={statusClass(img.status)}>{img.status}</span></td> <td><span className={statusClass(img.status)}>{img.status}</span></td>
@ -218,7 +218,7 @@ export function ImageFactoryModule() {
</div> </div>
<div className="list-scroll" style={{ maxHeight: '64vh' }}> <div className="list-scroll" style={{ maxHeight: '64vh' }}>
<table className="table"> <table className="table image-factory-table">
<thead> <thead>
<tr> <tr>
<th>Tag</th> <th>Tag</th>
@ -233,7 +233,7 @@ export function ImageFactoryModule() {
<tr key={img.tag}> <tr key={img.tag}>
<td> <td>
<div className="mono">{img.tag}</div> <div className="mono">{img.tag}</div>
<div style={{ color: 'var(--muted)', fontSize: 11 }}>version: {img.version}</div> <div className="image-factory-meta-line">version: {img.version}</div>
</td> </td>
<td className="mono" style={{ fontSize: 11 }}>{img.image_id.slice(0, 22)}...</td> <td className="mono" style={{ fontSize: 11 }}>{img.image_id.slice(0, 22)}...</td>
<td> <td>

View File

@ -626,7 +626,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
{step === 1 && ( {step === 1 && (
<div className="stack"> <div className="stack">
<button className="btn btn-secondary" onClick={() => void loadImages()}>{isLoadingImages ? ui.loading : ui.loadImages}</button> <button className="btn btn-secondary" onClick={() => void loadImages()}>{isLoadingImages ? ui.loading : ui.loadImages}</button>
<div className="list-scroll" style={{ maxHeight: '52vh' }}> <div className="list-scroll wizard-image-list" style={{ maxHeight: '52vh' }}>
{readyImages.map((img) => ( {readyImages.map((img) => (
<label key={img.tag} className="card selectable" style={{ display: 'block', cursor: 'pointer' }}> <label key={img.tag} className="card selectable" style={{ display: 'block', cursor: 'pointer' }}>
<input <input