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:
gongzhiyong
2026-04-08 18:31:12 +08:00
co-authored by Claude Sonnet 4.6
parent 712b96e26e
commit 915aaef30b
4 changed files with 209 additions and 12 deletions
+3 -1
View File
@@ -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;
+132 -8
View File
@@ -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(
+40 -3
View File
@@ -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(
+34
View File
@@ -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 {