kit初版,模型引入,agent优化
This commit is contained in:
623
art-agent/frontend/src/app/gallery/page.tsx
Normal file
623
art-agent/frontend/src/app/gallery/page.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import type { ImageAsset } from "@/lib/types";
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
type SortBy = "time" | "name";
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { assets, tags, toggleFavorite, deleteAssetById, updateAsset } = useApp();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [sortBy, setSortBy] = useState<SortBy>("time");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showFavOnly, setShowFavOnly] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [detailAsset, setDetailAsset] = useState<ImageAsset | null>(null);
|
||||
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = [...assets];
|
||||
|
||||
if (showFavOnly) {
|
||||
result = result.filter((a) => a.favorited);
|
||||
}
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
result = result.filter((a) => selectedTagIds.some((tid) => a.tags.includes(tid)));
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
result = result.filter((a) => a.prompt.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (sortBy === "time") return b.createdAt - a.createdAt;
|
||||
return a.prompt.localeCompare(b.prompt);
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [assets, showFavOnly, selectedTagIds, searchQuery, sortBy]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (selectedIds.size === filtered.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(filtered.map((a) => a.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
selectedIds.forEach((id) => deleteAssetById(id));
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
for (const id of selectedIds) {
|
||||
const asset = assets.find((a) => a.id === id);
|
||||
if (!asset) continue;
|
||||
try {
|
||||
const fullUrl = getImageUrl(asset.url);
|
||||
const resp = await fetch(fullUrl);
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `epeekit-${asset.id}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// 忽略单张下载失败
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (asset: ImageAsset) => {
|
||||
try {
|
||||
const fullUrl = getImageUrl(asset.url);
|
||||
const resp = await fetch(fullUrl);
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `epeekit-${asset.id}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
alert("下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<TopNav />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 主内容 */}
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2
|
||||
flex items-center gap-2 md:gap-3 flex-wrap">
|
||||
{/* 搜索 */}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[140px] md:min-w-[200px] max-w-[360px]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索 Prompt..."
|
||||
className="flex-1 px-2 py-1 text-sm bg-transparent border-none
|
||||
text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签筛选 */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{tags.map((tag) => {
|
||||
const active = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTagIds((prev) =>
|
||||
active ? prev.filter((id) => id !== tag.id) : [...prev, tag.id]
|
||||
)
|
||||
}
|
||||
className="px-2 py-0.5 text-[10px] rounded-full cursor-pointer border transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? tag.color + "22" : "transparent",
|
||||
borderColor: active ? tag.color : "var(--border)",
|
||||
color: active ? tag.color : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 收藏筛选 */}
|
||||
<button
|
||||
onClick={() => setShowFavOnly(!showFavOnly)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: showFavOnly ? "#f59e0b" : "var(--text-secondary)",
|
||||
backgroundColor: showFavOnly ? "#f59e0b15" : "transparent",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill={showFavOnly ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
收藏
|
||||
</button>
|
||||
|
||||
{/* 排序 */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] cursor-pointer focus:outline-none"
|
||||
>
|
||||
<option value="time">按时间</option>
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
|
||||
{/* 视图切换 */}
|
||||
<div className="flex items-center gap-0.5 bg-[var(--bg-tertiary)] rounded p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`p-1 rounded cursor-pointer transition-colors ${
|
||||
viewMode === "grid" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
title="网格视图"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`p-1 rounded cursor-pointer transition-colors ${
|
||||
viewMode === "list" ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
title="列表视图"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30
|
||||
flex items-center gap-3">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-[var(--accent)] cursor-pointer"
|
||||
>
|
||||
{selectedIds.size === filtered.length ? "取消全选" : "全选"}
|
||||
</button>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleBatchDownload}
|
||||
className="px-2.5 py-1 text-xs rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)] cursor-pointer"
|
||||
>
|
||||
批量下载
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchDelete}
|
||||
className="px-2.5 py-1 text-xs rounded bg-[var(--bg-tertiary)] text-red-400
|
||||
hover:text-red-300 border border-[var(--border)] cursor-pointer"
|
||||
>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片内容区 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl">📁</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{assets.length === 0 ? "还没有生成过图片" : "没有匹配的资源"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 md:gap-3">
|
||||
{filtered.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`group relative rounded-lg overflow-hidden border transition-colors cursor-pointer ${
|
||||
selectedIds.has(asset.id)
|
||||
? "border-[var(--accent)] ring-1 ring-[var(--accent)]"
|
||||
: "border-[var(--border)] hover:border-[var(--border)]/80"
|
||||
}`}
|
||||
onClick={() => setDetailAsset(asset)}
|
||||
>
|
||||
{/* 选择框 */}
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
selectedIds.has(asset.id)
|
||||
? "bg-[var(--accent)] border-[var(--accent)]"
|
||||
: "border-white/50 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{selectedIds.has(asset.id) && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={asset.prompt.slice(0, 50)}
|
||||
className="w-full aspect-square object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="p-2 bg-[var(--bg-secondary)]">
|
||||
<div className="text-[10px] text-[var(--text-secondary)] truncate">
|
||||
{asset.prompt || "无 prompt"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
{asset.tags.slice(0, 2).map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[9px] px-1 py-px rounded-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-[9px] text-[var(--text-secondary)] ml-auto">
|
||||
{new Date(asset.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮:移动端始终可见 */}
|
||||
<div className="absolute top-2 right-2 flex gap-1
|
||||
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(asset);
|
||||
}}
|
||||
className="w-7 h-7 rounded bg-black/60 text-white/80 hover:text-white
|
||||
flex items-center justify-center cursor-pointer"
|
||||
title="下载"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
className="w-7 h-7 rounded bg-black/60 flex items-center justify-center cursor-pointer"
|
||||
style={{ color: asset.favorited ? "#f59e0b" : "rgba(255,255,255,0.6)" }}
|
||||
title={asset.favorited ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 列表视图 */
|
||||
<div className="space-y-1">
|
||||
{filtered.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedIds.has(asset.id)
|
||||
? "bg-[var(--accent)]/10"
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
onClick={() => setDetailAsset(asset)}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
selectedIds.has(asset.id)
|
||||
? "bg-[var(--accent)] border-[var(--accent)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{selectedIds.has(asset.id) && (
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded object-cover border border-[var(--border)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-[var(--text-primary)] truncate">
|
||||
{asset.prompt || "无 prompt"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{asset.tags.slice(0, 3).map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[9px] px-1 py-px rounded-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">
|
||||
{new Date(asset.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
className="cursor-pointer flex-shrink-0"
|
||||
style={{ color: asset.favorited ? "#f59e0b" : "var(--text-secondary)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(asset);
|
||||
}}
|
||||
className="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex-shrink-0"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 详情 Modal */}
|
||||
{detailAsset && (
|
||||
<GalleryDetailModal
|
||||
asset={detailAsset}
|
||||
tags={tags}
|
||||
onClose={() => setDetailAsset(null)}
|
||||
onToggleFavorite={() => toggleFavorite(detailAsset.id)}
|
||||
onDownload={() => handleDownload(detailAsset)}
|
||||
onDelete={() => {
|
||||
deleteAssetById(detailAsset.id);
|
||||
setDetailAsset(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 资源详情 Modal
|
||||
function GalleryDetailModal({
|
||||
asset,
|
||||
tags,
|
||||
onClose,
|
||||
onToggleFavorite,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
asset: ImageAsset;
|
||||
tags: { id: string; name: string; color: string }[];
|
||||
onClose: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border)]
|
||||
max-w-3xl w-full mx-2 md:mx-4 max-h-[90vh] md:max-h-[85vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">资源详情</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
<div className="flex gap-5 flex-col md:flex-row">
|
||||
{/* 图片 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<img
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={asset.prompt}
|
||||
className="w-full rounded-lg border border-[var(--border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="w-full md:w-[260px] flex-shrink-0 space-y-4">
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleFavorite}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
border cursor-pointer transition-colors"
|
||||
style={{
|
||||
backgroundColor: asset.favorited ? "#f59e0b22" : "var(--bg-tertiary)",
|
||||
borderColor: asset.favorited ? "#f59e0b" : "var(--border)",
|
||||
color: asset.favorited ? "#f59e0b" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill={asset.favorited ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
{asset.favorited ? "已收藏" : "收藏"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
bg-[var(--bg-tertiary)] text-red-400/70 hover:text-red-400
|
||||
border border-[var(--border)] hover:border-red-400/50
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
{asset.prompt && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(asset.prompt)}
|
||||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
cursor-pointer"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
|
||||
{asset.prompt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{asset.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1">标签</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{asset.tags.map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>创建时间:{new Date(asset.createdAt).toLocaleString("zh-CN")}</div>
|
||||
<div>会话 ID:{asset.sessionId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,14 @@ body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overscroll-behavior: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
@@ -28,3 +31,25 @@ body {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 原生 select 下拉框暗色 */
|
||||
select option {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 移动端侧边栏遮罩动画 */
|
||||
.sidebar-overlay {
|
||||
animation: fadeIn 200ms ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 移动端防止 iOS 地址栏 bounce */
|
||||
@supports (height: 100dvh) {
|
||||
.h-screen {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AppProvider } from "@/lib/app-context";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Art Agent MVP",
|
||||
description: "AI 美术资源生成助手",
|
||||
title: "EPEEKit",
|
||||
description: "AI 美术资源生成工具集",
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -13,7 +21,9 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="antialiased">{children}</body>
|
||||
<body className="antialiased">
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,31 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ChatMessages } from "@/components/chat/chat-messages";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { sendChat, type Message } from "@/lib/api";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { ImageDetailPanel } from "@/components/detail/image-detail-panel";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { sendChat, getImageUrl, uploadRefImage } from "@/lib/api";
|
||||
import { generateId } from "@/lib/store";
|
||||
import type { ChatMessage, ImageAsset, ApiMessage, AnnotationData } from "@/lib/types";
|
||||
|
||||
export default function Home() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const {
|
||||
activeSession,
|
||||
activeSessionId,
|
||||
appendMessage,
|
||||
addAsset,
|
||||
updateSessionThumbnail,
|
||||
detailImage,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
} = useApp();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingImages, setStreamingImages] = useState<string[]>([]);
|
||||
const [streamingImages, setStreamingImages] = useState<ImageAsset[]>([]);
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -23,22 +40,73 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, refImage: File | null) => {
|
||||
// 添加用户消息
|
||||
const userMessage: Message = { role: "user", content: text };
|
||||
const updatedMessages = [...messages, userMessage];
|
||||
setMessages(updatedMessages);
|
||||
async (text: string, refImageServerUrl: string | null, imageModel: string | null = null) => {
|
||||
if (!activeSessionId || !activeSession) return;
|
||||
|
||||
let finalText = text;
|
||||
let finalRefServerUrl = refImageServerUrl;
|
||||
|
||||
if (pendingAnnotation) {
|
||||
const annotationDescs = pendingAnnotation.annotations
|
||||
.filter((a) => a.text)
|
||||
.map((a) => {
|
||||
if (a.type === "rect") {
|
||||
return `[区域 (${Math.round(a.x * 100)}%, ${Math.round(a.y * 100)}%) 大小 ${Math.round((a.w ?? 0) * 100)}%×${Math.round((a.h ?? 0) * 100)}%]: ${a.text}`;
|
||||
}
|
||||
return `[标注]: ${a.text}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
if (annotationDescs) {
|
||||
finalText = `${text}\n\n--- 图片标注 ---\n${annotationDescs}`;
|
||||
}
|
||||
|
||||
// 标注截图作为参考图:需要先上传再获取 URL
|
||||
if (pendingAnnotation.snapshot && !refImageServerUrl) {
|
||||
try {
|
||||
const res = await fetch(pendingAnnotation.snapshot);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], "annotation.png", { type: "image/png" });
|
||||
const uploadResult = await uploadRefImage(file);
|
||||
finalRefServerUrl = uploadResult.url;
|
||||
} catch {
|
||||
// 忽略转换/上传失败
|
||||
}
|
||||
}
|
||||
|
||||
setPendingAnnotation(null);
|
||||
}
|
||||
|
||||
// 用户消息中的参考图预览:优先使用服务端路径(通过 getImageUrl 转为完整 URL)
|
||||
let previewUrl: string | undefined;
|
||||
if (finalRefServerUrl) {
|
||||
previewUrl = getImageUrl(finalRefServerUrl);
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId("msg-"),
|
||||
role: "user",
|
||||
content: finalText,
|
||||
refImageUrl: previewUrl,
|
||||
};
|
||||
appendMessage(activeSessionId, userMessage);
|
||||
setIsLoading(true);
|
||||
setStreamingText("");
|
||||
setStreamingImages([]);
|
||||
setStatusText("");
|
||||
scrollToBottom();
|
||||
|
||||
const apiMessages: ApiMessage[] = [
|
||||
...activeSession.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: userMessage.role, content: userMessage.content },
|
||||
];
|
||||
|
||||
let assistantText = "";
|
||||
let collectedImages: string[] = [];
|
||||
let collectedImages: ImageAsset[] = [];
|
||||
let usedModelName = "";
|
||||
|
||||
try {
|
||||
for await (const event of sendChat(updatedMessages, refImage)) {
|
||||
for await (const event of sendChat(apiMessages, finalRefServerUrl, imageModel)) {
|
||||
switch (event.type) {
|
||||
case "text_delta":
|
||||
assistantText += event.data.text as string;
|
||||
@@ -51,15 +119,52 @@ export default function Home() {
|
||||
scrollToBottom();
|
||||
break;
|
||||
|
||||
case "image_result":
|
||||
collectedImages = [
|
||||
...collectedImages,
|
||||
...(event.data.images as string[]),
|
||||
];
|
||||
setStreamingImages(collectedImages);
|
||||
case "image_result": {
|
||||
const rawUrls = (event.data.images as string[]) || [];
|
||||
const newUrls = rawUrls.filter((u) => u && !u.startsWith("["));
|
||||
const prompt = (event.data.prompt_used as string) || "";
|
||||
const modelName = (event.data.model_name as string) || "";
|
||||
|
||||
if (newUrls.length === 0) break;
|
||||
|
||||
const newAssets: ImageAsset[] = newUrls.map((url) => ({
|
||||
id: generateId("img-"),
|
||||
url,
|
||||
prompt,
|
||||
sessionId: activeSessionId,
|
||||
tags: activeSession.tags ? [...activeSession.tags] : [],
|
||||
favorited: false,
|
||||
createdAt: Date.now(),
|
||||
}));
|
||||
newAssets.forEach((a) => addAsset(a));
|
||||
|
||||
if (newAssets.length > 0) {
|
||||
updateSessionThumbnail(activeSessionId, newAssets[0].url);
|
||||
}
|
||||
|
||||
collectedImages = [...collectedImages, ...newAssets];
|
||||
setStreamingImages([...collectedImages]);
|
||||
if (modelName) {
|
||||
usedModelName = modelName;
|
||||
setStatusText(`由 ${modelName} 生成`);
|
||||
} else {
|
||||
setStatusText("");
|
||||
}
|
||||
scrollToBottom();
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_error": {
|
||||
const errors = (event.data.errors as string[]) || [];
|
||||
const modelName = (event.data.model_name as string) || "";
|
||||
const errorDetail = errors.join("\n");
|
||||
const modelHint = modelName ? ` (${modelName})` : "";
|
||||
assistantText += `\n\n⚠️ 图片生成失败${modelHint}:\n${errorDetail}`;
|
||||
setStreamingText(assistantText);
|
||||
setStatusText("");
|
||||
scrollToBottom();
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
assistantText += `\n\n[错误: ${event.data.message}]`;
|
||||
@@ -74,79 +179,127 @@ export default function Home() {
|
||||
assistantText += `\n\n[请求失败: ${e instanceof Error ? e.message : "未知错误"}]`;
|
||||
}
|
||||
|
||||
// 完成:将流式内容合并为正式消息
|
||||
const assistantMessage: Message = {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: generateId("msg-"),
|
||||
role: "assistant",
|
||||
content: assistantText || "(生成完成)",
|
||||
images: collectedImages.length > 0 ? collectedImages : undefined,
|
||||
modelName: usedModelName || undefined,
|
||||
};
|
||||
setMessages([...updatedMessages, assistantMessage]);
|
||||
appendMessage(activeSessionId, assistantMessage);
|
||||
setIsLoading(false);
|
||||
setStreamingText("");
|
||||
setStreamingImages([]);
|
||||
setStatusText("");
|
||||
scrollToBottom();
|
||||
},
|
||||
[messages]
|
||||
[activeSession, activeSessionId, appendMessage, addAsset, updateSessionThumbnail, pendingAnnotation]
|
||||
);
|
||||
|
||||
const handleAnnotationComplete = useCallback((data: AnnotationData) => {
|
||||
setPendingAnnotation(data);
|
||||
}, []);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* 标题栏 */}
|
||||
<header className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--bg-secondary)] px-6 py-3">
|
||||
<h1 className="text-lg font-semibold">Art Agent</h1>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
AI 美术资源生成助手 · MVP
|
||||
</p>
|
||||
</header>
|
||||
<TopNav />
|
||||
|
||||
{/* 消息区域 */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 && !isLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-4xl">🎨</div>
|
||||
<h2 className="text-xl font-medium text-[var(--text-primary)]">
|
||||
欢迎使用 Art Agent
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-md">
|
||||
描述你想要的美术资源,我来帮你生成。
|
||||
<br />
|
||||
你可以上传参考图来引导风格方向。
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{[
|
||||
"画一个赛博朋克风格的退出按钮",
|
||||
"设计一个卡通风格的金币图标",
|
||||
"画一个奇幻风格的游戏角色立绘",
|
||||
].map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => handleSend(hint, null)}
|
||||
className="text-xs px-3 py-1.5 rounded-full
|
||||
border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:border-[var(--accent)] hover:text-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0 relative">
|
||||
{/* 桌面端侧边栏展开按钮(移动端用顶栏汉堡菜单替代) */}
|
||||
{sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
className="absolute left-1 top-2 z-10 p-1.5 rounded-md
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
transition-colors cursor-pointer
|
||||
hidden md:flex"
|
||||
title="展开侧边栏"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M13 17l5-5-5-5M6 17l5-5-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 标注提示条 */}
|
||||
{pendingAnnotation && (
|
||||
<div className="flex-shrink-0 px-4 py-2 bg-[var(--accent)]/10 border-b border-[var(--accent)]/30
|
||||
flex items-center gap-3">
|
||||
<img
|
||||
src={pendingAnnotation.snapshot}
|
||||
alt="标注预览"
|
||||
className="w-10 h-10 rounded object-cover border border-[var(--accent)]"
|
||||
/>
|
||||
<span className="text-xs text-[var(--accent)]">
|
||||
标注已就绪({pendingAnnotation.annotations.length} 处)— 输入修改意见后发送
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPendingAnnotation(null)}
|
||||
className="ml-auto text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 && !isLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-4xl">🎨</div>
|
||||
<h2 className="text-xl font-medium text-[var(--text-primary)]">
|
||||
欢迎使用 EPEEKit
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] max-w-md">
|
||||
描述你想要的美术资源,我来帮你生成。
|
||||
<br />
|
||||
你可以上传参考图来引导风格方向。
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{[
|
||||
"画一个赛博朋克风格的退出按钮",
|
||||
"设计一个卡通风格的金币图标",
|
||||
"画一个奇幻风格的游戏角色立绘",
|
||||
].map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => handleSend(hint, null, null)}
|
||||
className="text-xs px-3 py-1.5 rounded-full
|
||||
border border-[var(--border)] text-[var(--text-secondary)]
|
||||
hover:border-[var(--accent)] hover:text-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
streamingText={streamingText}
|
||||
streamingImages={streamingImages}
|
||||
statusText={statusText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
isLoading={isLoading}
|
||||
streamingText={streamingText}
|
||||
streamingImages={streamingImages}
|
||||
statusText={statusText}
|
||||
/>
|
||||
|
||||
<ChatInput onSend={handleSend} disabled={isLoading} />
|
||||
</main>
|
||||
|
||||
{detailImage && (
|
||||
<ImageDetailPanel onAnnotationComplete={handleAnnotationComplete} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<ChatInput onSend={handleSend} disabled={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user