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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useRef, useState, useCallback, type KeyboardEvent, type DragEvent } from "react";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { uploadRefImage, type UploadProgress } from "@/lib/api";
|
||||
|
||||
type UploadStatus = "idle" | "uploading" | "done" | "error";
|
||||
|
||||
interface UploadState {
|
||||
status: UploadStatus;
|
||||
/** 本地预览 URL */
|
||||
previewUrl: string | null;
|
||||
/** 上传成功后的服务端路径 */
|
||||
serverUrl: string | null;
|
||||
/** 上传进度 0-100 */
|
||||
progress: number;
|
||||
/** 错误信息 */
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_UPLOAD: UploadState = {
|
||||
status: "idle",
|
||||
previewUrl: null,
|
||||
serverUrl: null,
|
||||
progress: 0,
|
||||
errorMsg: null,
|
||||
};
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, refImage: File | null) => void;
|
||||
onSend: (text: string, refImageServerUrl: string | null, imageModel: string | null) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [refImage, setRefImage] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [upload, setUpload] = useState<UploadState>(INITIAL_UPLOAD);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed, refImage);
|
||||
if (upload.status === "uploading") return;
|
||||
|
||||
onSend(trimmed, upload.serverUrl, selectedModel || null);
|
||||
setText("");
|
||||
setRefImage(null);
|
||||
setPreviewUrl(null);
|
||||
clearUpload();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -29,54 +56,235 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const clearUpload = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setUpload((prev) => {
|
||||
if (prev.previewUrl) URL.revokeObjectURL(prev.previewUrl);
|
||||
return INITIAL_UPLOAD;
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const startUpload = useCallback(async (file: File) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setUpload({
|
||||
status: "uploading",
|
||||
previewUrl,
|
||||
serverUrl: null,
|
||||
progress: 0,
|
||||
errorMsg: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await uploadRefImage(
|
||||
file,
|
||||
(p: UploadProgress) => {
|
||||
setUpload((prev) => ({ ...prev, progress: p.percent }));
|
||||
},
|
||||
controller.signal
|
||||
);
|
||||
setUpload((prev) => ({
|
||||
...prev,
|
||||
status: "done",
|
||||
serverUrl: result.url,
|
||||
progress: 100,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
const msg = err instanceof Error ? err.message : "上传失败";
|
||||
setUpload((prev) => ({
|
||||
...prev,
|
||||
status: "error",
|
||||
progress: 0,
|
||||
errorMsg: msg,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setRefImage(file);
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const removeRefImage = () => {
|
||||
setRefImage(null);
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
if (file) {
|
||||
startUpload(file);
|
||||
}
|
||||
// 重置 input value,确保选同一文件也能触发
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const retryUpload = useCallback(() => {
|
||||
if (fileInputRef.current) fileInputRef.current.click();
|
||||
}, []);
|
||||
|
||||
// 拖拽
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image/")) {
|
||||
startUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (() => {
|
||||
switch (upload.status) {
|
||||
case "uploading":
|
||||
return `上传中 ${upload.progress}%`;
|
||||
case "done":
|
||||
return "参考图已就绪";
|
||||
case "error":
|
||||
return upload.errorMsg || "上传失败";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const statusColor = (() => {
|
||||
switch (upload.status) {
|
||||
case "uploading":
|
||||
return "text-[var(--accent)]";
|
||||
case "done":
|
||||
return "text-green-400";
|
||||
case "error":
|
||||
return "text-red-400";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--border)] bg-[var(--bg-secondary)] px-4 py-3">
|
||||
{/* 参考图预览 */}
|
||||
{previewUrl && (
|
||||
<div
|
||||
className={`border-t border-[var(--border)] bg-[var(--bg-secondary)] px-3 md:px-4 py-2 md:py-3 transition-colors ${
|
||||
isDragging ? "bg-[var(--accent)]/5 border-[var(--accent)]" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 拖拽提示 */}
|
||||
{isDragging && (
|
||||
<div className="mb-2 text-center text-xs text-[var(--accent)]">
|
||||
松开以添加参考图
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考图上传状态区 */}
|
||||
{upload.status !== "idle" && (
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="参考图"
|
||||
className="w-16 h-16 rounded-lg object-cover border border-[var(--border)]"
|
||||
/>
|
||||
<button
|
||||
onClick={removeRefImage}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full
|
||||
bg-red-500 text-white text-xs flex items-center justify-center
|
||||
hover:bg-red-600 cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="relative flex-shrink-0">
|
||||
{upload.previewUrl && (
|
||||
<img
|
||||
src={upload.previewUrl}
|
||||
alt="参考图"
|
||||
className={`w-16 h-16 rounded-lg object-cover border border-[var(--border)] transition-opacity ${
|
||||
upload.status === "uploading" ? "opacity-60" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* 上传中的遮罩进度环 */}
|
||||
{upload.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle
|
||||
cx="18" cy="18" r="14"
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${upload.progress * 0.88} 88`}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* 成功 ✓ 角标 */}
|
||||
{upload.status === "done" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-green-500
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
{/* 失败 ! 角标 */}
|
||||
{upload.status === "error" && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500
|
||||
text-white text-[10px] flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
)}
|
||||
{/* 删除按钮(非上传中时显示) */}
|
||||
{upload.status !== "uploading" && (
|
||||
<button
|
||||
onClick={clearUpload}
|
||||
className="absolute -top-1.5 -left-1.5 w-5 h-5 rounded-full
|
||||
bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
text-[var(--text-secondary)] text-xs flex items-center justify-center
|
||||
hover:bg-red-500 hover:text-white hover:border-red-500
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 mt-0.5 min-w-0">
|
||||
<span className={`text-xs ${statusColor} flex items-center gap-1.5`}>
|
||||
{upload.status === "uploading" && (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
|
||||
)}
|
||||
{statusLabel}
|
||||
</span>
|
||||
{/* 进度条 */}
|
||||
{upload.status === "uploading" && (
|
||||
<div className="w-32 h-1 rounded-full bg-[var(--bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--accent)] rounded-full transition-all duration-200"
|
||||
style={{ width: `${upload.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 失败时显示重试 */}
|
||||
{upload.status === "error" && (
|
||||
<button
|
||||
onClick={retryUpload}
|
||||
className="text-xs text-[var(--accent)] hover:underline cursor-pointer w-fit"
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)] mt-1">参考图已添加</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 模型选择器 */}
|
||||
<ModelSelector value={selectedModel} onChange={setSelectedModel} />
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg border border-[var(--border)]
|
||||
disabled={disabled || upload.status === "uploading"}
|
||||
className={`flex-shrink-0 w-10 h-10 rounded-lg border
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] hover:border-[var(--accent)]
|
||||
flex items-center justify-center transition-colors
|
||||
disabled:opacity-50 cursor-pointer"
|
||||
disabled:opacity-50 cursor-pointer ${
|
||||
upload.status === "done"
|
||||
? "border-green-500/50 text-green-400"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
title="上传参考图"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -114,7 +322,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !text.trim()}
|
||||
disabled={disabled || !text.trim() || upload.status === "uploading"}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg
|
||||
bg-[var(--accent)] text-white
|
||||
hover:bg-[var(--accent-hover)]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@/lib/api";
|
||||
import type { ChatMessage, ImageAsset } from "@/lib/types";
|
||||
import { ImageGrid } from "./image-grid";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: Message[];
|
||||
messages: ChatMessage[];
|
||||
isLoading: boolean;
|
||||
streamingText: string;
|
||||
streamingImages: string[];
|
||||
streamingImages: ImageAsset[];
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
@@ -19,27 +19,44 @@ export function ChatMessages({
|
||||
statusText,
|
||||
}: ChatMessagesProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-6">
|
||||
{/* 历史消息 */}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className="flex-1 overflow-y-auto px-3 md:px-4 py-4 md:py-6 space-y-4 md:space-y-6">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
||||
className={`max-w-[90%] md:max-w-[80%] rounded-2xl px-3.5 md:px-4 py-2.5 md:py-3 ${
|
||||
msg.role === "user"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{msg.refImageUrl && (
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={msg.refImageUrl}
|
||||
alt="参考图"
|
||||
className="max-w-[160px] max-h-[120px] rounded-lg object-cover border border-white/20"
|
||||
/>
|
||||
<span className="text-[10px] opacity-70 mt-1 block">参考图</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
|
||||
{msg.images && msg.images.length > 0 && <ImageGrid images={msg.images} />}
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<>
|
||||
<ImageGrid images={msg.images} />
|
||||
{msg.modelName && (
|
||||
<div className="mt-1.5 text-[10px] text-[var(--text-secondary)] opacity-70">
|
||||
由 {msg.modelName} 生成
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 正在生成的消息 */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl px-4 py-3 bg-[var(--bg-tertiary)]">
|
||||
<div className="max-w-[90%] md:max-w-[80%] rounded-2xl px-3.5 md:px-4 py-2.5 md:py-3 bg-[var(--bg-tertiary)]">
|
||||
{statusText && (
|
||||
<div className="text-xs text-[var(--accent)] mb-2 flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { ImageAsset } from "@/lib/types";
|
||||
|
||||
interface ImageGridProps {
|
||||
images: string[];
|
||||
images: ImageAsset[];
|
||||
}
|
||||
|
||||
export function ImageGrid({ images }: ImageGridProps) {
|
||||
const { setDetailImage, toggleFavorite } = useApp();
|
||||
|
||||
const handleDownload = async (imageUrl: string, index: number) => {
|
||||
try {
|
||||
const fullUrl = getImageUrl(imageUrl);
|
||||
@@ -15,7 +19,7 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `art-agent-${Date.now()}-${index + 1}.png`;
|
||||
a.download = `epeekit-${Date.now()}-${index + 1}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -27,30 +31,63 @@ export function ImageGrid({ images }: ImageGridProps) {
|
||||
|
||||
const gridCols =
|
||||
images.length === 1
|
||||
? "grid-cols-1 max-w-md"
|
||||
: images.length === 2
|
||||
? "grid-cols-2 max-w-2xl"
|
||||
: "grid-cols-2 max-w-2xl";
|
||||
? "grid-cols-1 max-w-[280px] md:max-w-sm"
|
||||
: "grid-cols-2 max-w-[320px] md:max-w-xl";
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridCols} gap-3 my-3`}>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="group relative rounded-lg overflow-hidden border border-[var(--border)]">
|
||||
<div className={`grid ${gridCols} gap-2 my-3`}>
|
||||
{images.map((asset, i) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="group relative rounded-lg overflow-hidden border border-[var(--border)]"
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(img)}
|
||||
src={getImageUrl(asset.url)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover"
|
||||
className="w-full aspect-square object-cover cursor-pointer"
|
||||
loading="lazy"
|
||||
onClick={() => setDetailImage(asset)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDownload(img, i)}
|
||||
className="absolute bottom-2 right-2 px-3 py-1.5 rounded-md
|
||||
bg-black/70 text-white text-sm
|
||||
opacity-0 group-hover:opacity-100 transition-opacity
|
||||
hover:bg-black/90 cursor-pointer"
|
||||
|
||||
{/* 操作栏:移动端始终可见,桌面端 hover 显示 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 px-2 py-1.5
|
||||
bg-gradient-to-t from-black/80 to-transparent
|
||||
opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity
|
||||
flex items-center gap-1"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDetailImage(asset)}
|
||||
className="p-1 rounded text-white/80 hover:text-white cursor-pointer"
|
||||
title="放大查看"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(asset.url, i)}
|
||||
className="p-1 rounded text-white/80 hover:text-white cursor-pointer"
|
||||
title="保存"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(asset.id);
|
||||
}}
|
||||
className="p-1 rounded cursor-pointer transition-colors"
|
||||
style={{ color: asset.favorited ? "#f59e0b" : "rgba(255,255,255,0.6)" }}
|
||||
title={asset.favorited ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
125
art-agent/frontend/src/components/chat/model-selector.tsx
Normal file
125
art-agent/frontend/src/components/chat/model-selector.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ImageModelInfo } from "@/lib/types";
|
||||
import { fetchModels } from "@/lib/api";
|
||||
|
||||
const STORAGE_KEY = "epeekit-selected-image-model";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
value: string;
|
||||
onChange: (modelId: string) => void;
|
||||
}
|
||||
|
||||
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
|
||||
const [models, setModels] = useState<ImageModelInfo[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
.then(({ models: list, default: defaultId }) => {
|
||||
setModels(list);
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
const validIds = new Set(list.map((m) => m.id));
|
||||
if (saved && validIds.has(saved)) {
|
||||
onChange(saved);
|
||||
} else if (!value || !validIds.has(value)) {
|
||||
onChange(defaultId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 后端不可用时静默降级
|
||||
});
|
||||
// 仅初始化时执行一次
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const selected = models.find((m) => m.id === value);
|
||||
|
||||
if (models.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs
|
||||
border border-[var(--border)] bg-[var(--bg-tertiary)]
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
hover:border-[var(--accent)] transition-colors cursor-pointer"
|
||||
title="切换生图模型"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<span className="max-w-[100px] truncate">{selected?.name ?? "模型"}</span>
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5"
|
||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg
|
||||
border border-[var(--border)] bg-[var(--bg-secondary)]
|
||||
shadow-lg overflow-hidden z-50"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const isActive = m.id === value;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
localStorage.setItem(STORAGE_KEY, m.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2.5 flex flex-col gap-0.5
|
||||
transition-colors cursor-pointer
|
||||
${isActive
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
||||
{m.name}
|
||||
{m.supports_ref_image && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full
|
||||
bg-emerald-500/15 text-emerald-400 font-normal leading-none">
|
||||
参考图
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-secondary)]">{m.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
art-agent/frontend/src/components/detail/annotation-canvas.tsx
Normal file
487
art-agent/frontend/src/components/detail/annotation-canvas.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback, type MouseEvent } from "react";
|
||||
import type { Annotation } from "@/lib/types";
|
||||
import { generateId } from "@/lib/store";
|
||||
|
||||
type Tool = "rect" | "arrow" | "freehand" | "text";
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
imageUrl: string;
|
||||
onComplete: (annotations: Annotation[], snapshot: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function AnnotationCanvas({ imageUrl, onComplete, onCancel }: AnnotationCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const [tool, setTool] = useState<Tool>("rect");
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [currentPos, setCurrentPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [freehandPoints, setFreehandPoints] = useState<{ x: number; y: number }[]>([]);
|
||||
const [editingAnnotation, setEditingAnnotation] = useState<string | null>(null);
|
||||
const [editText, setEditText] = useState("");
|
||||
const [undoStack, setUndoStack] = useState<Annotation[][]>([]);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// 获取鼠标相对于 canvas 的坐标(归一化到图片尺寸)
|
||||
const getRelPos = useCallback((e: MouseEvent): { x: number; y: number } | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载图片
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImgLoaded(true);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
// 渲染 canvas
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!canvas || !img) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
// 绘制已有标注
|
||||
for (const ann of annotations) {
|
||||
drawAnnotation(ctx, ann, w, h, false);
|
||||
}
|
||||
|
||||
// 绘制当前正在创建的标注
|
||||
if (drawing && startPos && currentPos) {
|
||||
ctx.strokeStyle = "#f43f5e";
|
||||
ctx.lineWidth = Math.max(2, w * 0.003);
|
||||
ctx.setLineDash([w * 0.005, w * 0.003]);
|
||||
|
||||
if (tool === "rect") {
|
||||
const rx = startPos.x * w, ry = startPos.y * h;
|
||||
const rw = (currentPos.x - startPos.x) * w;
|
||||
const rh = (currentPos.y - startPos.y) * h;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
} else if (tool === "arrow") {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startPos.x * w, startPos.y * h);
|
||||
ctx.lineTo(currentPos.x * w, currentPos.y * h);
|
||||
ctx.stroke();
|
||||
drawArrowHead(ctx, startPos.x * w, startPos.y * h, currentPos.x * w, currentPos.y * h, w * 0.015);
|
||||
} else if (tool === "freehand" && freehandPoints.length > 1) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(freehandPoints[0].x * w, freehandPoints[0].y * h);
|
||||
for (let i = 1; i < freehandPoints.length; i++) {
|
||||
ctx.lineTo(freehandPoints[i].x * w, freehandPoints[i].y * h);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}, [annotations, drawing, startPos, currentPos, freehandPoints, tool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (imgLoaded) render();
|
||||
}, [imgLoaded, render]);
|
||||
|
||||
// 鼠标事件
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (tool === "text") {
|
||||
const pos = getRelPos(e);
|
||||
if (!pos) return;
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "text",
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
text: "",
|
||||
};
|
||||
pushUndo();
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setEditingAnnotation(ann.id);
|
||||
setEditText("");
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = getRelPos(e);
|
||||
if (!pos) return;
|
||||
setDrawing(true);
|
||||
setStartPos(pos);
|
||||
setCurrentPos(pos);
|
||||
if (tool === "freehand") {
|
||||
setFreehandPoints([pos]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!drawing) return;
|
||||
const pos = getRelPos(e);
|
||||
if (!pos) return;
|
||||
setCurrentPos(pos);
|
||||
if (tool === "freehand") {
|
||||
setFreehandPoints((prev) => [...prev, pos]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!drawing || !startPos || !currentPos) {
|
||||
setDrawing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
pushUndo();
|
||||
|
||||
if (tool === "rect") {
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "rect",
|
||||
x: Math.min(startPos.x, currentPos.x),
|
||||
y: Math.min(startPos.y, currentPos.y),
|
||||
w: Math.abs(currentPos.x - startPos.x),
|
||||
h: Math.abs(currentPos.y - startPos.y),
|
||||
text: "",
|
||||
};
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setEditingAnnotation(ann.id);
|
||||
setEditText("");
|
||||
} else if (tool === "arrow") {
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "arrow",
|
||||
x: startPos.x,
|
||||
y: startPos.y,
|
||||
w: currentPos.x - startPos.x,
|
||||
h: currentPos.y - startPos.y,
|
||||
text: "",
|
||||
};
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setEditingAnnotation(ann.id);
|
||||
setEditText("");
|
||||
} else if (tool === "freehand") {
|
||||
const ann: Annotation = {
|
||||
id: generateId("ann-"),
|
||||
type: "freehand",
|
||||
x: freehandPoints[0]?.x ?? 0,
|
||||
y: freehandPoints[0]?.y ?? 0,
|
||||
points: [...freehandPoints],
|
||||
text: "",
|
||||
};
|
||||
setAnnotations((prev) => [...prev, ann]);
|
||||
setFreehandPoints([]);
|
||||
}
|
||||
|
||||
setDrawing(false);
|
||||
setStartPos(null);
|
||||
setCurrentPos(null);
|
||||
};
|
||||
|
||||
const pushUndo = () => {
|
||||
setUndoStack((prev) => [...prev, [...annotations]]);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (undoStack.length === 0) return;
|
||||
const prev = undoStack[undoStack.length - 1];
|
||||
setUndoStack((s) => s.slice(0, -1));
|
||||
setAnnotations(prev);
|
||||
setEditingAnnotation(null);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
pushUndo();
|
||||
setAnnotations([]);
|
||||
setEditingAnnotation(null);
|
||||
};
|
||||
|
||||
const commitText = () => {
|
||||
if (!editingAnnotation) return;
|
||||
setAnnotations((prev) =>
|
||||
prev.map((a) => (a.id === editingAnnotation ? { ...a, text: editText } : a))
|
||||
);
|
||||
setEditingAnnotation(null);
|
||||
setEditText("");
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
// 先提交正在编辑的文本
|
||||
let finalAnnotations = annotations;
|
||||
if (editingAnnotation) {
|
||||
finalAnnotations = annotations.map((a) =>
|
||||
a.id === editingAnnotation ? { ...a, text: editText } : a
|
||||
);
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// 最终渲染一次(含所有文本)
|
||||
const img = imageRef.current;
|
||||
if (img) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const ann of finalAnnotations) {
|
||||
drawAnnotation(ctx, ann, canvas.width, canvas.height, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = canvas.toDataURL("image/png");
|
||||
onComplete(finalAnnotations, snapshot);
|
||||
};
|
||||
|
||||
const TOOLS: { key: Tool; label: string; icon: string }[] = [
|
||||
{ key: "rect", label: "矩形", icon: "□" },
|
||||
{ key: "arrow", label: "箭头", icon: "→" },
|
||||
{ key: "freehand", label: "画笔", icon: "✎" },
|
||||
{ key: "text", label: "文字", icon: "T" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-[var(--border)] bg-[var(--bg-tertiary)] flex-wrap">
|
||||
{TOOLS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTool(t.key)}
|
||||
className={`px-2.5 py-1 text-xs rounded cursor-pointer transition-colors ${
|
||||
tool === t.key
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)]"
|
||||
}`}
|
||||
title={t.label}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-px h-4 bg-[var(--border)] mx-1" />
|
||||
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
disabled={undoStack.length === 0}
|
||||
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
|
||||
disabled:opacity-30 cursor-pointer"
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={annotations.length === 0}
|
||||
className="px-2 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] bg-[var(--bg-primary)]
|
||||
disabled:opacity-30 cursor-pointer"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2.5 py-1 text-xs rounded text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="px-3 py-1 text-xs rounded bg-[var(--accent)] text-white
|
||||
hover:bg-[var(--accent-hover)] cursor-pointer"
|
||||
>
|
||||
完成标注
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas 区域 */}
|
||||
<div ref={containerRef} className="flex-1 overflow-auto p-3 relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-w-full rounded-lg cursor-crosshair"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
|
||||
{/* 文字输入弹出框 */}
|
||||
{editingAnnotation && (() => {
|
||||
const ann = annotations.find((a) => a.id === editingAnnotation);
|
||||
if (!ann) return null;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const container = containerRef.current;
|
||||
if (!container) return null;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
let px: number, py: number;
|
||||
if (ann.type === "rect") {
|
||||
px = (ann.x + (ann.w ?? 0)) * rect.width + (rect.left - containerRect.left);
|
||||
py = ann.y * rect.height + (rect.top - containerRect.top);
|
||||
} else {
|
||||
px = ann.x * rect.width + (rect.left - containerRect.left) + 10;
|
||||
py = ann.y * rect.height + (rect.top - containerRect.top);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-10 bg-[var(--bg-secondary)] border border-[var(--accent)]
|
||||
rounded-lg shadow-lg p-2 min-w-[180px]"
|
||||
style={{ left: px, top: py }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitText();
|
||||
if (e.key === "Escape") {
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
|
||||
setEditingAnnotation(null);
|
||||
}
|
||||
}}
|
||||
placeholder="输入批注..."
|
||||
className="w-full px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
rounded text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== editingAnnotation));
|
||||
setEditingAnnotation(null);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] text-[var(--text-secondary)] cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={commitText}
|
||||
className="px-2 py-0.5 text-[10px] bg-[var(--accent)] text-white
|
||||
rounded cursor-pointer"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 绘制辅助函数 ---
|
||||
|
||||
function drawAnnotation(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ann: Annotation,
|
||||
w: number,
|
||||
h: number,
|
||||
isFinal: boolean,
|
||||
) {
|
||||
const lineWidth = Math.max(2, w * 0.003);
|
||||
ctx.strokeStyle = "#f43f5e";
|
||||
ctx.fillStyle = "#f43f5e";
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.setLineDash([]);
|
||||
|
||||
switch (ann.type) {
|
||||
case "rect":
|
||||
ctx.strokeRect(ann.x * w, ann.y * h, (ann.w ?? 0) * w, (ann.h ?? 0) * h);
|
||||
break;
|
||||
case "arrow": {
|
||||
const x1 = ann.x * w, y1 = ann.y * h;
|
||||
const x2 = (ann.x + (ann.w ?? 0)) * w, y2 = (ann.y + (ann.h ?? 0)) * h;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
drawArrowHead(ctx, x1, y1, x2, y2, w * 0.015);
|
||||
break;
|
||||
}
|
||||
case "freehand":
|
||||
if (ann.points && ann.points.length > 1) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ann.points[0].x * w, ann.points[0].y * h);
|
||||
for (let i = 1; i < ann.points.length; i++) {
|
||||
ctx.lineTo(ann.points[i].x * w, ann.points[i].y * h);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
break;
|
||||
case "text":
|
||||
break;
|
||||
}
|
||||
|
||||
// 绘制文字标签
|
||||
if (ann.text) {
|
||||
const fontSize = Math.max(12, w * 0.018);
|
||||
ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
|
||||
const metrics = ctx.measureText(ann.text);
|
||||
const padding = fontSize * 0.4;
|
||||
|
||||
let tx: number, ty: number;
|
||||
if (ann.type === "rect") {
|
||||
tx = ann.x * w;
|
||||
ty = ann.y * h - padding;
|
||||
} else {
|
||||
tx = ann.x * w;
|
||||
ty = ann.y * h - padding;
|
||||
}
|
||||
|
||||
// 文字背景
|
||||
ctx.fillStyle = "rgba(244, 63, 94, 0.85)";
|
||||
ctx.fillRect(
|
||||
tx - padding * 0.5,
|
||||
ty - fontSize,
|
||||
metrics.width + padding,
|
||||
fontSize + padding
|
||||
);
|
||||
|
||||
// 文字
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(ann.text, tx, ty);
|
||||
}
|
||||
}
|
||||
|
||||
function drawArrowHead(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
fromX: number, fromY: number,
|
||||
toX: number, toY: number,
|
||||
size: number,
|
||||
) {
|
||||
const angle = Math.atan2(toY - fromY, toX - fromX);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX, toY);
|
||||
ctx.lineTo(toX - size * Math.cos(angle - Math.PI / 6), toY - size * Math.sin(angle - Math.PI / 6));
|
||||
ctx.lineTo(toX - size * Math.cos(angle + Math.PI / 6), toY - size * Math.sin(angle + Math.PI / 6));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
236
art-agent/frontend/src/components/detail/image-detail-panel.tsx
Normal file
236
art-agent/frontend/src/components/detail/image-detail-panel.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { AnnotationCanvas } from "./annotation-canvas";
|
||||
import type { Annotation, AnnotationData } from "@/lib/types";
|
||||
|
||||
interface ImageDetailPanelProps {
|
||||
onAnnotationComplete?: (data: AnnotationData) => void;
|
||||
}
|
||||
|
||||
export function ImageDetailPanel({ onAnnotationComplete }: ImageDetailPanelProps) {
|
||||
const { detailImage, setDetailImage, toggleFavorite, tags, deleteAssetById } = useApp();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [showAnnotate, setShowAnnotate] = useState(false);
|
||||
|
||||
if (!detailImage) return null;
|
||||
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const fullUrl = getImageUrl(detailImage.url);
|
||||
const response = await fetch(fullUrl);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `epeekit-${Date.now()}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
alert("下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPrompt = () => {
|
||||
navigator.clipboard.writeText(detailImage.prompt);
|
||||
};
|
||||
|
||||
const handleAnnotationComplete = (annotations: Annotation[], snapshot: string) => {
|
||||
setShowAnnotate(false);
|
||||
const data: AnnotationData = {
|
||||
imageUrl: detailImage.url,
|
||||
annotations,
|
||||
snapshot,
|
||||
};
|
||||
onAnnotationComplete?.(data);
|
||||
};
|
||||
|
||||
if (showAnnotate) {
|
||||
return (
|
||||
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||
md:w-[480px] flex-shrink-0 border-l border-[var(--border)] bg-[var(--bg-secondary)]
|
||||
flex flex-col overflow-hidden">
|
||||
<AnnotationCanvas
|
||||
imageUrl={getImageUrl(detailImage.url)}
|
||||
onComplete={handleAnnotationComplete}
|
||||
onCancel={() => setShowAnnotate(false)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-0 z-50 md:relative md:inset-auto md:z-auto
|
||||
md:w-[360px] flex-shrink-0 border-l border-[var(--border)] bg-[var(--bg-secondary)]
|
||||
flex flex-col overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">图片详情</span>
|
||||
<button
|
||||
onClick={() => setDetailImage(null)}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" 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">
|
||||
{/* 图片预览 */}
|
||||
<div className="p-4">
|
||||
<div className="relative rounded-lg overflow-hidden border border-[var(--border)] bg-[var(--bg-primary)]">
|
||||
<img
|
||||
src={getImageUrl(detailImage.url)}
|
||||
alt="预览"
|
||||
className="w-full transition-transform"
|
||||
style={{ transform: `scale(${scale})`, transformOrigin: "center" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 缩放控制 */}
|
||||
<div className="flex items-center justify-center gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-xs text-[var(--text-secondary)] min-w-[40px] text-center">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScale(1)}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] cursor-pointer"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--text-primary)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)] transition-colors cursor-pointer"
|
||||
>
|
||||
<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={() => toggleFavorite(detailImage.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
border transition-colors cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: detailImage.favorited ? "#f59e0b22" : "var(--bg-tertiary)",
|
||||
borderColor: detailImage.favorited ? "#f59e0b" : "var(--border)",
|
||||
color: detailImage.favorited ? "#f59e0b" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill={detailImage.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>
|
||||
{detailImage.favorited ? "已收藏" : "收藏"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAnnotate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-secondary)]
|
||||
hover:text-[var(--accent)] border border-[var(--border)]
|
||||
hover:border-[var(--accent)] transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
标注
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteAssetById(detailImage.id)}
|
||||
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 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prompt 信息 */}
|
||||
{detailImage.prompt && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">Prompt</span>
|
||||
<button
|
||||
onClick={handleCopyPrompt}
|
||||
className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-md bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
text-xs text-[var(--text-secondary)] leading-relaxed break-all">
|
||||
{detailImage.prompt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{detailImage.tags.length > 0 && (
|
||||
<div className="px-4 pb-3">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">标签</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{detailImage.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="px-4 pb-4">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] block mb-1.5">信息</span>
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
<div>创建时间:{new Date(detailImage.createdAt).toLocaleString("zh-CN")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
84
art-agent/frontend/src/components/layout/top-nav.tsx
Normal file
84
art-agent/frontend/src/components/layout/top-nav.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "对话" },
|
||||
{ href: "/gallery", label: "资源库" },
|
||||
] as const;
|
||||
|
||||
export function TopNav() {
|
||||
const pathname = usePathname();
|
||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||
|
||||
return (
|
||||
<header className="flex-shrink-0 h-12 border-b border-[var(--border)] bg-[var(--bg-secondary)] flex items-center px-3 md:px-4 gap-3 md:gap-6">
|
||||
{/* 移动端汉堡菜单 */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="md:hidden flex-shrink-0 p-1.5 rounded-md
|
||||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
transition-colors cursor-pointer"
|
||||
title="菜单"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 flex-shrink-0">
|
||||
<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="7" fill="var(--accent)" />
|
||||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="white" />
|
||||
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" />
|
||||
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="23" cy="22.5" r="1" fill="white" />
|
||||
</svg>
|
||||
<span className="text-base font-bold tracking-tight text-[var(--text-primary)]">
|
||||
EPEEKit
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* 导航 Tab */}
|
||||
<nav className="flex items-center gap-1">
|
||||
{NAV_ITEMS.map(({ href, label }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`px-2.5 md:px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
active
|
||||
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)] font-medium"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 全局搜索入口 — 移动端只显示图标 */}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 md:px-3 py-1.5 rounded-md text-sm
|
||||
text-[var(--text-secondary)] border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] hover:border-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
title="搜索"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">搜索</span>
|
||||
<kbd className="hidden md:inline text-xs text-[var(--text-secondary)] opacity-50 ml-2">⌘K</kbd>
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
196
art-agent/frontend/src/components/sidebar/session-list.tsx
Normal file
196
art-agent/frontend/src/components/sidebar/session-list.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins}分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}天前`;
|
||||
return new Date(ts).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
export function SessionList() {
|
||||
const {
|
||||
sessions, activeSessionId, tags, selectedTagIds,
|
||||
createSession, switchSession, deleteSession, renameSession,
|
||||
} = useApp();
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const editRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 标签过滤
|
||||
const filtered = selectedTagIds.length === 0
|
||||
? sessions
|
||||
: sessions.filter((s) => selectedTagIds.some((tid) => s.tags.includes(tid)));
|
||||
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
|
||||
// 关闭右键菜单
|
||||
useEffect(() => {
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, []);
|
||||
|
||||
// 编辑聚焦
|
||||
useEffect(() => {
|
||||
if (editingId) editRef.current?.focus();
|
||||
}, [editingId]);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, sessionId });
|
||||
};
|
||||
|
||||
const startRename = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
setEditTitle(currentTitle);
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (editingId && editTitle.trim()) {
|
||||
renameSession(editingId, editTitle.trim());
|
||||
}
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 新建按钮 */}
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => createSession()}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2
|
||||
text-sm rounded-lg border border-dashed border-[var(--border)]
|
||||
text-[var(--text-secondary)] hover:border-[var(--accent)]
|
||||
hover:text-[var(--accent)] transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新建对话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 会话列表 */}
|
||||
<div className="px-2 pb-2 space-y-0.5">
|
||||
{filtered.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => switchSession(session.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, session.id)}
|
||||
className={`group relative flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer
|
||||
transition-colors ${
|
||||
session.id === activeSessionId
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]/50"
|
||||
}`}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
{session.thumbnail ? (
|
||||
<img
|
||||
src={getImageUrl(session.thumbnail)}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded object-cover flex-shrink-0 border border-[var(--border)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded bg-[var(--bg-primary)] border border-[var(--border)]
|
||||
flex items-center justify-center flex-shrink-0 text-[var(--text-secondary)] text-xs">
|
||||
💬
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会话信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingId === session.id ? (
|
||||
<input
|
||||
ref={editRef}
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
className="w-full text-sm bg-transparent border-b border-[var(--accent)]
|
||||
text-[var(--text-primary)] focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-primary)] truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 + 时间 */}
|
||||
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
{session.tags.slice(0, 3).map((tid) => {
|
||||
const tag = tagMap.get(tid);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={tid}
|
||||
className="text-[10px] px-1.5 py-px rounded-full"
|
||||
style={{ backgroundColor: tag.color + "22", color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] ml-auto flex-shrink-0">
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center text-xs text-[var(--text-secondary)] py-6">
|
||||
没有匹配的对话
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 bg-[var(--bg-secondary)] border border-[var(--border)]
|
||||
rounded-lg shadow-xl py-1 min-w-[120px]"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const s = sessions.find((s) => s.id === contextMenu.sessionId);
|
||||
if (s) startRename(s.id, s.title);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteSession(contextMenu.sessionId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-red-400
|
||||
hover:bg-[var(--bg-tertiary)] cursor-pointer"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
art-agent/frontend/src/components/sidebar/sidebar.tsx
Normal file
52
art-agent/frontend/src/components/sidebar/sidebar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import { TagFilter } from "./tag-filter";
|
||||
import { SessionList } from "./session-list";
|
||||
|
||||
export function Sidebar() {
|
||||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 sidebar-overlay md:hidden"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`
|
||||
/* 移动端:固定定位抽屉 */
|
||||
fixed inset-y-0 left-0 z-40 w-[280px]
|
||||
md:relative md:inset-auto md:z-auto
|
||||
flex-shrink-0 border-r border-[var(--border)] bg-[var(--bg-secondary)]
|
||||
flex flex-col transition-transform duration-200
|
||||
${sidebarCollapsed ? "-translate-x-full md:-translate-x-0 md:w-0 md:border-r-0" : "translate-x-0 md:w-[280px]"}
|
||||
${sidebarCollapsed ? "md:overflow-hidden" : ""}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border)]">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
对话
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||||
transition-colors cursor-pointer p-0.5"
|
||||
title="折叠侧边栏"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TagFilter />
|
||||
<SessionList />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
148
art-agent/frontend/src/components/sidebar/tag-filter.tsx
Normal file
148
art-agent/frontend/src/components/sidebar/tag-filter.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useApp } from "@/lib/app-context";
|
||||
import type { Tag } from "@/lib/types";
|
||||
|
||||
const TAG_COLORS = [
|
||||
"#6366f1", "#f59e0b", "#10b981", "#ec4899",
|
||||
"#8b5cf6", "#06b6d4", "#f97316", "#84cc16",
|
||||
];
|
||||
|
||||
export function TagFilter() {
|
||||
const { tags, selectedTagIds, setSelectedTagIds, addTag, deleteTag } = useApp();
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState("");
|
||||
const [newTagColor, setNewTagColor] = useState(TAG_COLORS[0]);
|
||||
|
||||
const toggle = (tagId: string) => {
|
||||
setSelectedTagIds(
|
||||
selectedTagIds.includes(tagId)
|
||||
? selectedTagIds.filter((id) => id !== tagId)
|
||||
: [...selectedTagIds, tagId]
|
||||
);
|
||||
};
|
||||
|
||||
const clearFilter = () => setSelectedTagIds([]);
|
||||
|
||||
const handleAddTag = () => {
|
||||
const trimmed = newTagName.trim();
|
||||
if (!trimmed) return;
|
||||
addTag(trimmed, newTagColor);
|
||||
setNewTagName("");
|
||||
setNewTagColor(TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 border-b border-[var(--border)]">
|
||||
{/* 标签 pills */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={clearFilter}
|
||||
className={`px-2 py-0.5 text-xs rounded-full transition-colors cursor-pointer ${
|
||||
selectedTagIds.length === 0
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{tags.map((tag) => {
|
||||
const active = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggle(tag.id)}
|
||||
className="px-2 py-0.5 text-xs rounded-full transition-colors cursor-pointer border"
|
||||
style={{
|
||||
backgroundColor: active ? tag.color + "22" : "transparent",
|
||||
borderColor: active ? tag.color : "var(--border)",
|
||||
color: active ? tag.color : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setShowManager(!showManager)}
|
||||
className="px-2 py-0.5 text-xs rounded-full
|
||||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||||
transition-colors cursor-pointer"
|
||||
title="管理标签"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签管理面板 */}
|
||||
{showManager && (
|
||||
<div className="mt-2 p-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border)]">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<input
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddTag()}
|
||||
placeholder="新标签名称"
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-[var(--bg-primary)]
|
||||
border border-[var(--border)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
<div className="flex gap-0.5">
|
||||
{TAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewTagColor(c)}
|
||||
className="w-4 h-4 rounded-full cursor-pointer transition-transform"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
transform: newTagColor === c ? "scale(1.3)" : "scale(1)",
|
||||
boxShadow: newTagColor === c ? `0 0 0 2px var(--bg-tertiary), 0 0 0 3px ${c}` : "none",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!newTagName.trim()}
|
||||
className="px-2 py-1 text-xs rounded bg-[var(--accent)] text-white
|
||||
disabled:opacity-40 cursor-pointer hover:bg-[var(--accent-hover)]
|
||||
transition-colors"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 自定义标签列表(可删除) */}
|
||||
{tags.filter((t) => !t.builtin).length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
{tags
|
||||
.filter((t) => !t.builtin)
|
||||
.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-2 py-0.5 rounded text-xs"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteTag(tag.id)}
|
||||
className="text-[var(--text-secondary)] hover:text-red-400 cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,37 +2,115 @@
|
||||
* 后端 API 调用封装 + SSE 流式读取。
|
||||
*/
|
||||
|
||||
import type { ApiMessage, ImageModelInfo } from "./types";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
export interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
images?: string[];
|
||||
export interface UploadProgress {
|
||||
/** 0-100 */
|
||||
percent: number;
|
||||
loaded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立上传参考图,支持进度回调。
|
||||
* 使用 XMLHttpRequest 以获取上传进度事件(fetch API 不支持)。
|
||||
*/
|
||||
export function uploadRefImage(
|
||||
file: File,
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<UploadResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
xhr.abort();
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
});
|
||||
}
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress({
|
||||
percent: Math.round((e.loaded / e.total) * 100),
|
||||
loaded: e.loaded,
|
||||
total: e.total,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
reject(new Error("解析上传响应失败"));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`上传失败: ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => reject(new Error("网络错误,上传失败")));
|
||||
xhr.addEventListener("timeout", () => reject(new Error("上传超时")));
|
||||
|
||||
xhr.open("POST", `${API_URL}/api/upload-ref-image`);
|
||||
xhr.timeout = 120_000;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: "text_delta" | "tool_start" | "image_result" | "done" | "error";
|
||||
type: "text_delta" | "tool_start" | "image_result" | "tool_error" | "done" | "error";
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的图像生成模型列表。
|
||||
*/
|
||||
export async function fetchModels(): Promise<{
|
||||
models: ImageModelInfo[];
|
||||
default: string;
|
||||
}> {
|
||||
const resp = await fetch(`${API_URL}/api/models`);
|
||||
if (!resp.ok) throw new Error(`获取模型列表失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送对话消息到后端,返回 SSE 事件的异步迭代器。
|
||||
* refImageUrl: 已通过 uploadRefImage 上传后的服务端路径(如 /uploads/xxx.png)
|
||||
*/
|
||||
export async function* sendChat(
|
||||
messages: Message[],
|
||||
refImageFile?: File | null
|
||||
messages: ApiMessage[],
|
||||
refImageUrl?: string | null,
|
||||
imageModel?: string | null
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const formData = new FormData();
|
||||
|
||||
// messages 序列化:只发 role + content
|
||||
const apiMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
formData.append("messages", JSON.stringify(apiMessages));
|
||||
|
||||
if (refImageFile) {
|
||||
formData.append("ref_image", refImageFile);
|
||||
if (refImageUrl) {
|
||||
formData.append("ref_image_url", refImageUrl);
|
||||
}
|
||||
|
||||
if (imageModel) {
|
||||
formData.append("image_model", imageModel);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/api/chat`, {
|
||||
@@ -55,8 +133,8 @@ export async function* sendChat(
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
|
||||
// 按 SSE 协议解析:每个事件以 \n\n 分隔
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() || "";
|
||||
|
||||
@@ -90,6 +168,6 @@ export async function* sendChat(
|
||||
|
||||
/** 获取图片完整 URL(处理相对路径)。 */
|
||||
export function getImageUrl(path: string): string {
|
||||
if (path.startsWith("http")) return path;
|
||||
if (path.startsWith("http") || path.startsWith("data:")) return path;
|
||||
return `${API_URL}${path}`;
|
||||
}
|
||||
|
||||
289
art-agent/frontend/src/lib/app-context.tsx
Normal file
289
art-agent/frontend/src/lib/app-context.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
|
||||
import {
|
||||
loadSessions,
|
||||
saveSessions,
|
||||
createSession as storeCreateSession,
|
||||
updateSession as storeUpdateSession,
|
||||
deleteSession as storeDeleteSession,
|
||||
loadTags,
|
||||
saveTags,
|
||||
addTag as storeAddTag,
|
||||
deleteTag as storeDeleteTag,
|
||||
loadAssets,
|
||||
addAsset as storeAddAsset,
|
||||
updateAsset as storeUpdateAsset,
|
||||
deleteAsset as storeDeleteAsset,
|
||||
toggleFavorite as storeToggleFavorite,
|
||||
generateId,
|
||||
} from "./store";
|
||||
|
||||
interface AppContextValue {
|
||||
// 会话
|
||||
sessions: Session[];
|
||||
activeSessionId: string | null;
|
||||
activeSession: Session | null;
|
||||
createSession: () => Session;
|
||||
switchSession: (id: string) => void;
|
||||
deleteSession: (id: string) => void;
|
||||
renameSession: (id: string, title: string) => void;
|
||||
updateSessionTags: (id: string, tags: string[]) => void;
|
||||
appendMessage: (sessionId: string, message: ChatMessage) => void;
|
||||
updateSessionThumbnail: (sessionId: string, url: string) => void;
|
||||
|
||||
// 标签
|
||||
tags: Tag[];
|
||||
addTag: (name: string, color: string) => Tag;
|
||||
deleteTag: (id: string) => void;
|
||||
selectedTagIds: string[];
|
||||
setSelectedTagIds: (ids: string[]) => void;
|
||||
|
||||
// 图片资源
|
||||
assets: ImageAsset[];
|
||||
addAsset: (asset: ImageAsset) => void;
|
||||
updateAsset: (id: string, patch: Partial<ImageAsset>) => void;
|
||||
deleteAssetById: (id: string) => void;
|
||||
toggleFavorite: (id: string) => boolean;
|
||||
|
||||
// 右侧面板
|
||||
detailImage: ImageAsset | null;
|
||||
setDetailImage: (asset: ImageAsset | null) => void;
|
||||
|
||||
// 左栏折叠
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
export function useApp(): AppContextValue {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error("useApp must be used within AppProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [assets, setAssets] = useState<ImageAsset[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [detailImage, setDetailImage] = useState<ImageAsset | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== "undefined") return window.innerWidth < 768;
|
||||
return false;
|
||||
});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// 初始化:从 localStorage 加载
|
||||
useEffect(() => {
|
||||
setSessions(loadSessions());
|
||||
setTags(loadTags());
|
||||
setAssets(loadAssets());
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
|
||||
// 初始化后,如果没有会话则自动创建一个
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
if (sessions.length === 0) {
|
||||
const s = storeCreateSession();
|
||||
setSessions([s]);
|
||||
setActiveSessionId(s.id);
|
||||
} else if (!activeSessionId) {
|
||||
setActiveSessionId(sessions[0].id);
|
||||
}
|
||||
}, [initialized, sessions.length, activeSessionId]);
|
||||
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId]
|
||||
);
|
||||
|
||||
// --- 会话操作 ---
|
||||
|
||||
const createSession = useCallback(() => {
|
||||
const s = storeCreateSession();
|
||||
setSessions((prev) => [s, ...prev]);
|
||||
setActiveSessionId(s.id);
|
||||
setDetailImage(null);
|
||||
return s;
|
||||
}, []);
|
||||
|
||||
const switchSession = useCallback((id: string) => {
|
||||
setActiveSessionId(id);
|
||||
setDetailImage(null);
|
||||
if (typeof window !== "undefined" && window.innerWidth < 768) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteSessionCb = useCallback(
|
||||
(id: string) => {
|
||||
storeDeleteSession(id);
|
||||
setSessions((prev) => {
|
||||
const next = prev.filter((s) => s.id !== id);
|
||||
if (activeSessionId === id) {
|
||||
if (next.length > 0) {
|
||||
setActiveSessionId(next[0].id);
|
||||
} else {
|
||||
const s = storeCreateSession();
|
||||
next.push(s);
|
||||
setActiveSessionId(s.id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAssets((prev) => prev.filter((a) => a.sessionId !== id));
|
||||
setDetailImage(null);
|
||||
},
|
||||
[activeSessionId]
|
||||
);
|
||||
|
||||
const renameSession = useCallback((id: string, title: string) => {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const updated = { ...s, title, updatedAt: Date.now() };
|
||||
storeUpdateSession(updated);
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateSessionTags = useCallback((id: string, tagIds: string[]) => {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const updated = { ...s, tags: tagIds, updatedAt: Date.now() };
|
||||
storeUpdateSession(updated);
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const appendMessage = useCallback((sessionId: string, message: ChatMessage) => {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const updated = {
|
||||
...s,
|
||||
messages: [...s.messages, message],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
// 用首条用户消息作为自动标题
|
||||
if (message.role === "user" && s.messages.length === 0) {
|
||||
updated.title = message.content.slice(0, 30) + (message.content.length > 30 ? "…" : "");
|
||||
}
|
||||
storeUpdateSession(updated);
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateSessionThumbnail = useCallback((sessionId: string, url: string) => {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== sessionId) return s;
|
||||
const updated = { ...s, thumbnail: url, updatedAt: Date.now() };
|
||||
storeUpdateSession(updated);
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
// --- 标签操作 ---
|
||||
|
||||
const addTag = useCallback((name: string, color: string) => {
|
||||
const t = storeAddTag(name, color);
|
||||
setTags(loadTags());
|
||||
return t;
|
||||
}, []);
|
||||
|
||||
const deleteTagCb = useCallback((id: string) => {
|
||||
storeDeleteTag(id);
|
||||
setTags(loadTags());
|
||||
setSelectedTagIds((prev) => prev.filter((tid) => tid !== id));
|
||||
}, []);
|
||||
|
||||
// --- 资源操作 ---
|
||||
|
||||
const addAssetCb = useCallback((asset: ImageAsset) => {
|
||||
storeAddAsset(asset);
|
||||
setAssets((prev) => [asset, ...prev]);
|
||||
}, []);
|
||||
|
||||
const updateAssetCb = useCallback((id: string, patch: Partial<ImageAsset>) => {
|
||||
storeUpdateAsset(id, patch);
|
||||
setAssets((prev) => prev.map((a) => (a.id === id ? { ...a, ...patch } : a)));
|
||||
setDetailImage((prev) => (prev && prev.id === id ? { ...prev, ...patch } : prev));
|
||||
}, []);
|
||||
|
||||
const deleteAssetCb = useCallback((id: string) => {
|
||||
storeDeleteAsset(id);
|
||||
setAssets((prev) => prev.filter((a) => a.id !== id));
|
||||
setDetailImage((prev) => (prev && prev.id === id ? null : prev));
|
||||
}, []);
|
||||
|
||||
const toggleFavoriteCb = useCallback((id: string) => {
|
||||
const result = storeToggleFavorite(id);
|
||||
setAssets((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, favorited: result } : a))
|
||||
);
|
||||
setDetailImage((prev) =>
|
||||
prev && prev.id === id ? { ...prev, favorited: result } : prev
|
||||
);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
createSession,
|
||||
switchSession,
|
||||
deleteSession: deleteSessionCb,
|
||||
renameSession,
|
||||
updateSessionTags,
|
||||
appendMessage,
|
||||
updateSessionThumbnail,
|
||||
tags,
|
||||
addTag,
|
||||
deleteTag: deleteTagCb,
|
||||
selectedTagIds,
|
||||
setSelectedTagIds,
|
||||
assets,
|
||||
addAsset: addAssetCb,
|
||||
updateAsset: updateAssetCb,
|
||||
deleteAssetById: deleteAssetCb,
|
||||
toggleFavorite: toggleFavoriteCb,
|
||||
detailImage,
|
||||
setDetailImage,
|
||||
sidebarCollapsed,
|
||||
setSidebarCollapsed,
|
||||
}),
|
||||
[
|
||||
sessions, activeSessionId, activeSession,
|
||||
createSession, switchSession, deleteSessionCb,
|
||||
renameSession, updateSessionTags, appendMessage, updateSessionThumbnail,
|
||||
tags, addTag, deleteTagCb, selectedTagIds,
|
||||
assets, addAssetCb, updateAssetCb, deleteAssetCb, toggleFavoriteCb,
|
||||
detailImage, sidebarCollapsed,
|
||||
]
|
||||
);
|
||||
|
||||
if (!initialized) return null;
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
160
art-agent/frontend/src/lib/store.ts
Normal file
160
art-agent/frontend/src/lib/store.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 基于 localStorage 的客户端持久化存储。
|
||||
* 提供会话、标签、图片资源的 CRUD 操作。
|
||||
*/
|
||||
|
||||
import type { Session, Tag, ImageAsset, ChatMessage } from "./types";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
sessions: "epeekit-sessions",
|
||||
tags: "epeekit-tags",
|
||||
assets: "epeekit-assets",
|
||||
} as const;
|
||||
|
||||
// --------------- 内置标签 ---------------
|
||||
|
||||
const BUILTIN_TAGS: Tag[] = [
|
||||
{ id: "tag-ui", name: "UI", color: "#6366f1", builtin: true },
|
||||
{ id: "tag-icon", name: "Icon", color: "#f59e0b", builtin: true },
|
||||
{ id: "tag-illustration", name: "原画", color: "#10b981", builtin: true },
|
||||
{ id: "tag-style", name: "风格探索", color: "#ec4899", builtin: true },
|
||||
{ id: "tag-character", name: "立绘", color: "#8b5cf6", builtin: true },
|
||||
{ id: "tag-concept", name: "概念图", color: "#06b6d4", builtin: true },
|
||||
];
|
||||
|
||||
// --------------- 通用 helpers ---------------
|
||||
|
||||
function readJSON<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJSON<T>(key: string, data: T) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// --------------- ID 生成 ---------------
|
||||
|
||||
let counter = 0;
|
||||
export function generateId(prefix = ""): string {
|
||||
counter++;
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 6);
|
||||
return `${prefix}${ts}-${rand}-${counter}`;
|
||||
}
|
||||
|
||||
// --------------- 标签 ---------------
|
||||
|
||||
export function loadTags(): Tag[] {
|
||||
const custom = readJSON<Tag[]>(STORAGE_KEYS.tags, []);
|
||||
const builtinIds = new Set(BUILTIN_TAGS.map((t) => t.id));
|
||||
const merged = [...BUILTIN_TAGS, ...custom.filter((t) => !builtinIds.has(t.id))];
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function saveTags(tags: Tag[]) {
|
||||
const custom = tags.filter((t) => !t.builtin);
|
||||
writeJSON(STORAGE_KEYS.tags, custom);
|
||||
}
|
||||
|
||||
export function addTag(name: string, color: string): Tag {
|
||||
const tag: Tag = { id: generateId("tag-"), name, color, builtin: false };
|
||||
const existing = loadTags();
|
||||
saveTags([...existing, tag]);
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function deleteTag(tagId: string) {
|
||||
const tags = loadTags().filter((t) => t.id !== tagId && !t.builtin);
|
||||
saveTags(tags);
|
||||
}
|
||||
|
||||
// --------------- 会话 ---------------
|
||||
|
||||
export function loadSessions(): Session[] {
|
||||
return readJSON<Session[]>(STORAGE_KEYS.sessions, []);
|
||||
}
|
||||
|
||||
export function saveSessions(sessions: Session[]) {
|
||||
writeJSON(STORAGE_KEYS.sessions, sessions);
|
||||
}
|
||||
|
||||
export function createSession(): Session {
|
||||
const now = Date.now();
|
||||
const session: Session = {
|
||||
id: generateId("sess-"),
|
||||
title: "新对话",
|
||||
tags: [],
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const sessions = loadSessions();
|
||||
saveSessions([session, ...sessions]);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function updateSession(session: Session) {
|
||||
const sessions = loadSessions();
|
||||
const idx = sessions.findIndex((s) => s.id === session.id);
|
||||
if (idx >= 0) {
|
||||
sessions[idx] = { ...session, updatedAt: Date.now() };
|
||||
} else {
|
||||
sessions.unshift({ ...session, updatedAt: Date.now() });
|
||||
}
|
||||
saveSessions(sessions);
|
||||
}
|
||||
|
||||
export function deleteSession(sessionId: string) {
|
||||
const sessions = loadSessions().filter((s) => s.id !== sessionId);
|
||||
saveSessions(sessions);
|
||||
// 同时删除关联的图片资源
|
||||
const assets = loadAssets().filter((a) => a.sessionId !== sessionId);
|
||||
saveAssets(assets);
|
||||
}
|
||||
|
||||
// --------------- 图片资源 ---------------
|
||||
|
||||
export function loadAssets(): ImageAsset[] {
|
||||
return readJSON<ImageAsset[]>(STORAGE_KEYS.assets, []);
|
||||
}
|
||||
|
||||
export function saveAssets(assets: ImageAsset[]) {
|
||||
writeJSON(STORAGE_KEYS.assets, assets);
|
||||
}
|
||||
|
||||
export function addAsset(asset: ImageAsset) {
|
||||
const assets = loadAssets();
|
||||
assets.unshift(asset);
|
||||
saveAssets(assets);
|
||||
}
|
||||
|
||||
export function updateAsset(assetId: string, patch: Partial<ImageAsset>) {
|
||||
const assets = loadAssets();
|
||||
const idx = assets.findIndex((a) => a.id === assetId);
|
||||
if (idx >= 0) {
|
||||
assets[idx] = { ...assets[idx], ...patch };
|
||||
saveAssets(assets);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteAsset(assetId: string) {
|
||||
saveAssets(loadAssets().filter((a) => a.id !== assetId));
|
||||
}
|
||||
|
||||
export function toggleFavorite(assetId: string): boolean {
|
||||
const assets = loadAssets();
|
||||
const idx = assets.findIndex((a) => a.id === assetId);
|
||||
if (idx >= 0) {
|
||||
assets[idx].favorited = !assets[idx].favorited;
|
||||
saveAssets(assets);
|
||||
return assets[idx].favorited;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
77
art-agent/frontend/src/lib/types.ts
Normal file
77
art-agent/frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 全局类型定义。
|
||||
*/
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
/** 是否为系统内置标签 */
|
||||
builtin: boolean;
|
||||
}
|
||||
|
||||
export interface ImageAsset {
|
||||
id: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
sessionId: string;
|
||||
tags: string[];
|
||||
favorited: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
images?: ImageAsset[];
|
||||
/** 用户消息附带的参考图(本地预览 URL 或上传后的服务端路径) */
|
||||
refImageUrl?: string;
|
||||
/** 生成图片时使用的模型名称 */
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
messages: ChatMessage[];
|
||||
/** 最后生成的图片 URL(用于缩略图) */
|
||||
thumbnail?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 用于 API 传输的精简消息格式 */
|
||||
export interface ApiMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 图像生成模型信息(从后端 GET /api/models 返回) */
|
||||
export interface ImageModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 是否原生支持参考图输入(IP-Adapter 等) */
|
||||
supports_ref_image?: boolean;
|
||||
}
|
||||
|
||||
/** 标注数据 */
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
type: "rect" | "arrow" | "freehand" | "text";
|
||||
x: number;
|
||||
y: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
points?: { x: number; y: number }[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AnnotationData {
|
||||
imageUrl: string;
|
||||
annotations: Annotation[];
|
||||
/** canvas 导出的带标注截图(base64) */
|
||||
snapshot?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user