Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20)
This commit was merged in pull request #20.
This commit is contained in:
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useChatAgent } from "./use-chat-agent";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agent: {
|
||||
error: undefined,
|
||||
historyReady: true,
|
||||
messages: [],
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
status: "idle" as const,
|
||||
},
|
||||
organization: {} as { error?: Error; organizationId?: string },
|
||||
useFlueAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@flue/react", () => ({
|
||||
useFlueAgent: mocks.useFlueAgent,
|
||||
}));
|
||||
vi.mock("@/hooks/use-personal-organization", () => ({
|
||||
usePersonalOrganization: () => mocks.organization,
|
||||
}));
|
||||
|
||||
describe("useChatAgent", () => {
|
||||
beforeEach(() => {
|
||||
mocks.organization = {};
|
||||
mocks.agent.sendMessage.mockClear();
|
||||
mocks.useFlueAgent.mockReset();
|
||||
mocks.useFlueAgent.mockReturnValue(mocks.agent);
|
||||
});
|
||||
|
||||
test("does not use the agent before organization bootstrap completes", async () => {
|
||||
const chat = useChatAgent();
|
||||
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
expect(chat.status).toBe("connecting");
|
||||
await expect(chat.sendMessage("too early")).rejects.toThrow(
|
||||
"Personal organization is still being prepared"
|
||||
);
|
||||
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses the ensured organization id for the agent session", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
|
||||
const chat = useChatAgent();
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: "org-a",
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
|
||||
await chat.sendMessage("ready");
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready", undefined);
|
||||
});
|
||||
|
||||
test("forwards image attachments through to the Flue agent", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
const images = [
|
||||
{
|
||||
data: "abc",
|
||||
filename: "a.png",
|
||||
mimeType: "image/png",
|
||||
type: "image" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const chat = useChatAgent();
|
||||
await chat.sendMessage("describe this", { images });
|
||||
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("describe this", {
|
||||
images,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,106 @@
|
||||
import type { SendMessageOptions } from "@flue/react";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { projectConversation } from "@/lib/chat/conversation";
|
||||
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
|
||||
|
||||
const requestId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const uploadImage = async (
|
||||
file: File,
|
||||
generateUploadUrl: () => Promise<string>
|
||||
) => {
|
||||
const response = await fetch(await generateUploadUrl(), {
|
||||
body: file,
|
||||
headers: { "content-type": file.type },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image upload failed (${response.status})`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("storageId" in payload) ||
|
||||
typeof payload.storageId !== "string"
|
||||
) {
|
||||
throw new Error("Image upload returned an invalid response");
|
||||
}
|
||||
return {
|
||||
filename: file.name || undefined,
|
||||
mimeType: file.type,
|
||||
storageId: payload.storageId as Id<"_storage">,
|
||||
};
|
||||
};
|
||||
|
||||
export const useOrganizationChatAgent = (
|
||||
organization: PersonalOrganizationState
|
||||
): ChatAgentState => {
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization.organizationId,
|
||||
});
|
||||
const { organizationId } = organization;
|
||||
const rows = useQuery(
|
||||
api.conversationMessages.listForCurrentOrganization,
|
||||
organizationId ? { organizationId } : "skip"
|
||||
);
|
||||
const send = useMutation(api.conversationMessages.send);
|
||||
const generateUploadUrl = useMutation(
|
||||
api.conversationMessages.generateUploadUrl
|
||||
);
|
||||
const [sendError, setSendError] = useState<Error>();
|
||||
|
||||
const projected = projectConversation(rows ?? []);
|
||||
|
||||
let status: AgentStatus = projected.pending ? "submitted" : "idle";
|
||||
if (!organizationId || rows === undefined) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
} else if (projected.failedError || sendError) {
|
||||
status = "error";
|
||||
}
|
||||
|
||||
const sendMessage = async (
|
||||
message: string,
|
||||
options?: SendMessageOptions
|
||||
options?: { readonly images?: readonly File[] }
|
||||
): Promise<void> => {
|
||||
if (!organization.organizationId) {
|
||||
if (!organizationId) {
|
||||
throw (
|
||||
organization.error ??
|
||||
new Error("Personal organization is still being prepared")
|
||||
);
|
||||
}
|
||||
await agent.sendMessage(message, options);
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
(options?.images ?? []).map((file) =>
|
||||
uploadImage(file, generateUploadUrl)
|
||||
)
|
||||
);
|
||||
await send({
|
||||
clientRequestId: requestId(),
|
||||
images,
|
||||
organizationId,
|
||||
rawText: message,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
setSendError(normalized);
|
||||
throw normalized;
|
||||
}
|
||||
};
|
||||
|
||||
let { status } = agent;
|
||||
if (!organization.organizationId) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
}
|
||||
|
||||
return {
|
||||
error: organization.error ?? agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
error:
|
||||
organization.error ??
|
||||
sendError ??
|
||||
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||
historyReady: rows !== undefined,
|
||||
messages: projected.messages,
|
||||
sendMessage,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatAgent = (): ChatAgentState =>
|
||||
useOrganizationChatAgent(usePersonalOrganization());
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
|
||||
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||
import { generateBrowserRequestId } from "@/lib/flue-transport";
|
||||
|
||||
const generateImageId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
export const useChatImages = () => {
|
||||
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||
@@ -40,7 +43,7 @@ export const useChatImages = () => {
|
||||
previewUrls.current.add(previewUrl);
|
||||
next.push({
|
||||
file,
|
||||
id: generateBrowserRequestId(),
|
||||
id: generateImageId(),
|
||||
previewUrl,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user