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

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