feat: add file upload UI and attachment display in messages
- lib/api.ts: add AttachmentData interface, uploadAttachment() function, and getAttachmentDownloadUrl() helper for upcoming /api/attachments endpoint - GeminiInput.tsx: wire + button to trigger file selection with upload state management (uploading/done/error), file preview chips above textarea with retry and remove controls, pass AttachmentData to parent on submit - GeminiMessage.tsx: add attachments field to Message interface, render attachment list (filename + size + download link) below both user and assistant message content - GeminiChat.tsx: accept AttachmentData[] in handleSend, store attachments on user messages for display Backend /api/attachments endpoints not yet live — frontend is ready for integration once backend agent completes the upload/download API. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
712b96e26e
commit
915aaef30b
@@ -16,6 +16,7 @@ import {
|
||||
fetchTicketSummary,
|
||||
streamChat,
|
||||
type TicketSummaryData,
|
||||
type AttachmentData,
|
||||
} from "@/lib/api";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -162,7 +163,7 @@ export function GeminiChat() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const handleSend = useCallback(async (attachments?: AttachmentData[]) => {
|
||||
const text = inputValue.trim();
|
||||
if (!text || isLoading) return;
|
||||
setInputValue("");
|
||||
@@ -173,6 +174,7 @@ export function GeminiChat() {
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
};
|
||||
|
||||
let convId = activeConvId;
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
Database,
|
||||
FileText,
|
||||
Code2,
|
||||
X,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { uploadAttachment, type AttachmentData } from "@/lib/api";
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
@@ -19,10 +24,18 @@ interface Tool {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
id: string;
|
||||
file: File;
|
||||
status: "uploading" | "done" | "error";
|
||||
attachment?: AttachmentData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GeminiInputProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSubmit: (attachments?: AttachmentData[]) => void;
|
||||
isLoading?: boolean;
|
||||
activeTools: Set<string>;
|
||||
onActiveToolsChange: (tools: Set<string>) => void;
|
||||
@@ -50,6 +63,7 @@ export function GeminiInput({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
@@ -62,7 +76,7 @@ export function GeminiInput({
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (value.trim() && !isLoading) onSubmit();
|
||||
if (canSend) handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,14 +90,72 @@ export function GeminiInput({
|
||||
onActiveToolsChange(next);
|
||||
};
|
||||
|
||||
const canSend = value.trim().length > 0 && !isLoading;
|
||||
const hasUploading = uploadingFiles.some((f) => f.status === "uploading");
|
||||
const canSend = value.trim().length > 0 && !isLoading && !hasUploading;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const attachments = uploadingFiles
|
||||
.filter((f) => f.status === "done" && f.attachment)
|
||||
.map((f) => f.attachment!);
|
||||
onSubmit(attachments.length > 0 ? attachments : undefined);
|
||||
setUploadingFiles([]);
|
||||
};
|
||||
|
||||
const doUpload = async (entry: UploadingFile) => {
|
||||
try {
|
||||
const attachment = await uploadAttachment(entry.file);
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === entry.id ? { ...f, status: "done", attachment } : f
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === entry.id
|
||||
? { ...f, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
|
||||
: f
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
// Handle file upload
|
||||
console.log("上传文件:", files[0].name);
|
||||
}
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newEntries: UploadingFile[] = Array.from(files).map((file) => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
status: "uploading" as const,
|
||||
}));
|
||||
|
||||
setUploadingFiles((prev) => [...prev, ...newEntries]);
|
||||
|
||||
// Start uploads
|
||||
newEntries.forEach((entry) => doUpload(entry));
|
||||
|
||||
// Reset file input so the same file can be selected again
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleRetryUpload = (entry: UploadingFile) => {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === entry.id ? { ...f, status: "uploading", error: undefined } : f
|
||||
)
|
||||
);
|
||||
doUpload({ ...entry, status: "uploading" });
|
||||
};
|
||||
|
||||
const handleRemoveFile = (id: string) => {
|
||||
setUploadingFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -91,6 +163,58 @@ export function GeminiInput({
|
||||
<div className="max-w-3xl mx-auto px-4 pointer-events-auto">
|
||||
{/* Input container */}
|
||||
<div className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-3xl shadow-lg focus-within:border-[var(--gem-border-hover)] transition-colors duration-150">
|
||||
{/* Uploaded files preview */}
|
||||
{uploadingFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pt-3 pb-1">
|
||||
{uploadingFiles.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs border max-w-[220px]",
|
||||
entry.status === "error"
|
||||
? "bg-red-500/10 border-red-500/30 text-red-400"
|
||||
: "bg-[var(--gem-surface-2)] border-[var(--gem-border)] text-[var(--gem-text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{entry.status === "uploading" && (
|
||||
<Loader2 size={14} className="animate-spin flex-shrink-0 text-[var(--gem-blue)]" />
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<Paperclip size={14} className="flex-shrink-0 text-[var(--gem-text-muted)]" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<Paperclip size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{entry.file.name}
|
||||
{entry.status === "done" && entry.attachment && (
|
||||
<span className="text-[var(--gem-text-muted)] ml-1">
|
||||
{formatSize(entry.attachment.size_bytes)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{entry.status === "error" && (
|
||||
<button
|
||||
onClick={() => handleRetryUpload(entry)}
|
||||
className="flex-shrink-0 hover:opacity-80 cursor-pointer"
|
||||
aria-label="重试上传"
|
||||
title="重试"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRemoveFile(entry.id)}
|
||||
className="flex-shrink-0 hover:opacity-80 cursor-pointer text-[var(--gem-text-muted)]"
|
||||
aria-label="移除文件"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main input row */}
|
||||
<div className="flex items-end gap-2 px-4 py-3">
|
||||
{/* Left: File upload button */}
|
||||
@@ -152,7 +276,7 @@ export function GeminiInput({
|
||||
<Mic size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => canSend && onSubmit()}
|
||||
onClick={() => canSend && handleSubmit()}
|
||||
disabled={!canSend}
|
||||
aria-label="发送消息"
|
||||
className={cn(
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check } from "lucide-react";
|
||||
import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check, Paperclip, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAttachmentDownloadUrl, type AttachmentData } from "@/lib/api";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
attachments?: AttachmentData[];
|
||||
}
|
||||
|
||||
interface GeminiMessageProps {
|
||||
@@ -121,6 +123,33 @@ function inlineFormat(text: string): string {
|
||||
.replace(/`(.+?)`/g, '<code style="background:var(--gem-surface-2);color:#a8c4f5;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-family:monospace">$1</code>');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments }: { attachments: AttachmentData[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={getAttachmentDownloadUrl(att.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--gem-surface-2)] border border-[var(--gem-border)] hover:border-[var(--gem-border-hover)] transition-colors duration-150 text-xs max-w-[240px]"
|
||||
>
|
||||
<Paperclip size={14} className="text-[var(--gem-text-muted)] flex-shrink-0" />
|
||||
<span className="truncate text-[var(--gem-text-secondary)]">{att.filename}</span>
|
||||
<span className="text-[var(--gem-text-muted)] flex-shrink-0">{formatFileSize(att.size_bytes)}</span>
|
||||
<Download size={12} className="text-[var(--gem-text-muted)] flex-shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GemIcon = ({ size = 18 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0 mt-0.5">
|
||||
<defs>
|
||||
@@ -149,8 +178,11 @@ export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end mb-4" role="article" aria-label="Your message">
|
||||
<div className="max-w-[70%] bg-[var(--gem-surface-2)] rounded-2xl px-5 py-3.5 text-[var(--gem-text)] text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{message.content}
|
||||
<div className="max-w-[70%] bg-[var(--gem-surface-2)] rounded-2xl px-5 py-3.5 text-[var(--gem-text)] text-sm leading-relaxed">
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentList attachments={message.attachments} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -165,6 +197,11 @@ export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="space-y-0.5">{renderContent(message.content)}</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentList attachments={message.attachments} />
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -75,6 +75,40 @@ export async function fetchTicketSummary(): Promise<TicketSummaryData> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Attachments ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AttachmentData {
|
||||
id: string;
|
||||
blob_url: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export async function uploadAttachment(
|
||||
file: File,
|
||||
conversationId?: string,
|
||||
): Promise<AttachmentData> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (conversationId) {
|
||||
form.append("conversation_id", conversationId);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}/api/attachments/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "Unknown error");
|
||||
throw new Error(`Upload failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getAttachmentDownloadUrl(attachmentId: string): string {
|
||||
return `${API_URL}/api/attachments/${attachmentId}/download`;
|
||||
}
|
||||
|
||||
// ── SSE Chat Stream ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatStreamEvent {
|
||||
|
||||
Reference in New Issue
Block a user