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;
}
.wizard-image-list {
display: grid;
gap: 10px;
}
.wizard-image-list .card {
margin: 0;
}
.table {
width: 100%;
border-collapse: collapse;
@ -532,6 +541,21 @@ body {
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 {
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 { 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';
@ -198,17 +198,10 @@ interface NanobotImage {
status: string;
}
interface DockerImage {
tag: string;
version?: string;
image_id?: string;
}
interface BaseImageOption {
tag: string;
label: string;
disabled: boolean;
needsRegister: boolean;
}
interface WorkspaceSkillOption {
@ -1048,7 +1041,6 @@ export function BotDashboardModule({
const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
const [isBatchOperating, setIsBatchOperating] = useState(false);
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
sendProgress: 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 source = String(text || '');
if (!source) return [source];
@ -1329,6 +1343,17 @@ export function BotDashboardModule({
</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 {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
),
@ -1339,7 +1364,7 @@ export function BotDashboardModule({
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
),
}),
[fileNotPreviewableLabel, notify, selectedBotId],
[fileNotPreviewableLabel, notify, resolveWorkspaceMediaSrc, selectedBotId],
);
const [editForm, setEditForm] = useState({
@ -1449,48 +1474,32 @@ export function BotDashboardModule({
}, [activeTopicOptions, topics]);
const lc = isZh ? channelsZhCn : channelsEn;
const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const readyTags = new Set(
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);
});
const imagesByTag = new Map<string, NanobotImage>();
availableImages.forEach((img) => {
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) {
allTags.add(editForm.image_tag);
}
return Array.from(allTags)
.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 {
tag,
label: isZh ? `${tag} · 本地镜像(未登记)` : `${tag} · local image (unregistered)`,
disabled: false,
needsRegister: true,
};
}
const options = Array.from(imagesByTag.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([tag, img]) => {
const status = String(img.status || '').toUpperCase() || 'UNKNOWN';
return {
tag,
label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`,
disabled: true,
needsRegister: false,
label: `${tag} · ${status}`,
disabled: status !== 'READY',
};
});
}, [availableImages, localDockerImages, editForm.image_tag, isZh]);
const currentTag = String(editForm.image_tag || '').trim();
if (currentTag && !options.some((opt) => opt.tag === currentTag)) {
options.unshift({
tag: currentTag,
label: isZh ? `${currentTag} · 未登记(只读)` : `${currentTag} · unregistered (read-only)`,
disabled: true,
});
}
return options;
}, [availableImages, editForm.image_tag, isZh]);
const runtimeMoreLabel = isZh ? '更多' : 'More';
const effectiveTopicPresetTemplates = useMemo(
() => (topicPresetTemplates.length > 0 ? topicPresetTemplates : DEFAULT_TOPIC_PRESET_TEMPLATES),
@ -2080,20 +2089,12 @@ export function BotDashboardModule({
}, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]);
const loadImageOptions = async () => {
const [imagesRes, dockerImagesRes] = await Promise.allSettled([
axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`),
axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`),
]);
const [imagesRes] = await Promise.allSettled([axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`)]);
if (imagesRes.status === 'fulfilled') {
setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []);
} else {
setAvailableImages([]);
}
if (dockerImagesRes.status === 'fulfilled') {
setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []);
} else {
setLocalDockerImages([]);
}
};
const refresh = async () => {
@ -4388,12 +4389,6 @@ export function BotDashboardModule({
if (selectedImageOption?.disabled) {
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 normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
@ -5578,11 +5573,6 @@ export function BotDashboardModule({
</option>
))}
</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>
<input
@ -5999,15 +5989,6 @@ export function BotDashboardModule({
/>
{t.topicActive}
</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
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingTopic}
@ -6112,6 +6093,13 @@ export function BotDashboardModule({
/>
</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}
</div>

View File

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

View File

@ -626,7 +626,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
{step === 1 && (
<div className="stack">
<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) => (
<label key={img.tag} className="card selectable" style={{ display: 'block', cursor: 'pointer' }}>
<input