feat(25-03): wire files into ChatMessage, ChatPanel, and server listMessages
- ChatMessage: files prop, renders ChatFilePreview for each attached file - ChatMessageList: passes files prop through to ChatMessage - ChatPanel: wires useChatFileUpload, passes pendingFiles/onRemoveFile/onFilesPicked to ChatInput - ChatPanel handleSend: attaches uploaded files to message after creation, invalidates query - chatApi: adds attachFilesToMessage (parallel PATCH /files/:fileId for each fileId) - server/chat.ts: listMessages fetches chatFiles by messageId (inArray), attaches files array - server/chat.ts: addMessage returns files: [] for consistency
This commit is contained in:
parent
3e13ac88dc
commit
35a7814032
5 changed files with 86 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { and, asc, desc, eq, gt, ilike, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, ilike, inArray, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, chatConversations, chatMessageBookmarks, chatMessages } from "@paperclipai/db";
|
||||
import { agents, chatConversations, chatFiles, chatMessageBookmarks, chatMessages } from "@paperclipai/db";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
export function chatService(db: Db) {
|
||||
|
|
@ -132,7 +132,33 @@ export function chatService(db: Db) {
|
|||
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? items[items.length - 1]!.createdAt.toISOString() : null;
|
||||
|
||||
return { items, hasMore, nextCursor };
|
||||
// Load files for these messages and attach them
|
||||
const messageIds = items.map((m) => m.id);
|
||||
let filesByMessageId = new Map<string, typeof chatFiles.$inferSelect[]>();
|
||||
if (messageIds.length > 0) {
|
||||
const fileRows = await db
|
||||
.select()
|
||||
.from(chatFiles)
|
||||
.where(inArray(chatFiles.messageId, messageIds))
|
||||
.orderBy(asc(chatFiles.createdAt));
|
||||
for (const f of fileRows) {
|
||||
if (!f.messageId) continue;
|
||||
const existing = filesByMessageId.get(f.messageId) ?? [];
|
||||
existing.push(f);
|
||||
filesByMessageId.set(f.messageId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithFiles = items.map((m) => ({
|
||||
...m,
|
||||
files: (filesByMessageId.get(m.id) ?? []).map((f) => ({
|
||||
...f,
|
||||
createdAt: f.createdAt.toISOString(),
|
||||
updatedAt: f.updatedAt.toISOString(),
|
||||
})),
|
||||
}));
|
||||
|
||||
return { items: itemsWithFiles, hasMore, nextCursor };
|
||||
},
|
||||
|
||||
async addMessage(
|
||||
|
|
@ -164,7 +190,7 @@ export function chatService(db: Db) {
|
|||
.where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title)));
|
||||
}
|
||||
|
||||
return message!;
|
||||
return { ...message!, files: [] };
|
||||
},
|
||||
|
||||
async editMessage(messageId: string, content: string) {
|
||||
|
|
|
|||
|
|
@ -213,6 +213,14 @@ export const chatApi = {
|
|||
return `/api/conversations/${conversationId}/export?format=${format}`;
|
||||
},
|
||||
|
||||
async attachFilesToMessage(fileIds: string[], messageId: string) {
|
||||
await Promise.all(
|
||||
fileIds.map((fileId) =>
|
||||
api.patch(`/files/${fileId}`, { messageId })
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
async uploadFile(
|
||||
conversationId: string,
|
||||
file: File,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import { ChatSpecCard } from "./ChatSpecCard";
|
|||
import { ChatHandoffIndicator } from "./ChatHandoffIndicator";
|
||||
import { ChatTaskCreatedBadge } from "./ChatTaskCreatedBadge";
|
||||
import { ChatStatusUpdateBadge } from "./ChatStatusUpdateBadge";
|
||||
import { ChatFilePreview } from "./ChatFilePreview";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { AgentRole } from "@paperclipai/shared";
|
||||
import type { AgentRole, ChatFile } from "@paperclipai/shared";
|
||||
|
||||
interface ChatMessageProps {
|
||||
id?: string;
|
||||
|
|
@ -28,6 +29,7 @@ interface ChatMessageProps {
|
|||
onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void;
|
||||
onBookmark?: (messageId: string) => void;
|
||||
isBookmarked?: boolean;
|
||||
files?: ChatFile[];
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
|
|
@ -47,6 +49,7 @@ export function ChatMessage({
|
|||
onHandoff,
|
||||
onBookmark,
|
||||
isBookmarked,
|
||||
files,
|
||||
}: ChatMessageProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(content);
|
||||
|
|
@ -135,6 +138,17 @@ export function ChatMessage({
|
|||
)}
|
||||
>
|
||||
{content}
|
||||
{files && files.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{files.map((f) => (
|
||||
<ChatFilePreview
|
||||
key={f.id}
|
||||
file={f}
|
||||
contentPath={`/api/files/${f.id}/content`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChatMessageActions
|
||||
role="user"
|
||||
isStreaming={isAnyStreaming}
|
||||
|
|
@ -160,6 +174,17 @@ export function ChatMessage({
|
|||
/>
|
||||
)}
|
||||
<ChatMarkdownMessage content={content} />
|
||||
{files && files.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{files.map((f) => (
|
||||
<ChatFilePreview
|
||||
key={f.id}
|
||||
file={f}
|
||||
contentPath={`/api/files/${f.id}/content`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isStreaming && <ChatStreamingCursor />}
|
||||
<ChatMessageActions
|
||||
role="assistant"
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export function ChatMessageList({
|
|||
onHandoff={onHandoff}
|
||||
onBookmark={onBookmark}
|
||||
isBookmarked={msg.id ? (bookmarkedMessageIds?.has(msg.id) ?? false) : false}
|
||||
files={msg.files}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { useChatMessages } from "../hooks/useChatMessages";
|
|||
import { useStreamingChat } from "../hooks/useStreamingChat";
|
||||
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
|
||||
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
||||
import { useChatFileUpload } from "../hooks/useChatFileUpload";
|
||||
import { resolveAgentFromContent } from "../lib/slash-commands";
|
||||
import type { AgentRole } from "@paperclipai/shared";
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ export function ChatPanel() {
|
|||
|
||||
const { messages } = useChatMessages(activeConversationId);
|
||||
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
||||
const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId);
|
||||
|
||||
const brainstormerDefaultId = useBrainstormerDefault();
|
||||
|
||||
|
|
@ -132,6 +134,9 @@ export function ChatPanel() {
|
|||
// Resolve agent from slash command or @mention
|
||||
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
|
||||
|
||||
// Capture file IDs before clearing
|
||||
const fileIdsToAttach = [...completedFileIds];
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
if (!activeConversationId) {
|
||||
|
|
@ -140,13 +145,24 @@ export function ChatPanel() {
|
|||
agentId: resolvedAgentId ?? undefined,
|
||||
});
|
||||
setActiveConversationId(newConvo.id);
|
||||
await chatApi.postMessage(newConvo.id, { role: "user", content });
|
||||
const message = await chatApi.postMessage(newConvo.id, { role: "user", content });
|
||||
// Attach any uploaded files to the message
|
||||
if (fileIdsToAttach.length > 0) {
|
||||
await chatApi.attachFilesToMessage(fileIdsToAttach, message.id);
|
||||
clearCompleted();
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["chat"] });
|
||||
// Note: streaming starts on next render when activeConversationId is set
|
||||
// For now, the echo stream will be triggered by the new conversation
|
||||
} else {
|
||||
// Path 2: Active conversation -- post user message then stream
|
||||
await chatApi.postMessage(activeConversationId, { role: "user", content });
|
||||
const message = await chatApi.postMessage(activeConversationId, { role: "user", content });
|
||||
// Attach any uploaded files to the message
|
||||
if (fileIdsToAttach.length > 0) {
|
||||
await chatApi.attachFilesToMessage(fileIdsToAttach, message.id);
|
||||
clearCompleted();
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||
startStream(content, resolvedAgentId ?? undefined);
|
||||
}
|
||||
|
|
@ -369,6 +385,9 @@ export function ChatPanel() {
|
|||
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
||||
agents={agents}
|
||||
agentsLoading={agentsLoading}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemoveFile={removeFile}
|
||||
onFilesPicked={(files) => files.forEach(addFile)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue