kit初版,模型引入,agent优化

This commit is contained in:
2026-04-13 00:41:23 +08:00
parent 9b053e302b
commit c435ab15cf
14097 changed files with 5032 additions and 2676248 deletions

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)]

View File

@@ -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" />

View File

@@ -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>

View 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>
);
}

View 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();
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View File

@@ -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}`;
}

View 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>;
}

View 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;
}

View 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;
}