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 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";
|
import { notFound } from "../errors.js";
|
||||||
|
|
||||||
export function chatService(db: Db) {
|
export function chatService(db: Db) {
|
||||||
|
|
@ -132,7 +132,33 @@ export function chatService(db: Db) {
|
||||||
const items = hasMore ? rows.slice(0, limit) : rows;
|
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||||
const nextCursor = hasMore ? items[items.length - 1]!.createdAt.toISOString() : null;
|
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(
|
async addMessage(
|
||||||
|
|
@ -164,7 +190,7 @@ export function chatService(db: Db) {
|
||||||
.where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title)));
|
.where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return message!;
|
return { ...message!, files: [] };
|
||||||
},
|
},
|
||||||
|
|
||||||
async editMessage(messageId: string, content: string) {
|
async editMessage(messageId: string, content: string) {
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,14 @@ export const chatApi = {
|
||||||
return `/api/conversations/${conversationId}/export?format=${format}`;
|
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(
|
async uploadFile(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import { ChatSpecCard } from "./ChatSpecCard";
|
||||||
import { ChatHandoffIndicator } from "./ChatHandoffIndicator";
|
import { ChatHandoffIndicator } from "./ChatHandoffIndicator";
|
||||||
import { ChatTaskCreatedBadge } from "./ChatTaskCreatedBadge";
|
import { ChatTaskCreatedBadge } from "./ChatTaskCreatedBadge";
|
||||||
import { ChatStatusUpdateBadge } from "./ChatStatusUpdateBadge";
|
import { ChatStatusUpdateBadge } from "./ChatStatusUpdateBadge";
|
||||||
|
import { ChatFilePreview } from "./ChatFilePreview";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import type { AgentRole } from "@paperclipai/shared";
|
import type { AgentRole, ChatFile } from "@paperclipai/shared";
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -28,6 +29,7 @@ interface ChatMessageProps {
|
||||||
onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void;
|
onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void;
|
||||||
onBookmark?: (messageId: string) => void;
|
onBookmark?: (messageId: string) => void;
|
||||||
isBookmarked?: boolean;
|
isBookmarked?: boolean;
|
||||||
|
files?: ChatFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({
|
export function ChatMessage({
|
||||||
|
|
@ -47,6 +49,7 @@ export function ChatMessage({
|
||||||
onHandoff,
|
onHandoff,
|
||||||
onBookmark,
|
onBookmark,
|
||||||
isBookmarked,
|
isBookmarked,
|
||||||
|
files,
|
||||||
}: ChatMessageProps) {
|
}: ChatMessageProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(content);
|
const [editValue, setEditValue] = useState(content);
|
||||||
|
|
@ -135,6 +138,17 @@ export function ChatMessage({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
|
)}
|
||||||
<ChatMessageActions
|
<ChatMessageActions
|
||||||
role="user"
|
role="user"
|
||||||
isStreaming={isAnyStreaming}
|
isStreaming={isAnyStreaming}
|
||||||
|
|
@ -160,6 +174,17 @@ export function ChatMessage({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ChatMarkdownMessage content={content} />
|
<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 />}
|
{isStreaming && <ChatStreamingCursor />}
|
||||||
<ChatMessageActions
|
<ChatMessageActions
|
||||||
role="assistant"
|
role="assistant"
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export function ChatMessageList({
|
||||||
onHandoff={onHandoff}
|
onHandoff={onHandoff}
|
||||||
onBookmark={onBookmark}
|
onBookmark={onBookmark}
|
||||||
isBookmarked={msg.id ? (bookmarkedMessageIds?.has(msg.id) ?? false) : false}
|
isBookmarked={msg.id ? (bookmarkedMessageIds?.has(msg.id) ?? false) : false}
|
||||||
|
files={msg.files}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useChatMessages } from "../hooks/useChatMessages";
|
||||||
import { useStreamingChat } from "../hooks/useStreamingChat";
|
import { useStreamingChat } from "../hooks/useStreamingChat";
|
||||||
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
|
import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault";
|
||||||
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks";
|
||||||
|
import { useChatFileUpload } from "../hooks/useChatFileUpload";
|
||||||
import { resolveAgentFromContent } from "../lib/slash-commands";
|
import { resolveAgentFromContent } from "../lib/slash-commands";
|
||||||
import type { AgentRole } from "@paperclipai/shared";
|
import type { AgentRole } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ export function ChatPanel() {
|
||||||
|
|
||||||
const { messages } = useChatMessages(activeConversationId);
|
const { messages } = useChatMessages(activeConversationId);
|
||||||
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId);
|
||||||
|
const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId);
|
||||||
|
|
||||||
const brainstormerDefaultId = useBrainstormerDefault();
|
const brainstormerDefaultId = useBrainstormerDefault();
|
||||||
|
|
||||||
|
|
@ -132,6 +134,9 @@ export function ChatPanel() {
|
||||||
// Resolve agent from slash command or @mention
|
// Resolve agent from slash command or @mention
|
||||||
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
|
const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId);
|
||||||
|
|
||||||
|
// Capture file IDs before clearing
|
||||||
|
const fileIdsToAttach = [...completedFileIds];
|
||||||
|
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
if (!activeConversationId) {
|
if (!activeConversationId) {
|
||||||
|
|
@ -140,13 +145,24 @@ export function ChatPanel() {
|
||||||
agentId: resolvedAgentId ?? undefined,
|
agentId: resolvedAgentId ?? undefined,
|
||||||
});
|
});
|
||||||
setActiveConversationId(newConvo.id);
|
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"] });
|
queryClient.invalidateQueries({ queryKey: ["chat"] });
|
||||||
// Note: streaming starts on next render when activeConversationId is set
|
// Note: streaming starts on next render when activeConversationId is set
|
||||||
// For now, the echo stream will be triggered by the new conversation
|
// For now, the echo stream will be triggered by the new conversation
|
||||||
} else {
|
} else {
|
||||||
// Path 2: Active conversation -- post user message then stream
|
// 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] });
|
queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] });
|
||||||
startStream(content, resolvedAgentId ?? undefined);
|
startStream(content, resolvedAgentId ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +385,9 @@ export function ChatPanel() {
|
||||||
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
agentsLoading={agentsLoading}
|
agentsLoading={agentsLoading}
|
||||||
|
pendingFiles={pendingFiles}
|
||||||
|
onRemoveFile={removeFile}
|
||||||
|
onFilesPicked={(files) => files.forEach(addFile)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue