大活
This commit is contained in:
131
art-agent/frontend/src/components/chat/chat-input.tsx
Normal file
131
art-agent/frontend/src/components/chat/chat-input.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, refImage: File | 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed, refImage);
|
||||
setText("");
|
||||
setRefImage(null);
|
||||
setPreviewUrl(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
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 (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--border)] bg-[var(--bg-secondary)] px-4 py-3">
|
||||
{/* 参考图预览 */}
|
||||
{previewUrl && (
|
||||
<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>
|
||||
<span className="text-xs text-[var(--text-secondary)] mt-1">参考图已添加</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 上传参考图按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg border border-[var(--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"
|
||||
title="上传参考图"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 文字输入框 */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述你想要的美术资源..."
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-[var(--border)]
|
||||
bg-[var(--bg-tertiary)] text-[var(--text-primary)]
|
||||
placeholder:text-[var(--text-secondary)]
|
||||
px-4 py-2.5 text-sm leading-relaxed
|
||||
focus:outline-none focus:border-[var(--accent)]
|
||||
disabled:opacity-50 transition-colors
|
||||
min-h-[42px] max-h-[120px]"
|
||||
style={{ fieldSizing: "content" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="flex-shrink-0 w-10 h-10 rounded-lg
|
||||
bg-[var(--accent)] text-white
|
||||
hover:bg-[var(--accent-hover)]
|
||||
flex items-center justify-center transition-colors
|
||||
disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
art-agent/frontend/src/components/chat/chat-messages.tsx
Normal file
65
art-agent/frontend/src/components/chat/chat-messages.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@/lib/api";
|
||||
import { ImageGrid } from "./image-grid";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
streamingText: string;
|
||||
streamingImages: string[];
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
isLoading,
|
||||
streamingText,
|
||||
streamingImages,
|
||||
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={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
||||
msg.role === "user"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</div>
|
||||
{msg.images && msg.images.length > 0 && <ImageGrid images={msg.images} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 正在生成的消息 */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl px-4 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" />
|
||||
{statusText}
|
||||
</div>
|
||||
)}
|
||||
{streamingText && (
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed">{streamingText}</div>
|
||||
)}
|
||||
{streamingImages.length > 0 && <ImageGrid images={streamingImages} />}
|
||||
{!streamingText && !statusText && (
|
||||
<div className="flex gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.1s]" />
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--text-secondary)] animate-bounce [animation-delay:0.2s]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
art-agent/frontend/src/components/chat/image-grid.tsx
Normal file
58
art-agent/frontend/src/components/chat/image-grid.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { getImageUrl } from "@/lib/api";
|
||||
|
||||
interface ImageGridProps {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
export function ImageGrid({ images }: ImageGridProps) {
|
||||
const handleDownload = async (imageUrl: string, index: number) => {
|
||||
try {
|
||||
const fullUrl = getImageUrl(imageUrl);
|
||||
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 = `art-agent-${Date.now()}-${index + 1}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
alert("下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
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)]">
|
||||
<img
|
||||
src={getImageUrl(img)}
|
||||
alt={`生成图片 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user