Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 7 additions & 17 deletions components/chat/chat-history-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export function ChatHistorySidebar({
</div>
</div>

{filteredConversations.length > 0 && (
{selectedCount > 0 && filteredConversations.length > 0 && (
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border/40 px-2 py-1.5">
<button
type="button"
Expand Down Expand Up @@ -372,24 +372,12 @@ export function ChatHistorySidebar({
tabIndex={0}
onClick={() => {
if (renamingId === conv.id) return;
if (selectedCount > 0) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(conv.id)) {
next.delete(conv.id);
} else {
next.add(conv.id);
}
return next;
});
return;
}
onSelectConversation(conv.id);
if (isMobile) onMobileSheetOpenChange(false);
}}
onKeyDown={(e) => {
if (renamingId === conv.id) return;
if (e.key === "Enter" || e.key === " ") {
if (e.key === "Enter") {
e.preventDefault();
onSelectConversation(conv.id);
if (isMobile) onMobileSheetOpenChange(false);
Expand All @@ -406,9 +394,11 @@ export function ChatHistorySidebar({
aria-label={selected ? "Desmarcar conversa" : "Marcar conversa"}
onClick={(e) => toggleConversationSelected(e, conv.id)}
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100",
selected && "border-primary bg-primary text-primary-foreground opacity-100",
selectedCount > 0 && "opacity-100",
"shrink-0 items-center justify-center rounded border transition-all",
selected || selectedCount > 0
? "flex size-4 border-border bg-background"
: "hidden h-0 w-0 border-0 md:group-hover:flex md:group-hover:size-4 md:group-hover:border-border md:group-hover:bg-background",
selected && "border-primary bg-primary text-primary-foreground",
)}
>
{selected ? <CheckIcon className="size-3" /> : null}
Expand Down
9 changes: 6 additions & 3 deletions components/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ export function ChatPage() {

try {
let fullText = "";
let effectiveModelLabel = assistantModelLabel;
if (browserProviderAdapter) {
if (browserProviderAuthState !== "signed-in") {
setBrowserProviderAuthState("loading");
Expand Down Expand Up @@ -883,6 +884,8 @@ export function ChatPage() {
const { resolvedLabel: resolvedAssistantLabel, fallbackMeta: modelFallbackMeta } =
resolveModelFallbackFromHeaders(response, assistantModelLabel, models, selectedProvider.label);

effectiveModelLabel = resolvedAssistantLabel ?? assistantModelLabel;

setMessages((current) =>
current.map((message) => {
if (message.id !== assistantMessageId) {
Expand Down Expand Up @@ -974,7 +977,7 @@ export function ChatPage() {
}
const persisted = await persistMessagesForConversation(convId, [
{ parts: messageParts, role: "user" },
{ content: fullText, parts: [{ text: fullText, type: "text" }], role: "assistant" },
{ content: fullText, modelLabel: effectiveModelLabel, parts: [{ text: fullText, type: "text" }], role: "assistant" },
]);
const [persistedUserMessage, persistedAssistantMessage] = persisted.messages;
if (persistedUserMessage && persistedAssistantMessage) {
Expand Down Expand Up @@ -1588,7 +1591,7 @@ export function ChatPage() {
) : null}
<div
className={cn(
"rounded-2xl px-3.5 py-2.5 text-sm",
"min-w-0 max-w-full overflow-hidden rounded-2xl px-3.5 py-2.5 text-sm",
message.role === "user"
? "rounded-tr-md bg-primary text-primary-foreground"
: "rounded-tl-md bg-muted",
Expand Down Expand Up @@ -1621,7 +1624,7 @@ export function ChatPage() {
</div>
) : message.role === "assistant" ? (
message.content ? (
<div className="prose-sm">
<div className="min-w-0 max-w-full overflow-hidden prose-sm">
<MarkdownRenderer content={message.content} />
{/* Blinking cursor during streaming */}
{pending && messageIndex === messages.length - 1 && !message.isError && (
Expand Down
9 changes: 8 additions & 1 deletion components/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
const normalised = normaliseMarkdown(content);

return (
<div className="markdown-renderer">
<div className="markdown-renderer min-w-0 max-w-full overflow-hidden">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
Expand All @@ -85,6 +85,13 @@ export function MarkdownRenderer({ content }: { content: string }) {
{children}
</code>
),
table: ({ children, ...props }) => (
<div className="my-4 max-w-full overflow-x-auto rounded-lg border border-border">
<table className="min-w-max" {...props}>
{children}
</table>
</div>
),
}}
>
{normalised}
Expand Down
12 changes: 10 additions & 2 deletions lib/chat-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ type TextPart = {
type: "text";
};

export type ConversationMessagePart = AttachmentReferencePart | TextPart;
type MetaPart = {
modelLabel: string;
type: "meta";
};

export type ConversationMessagePart = AttachmentReferencePart | MetaPart | TextPart;

export type ConversationAttachmentDescriptor = {
byteSize: number;
Expand All @@ -41,7 +46,7 @@ export type ConversationAttachmentDescriptor = {

export type HydratedAttachmentPart = AttachmentReferencePart & ConversationAttachmentDescriptor;

export type HydratedConversationMessagePart = HydratedAttachmentPart | TextPart;
export type HydratedConversationMessagePart = HydratedAttachmentPart | MetaPart | TextPart;

export function createMessageContentFallback(
parts: readonly ConversationMessagePart[],
Expand All @@ -51,6 +56,9 @@ export function createMessageContentFallback(
if (part.type === "text") {
return part.text;
}
if (part.type === "meta") {
return "";
}

return `[${part.kind}] ${part.fileName}`;
})
Expand Down
18 changes: 17 additions & 1 deletion lib/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,18 +240,33 @@ export function formatMessageTimestamp(createdAt: string): string {
return `${day} ${time}`;
}

function extractMetaModelLabel(parts: HydratedConversationMessagePart[] | undefined): string | undefined {
if (!parts) return undefined
for (const part of parts) {
if (typeof part === 'object' && part !== null && 'type' in part && (part as Record<string, unknown>).type === 'meta') {
const label = (part as Record<string, unknown>).modelLabel
if (typeof label === 'string' && label.trim()) return label
}
}
return undefined
}

export function hydrateChatMessage(input: {
message: PersistedConversationMessage;
assistantModelLabel?: string;
}): ChatMessage {
const metaLabel = input.message.role === 'assistant'
? extractMetaModelLabel(input.message.parts)
: undefined

return {
content:
input.message.role === "assistant"
? input.message.content
: getUserMessageText({ content: input.message.content, parts: input.message.parts }),
createdAt: input.message.createdAt,
id: input.message.id,
modelLabel: input.message.role === "assistant" ? input.assistantModelLabel : undefined,
modelLabel: input.message.role === "assistant" ? (metaLabel ?? input.assistantModelLabel) : undefined,
parts: input.message.role === "user" ? input.message.parts : undefined,
role: input.message.role,
toolCalls: [],
Expand Down Expand Up @@ -283,6 +298,7 @@ export function buildAttachmentLabel(attachment: { extractionStatus: AttachmentE

export async function persistMessagesForConversation(conversationId: string, outgoingMessages: Array<{
content?: string;
modelLabel?: string;
parts?: ConversationMessagePart[];
role: "assistant" | "user";
}>) {
Expand Down
12 changes: 12 additions & 0 deletions server/lib/conversation-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,13 @@ export function parseSingleMessagePart(rawPart: Record<string, unknown>): Conver
};
}

if (
rawPart.type === "meta" &&
typeof rawPart.modelLabel === "string"
) {
return { modelLabel: rawPart.modelLabel, type: "meta" };
}

return null;
}

Expand Down Expand Up @@ -377,6 +384,11 @@ export function hydrateMessageParts(input: {
return result;
}

if (part.type === "meta") {
result.push(part);
return result;
}

const attachment = input.attachmentsById.get(part.attachmentId);
if (!attachment) {
return result;
Expand Down
3 changes: 3 additions & 0 deletions server/providers/cloudflareworkersai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ const app = createProviderApp({
}
| null
const output = json?.result?.response || json?.result?.output_text || json?.result?.text || ''
if (!String(output).trim()) {
return upstreamErrorResponse('Cloudflare Workers AI', 502, 'Empty model response')
}

return toVercelSingleTextResponse(String(output))
} catch (error) {
Expand Down
100 changes: 50 additions & 50 deletions server/routes/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ app.use("*", async (c, next) => {

type CreateMessageInput = {
content?: string;
modelLabel?: string;
parts?: ConversationMessagePart[];
role: string;
};
Expand Down Expand Up @@ -138,57 +139,58 @@ async function authorizeConversation(c: Context): Promise<AuthorizedConversation
}

async function persistMessages(conversationId: string, messages: CreateMessageInput[]) {
const createdMessages = await prisma.$transaction(async (tx) => {
const output: Array<{
content: string;
createdAt: Date;
id: string;
parts: Prisma.JsonValue | null;
role: string;
}> = [];

for (const message of messages) {
const parts = normalizeIncomingMessageParts(message.parts);
const fallbackContent = parts.length > 0
? createMessageContentFallback(parts)
: (message.content ?? "").trim();

const created = await tx.message.create({
data: {
content: fallbackContent,
const createdMessages: Array<{
content: string;
createdAt: Date;
id: string;
parts: Prisma.JsonValue | null;
role: string;
}> = [];

for (const message of messages) {
const parts = normalizeIncomingMessageParts(message.parts);
const fallbackContent = parts.length > 0
? createMessageContentFallback(parts)
: (message.content ?? "").trim();

const partsWithMeta = message.modelLabel
? [...parts, { modelLabel: message.modelLabel, type: "meta" } as ConversationMessagePart]
: parts;

const created = await prisma.message.create({
data: {
content: fallbackContent,
conversationId,
role: message.role,
...(partsWithMeta.length > 0 ? { parts: partsWithMeta as unknown as Prisma.InputJsonValue } : {}),
},
select: {
content: true,
createdAt: true,
id: true,
parts: true,
role: true,
},
});

const attachmentIds = parts
.filter((part) => part.type === "attachment")
.map((part) => part.attachmentId);

if (attachmentIds.length > 0) {
await prisma.conversationAttachment.updateMany({
data: { messageId: created.id },
where: {
conversationId,
role: message.role,
...(parts.length > 0 ? { parts: parts as unknown as Prisma.InputJsonValue } : {}),
},
select: {
content: true,
createdAt: true,
id: true,
parts: true,
role: true,
id: { in: attachmentIds },
},
});

const attachmentIds = parts
.filter((part) => part.type === "attachment")
.map((part) => part.attachmentId);

if (attachmentIds.length > 0) {
await tx.conversationAttachment.updateMany({
data: { messageId: created.id },
where: {
conversationId,
id: { in: attachmentIds },
},
});
}

output.push(created);
}

await tx.conversation.update({ where: { id: conversationId }, data: {} });
return output;
});
createdMessages.push(created);
}

await prisma.conversation.update({ where: { id: conversationId }, data: {} });

const attachments = await prisma.conversationAttachment.findMany({
where: { conversationId },
Expand Down Expand Up @@ -462,10 +464,8 @@ app.delete("/:id/messages", async (c) => {
return c.json({ deletedMessageIds: [] });
}

await prisma.$transaction([
prisma.message.deleteMany({ where: { conversationId, id: { in: idsToDelete } } }),
prisma.conversation.update({ where: { id: conversationId }, data: {} }),
]);
await prisma.message.deleteMany({ where: { conversationId, id: { in: idsToDelete } } });
await prisma.conversation.update({ where: { id: conversationId }, data: {} });

return c.json({ deletedMessageIds: idsToDelete });
});
Expand Down
Loading
Loading