feat(chat): scope agent to current organization
This commit is contained in:
@@ -1,16 +1,36 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { setSendRequestId } from "@/root";
|
||||
|
||||
export const useChatAgent = (): ChatAgentState => {
|
||||
const agent = useFlueAgent(CHAT_AGENT);
|
||||
// The global Zopu agent instance id is the user's current personal
|
||||
// organization id. This is the hard tenancy boundary: the server rejects an
|
||||
// instance id that is not the caller's own organization.
|
||||
const organization = useQuery(api.organizations.getCurrent);
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization?._id,
|
||||
});
|
||||
|
||||
const sendMessage = async (message: string): Promise<void> => {
|
||||
// Generate a stable idempotency request id once per send, immediately
|
||||
// before the message is submitted. It is reused across fetch/stream
|
||||
// retries for this send and reset only on the next send, so the server
|
||||
// can capture normalized evidence keyed by (organization, request id)
|
||||
// without creating duplicates.
|
||||
setSendRequestId(crypto.randomUUID());
|
||||
await agent.sendMessage(message);
|
||||
};
|
||||
|
||||
return {
|
||||
error: agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
sendMessage: agent.sendMessage,
|
||||
sendMessage,
|
||||
status: agent.status,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AgentStatus } from "@flue/react";
|
||||
|
||||
/**
|
||||
* The global Zopu agent. The instance `id` is the current organization id
|
||||
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
||||
* instance. Organization scoping is the hard tenancy boundary.
|
||||
*/
|
||||
export const CHAT_AGENT = {
|
||||
id: "main",
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WebAuthProvider } from "@code/auth/web";
|
||||
import { authClient, WebAuthProvider } from "@code/auth/web";
|
||||
import { env } from "@code/env/web";
|
||||
import { Toaster } from "@code/ui/components/sonner";
|
||||
import { FlueProvider } from "@flue/react";
|
||||
@@ -31,6 +31,49 @@ export const links: Route.LinksFunction = () => [
|
||||
];
|
||||
|
||||
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authenticated Flue request headers.
|
||||
//
|
||||
// The access token is the Better Auth/Convex JWT, resolved once per session
|
||||
// through the installed `convex.token` plugin endpoint and cached. The stable
|
||||
// per-send request id is generated by the chat hook immediately before a user
|
||||
// sends a message and is reused across fetch/stream retries for that send, so
|
||||
// it is a reliable idempotency key for server-side evidence capture. It is
|
||||
// reset only when a new message is sent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedAccessToken: string | null = null;
|
||||
let currentSendRequestId: string | null = null;
|
||||
|
||||
/** Set the idempotency request id for the next send. Called by the chat hook. */
|
||||
export const setSendRequestId = (id: string): void => {
|
||||
currentSendRequestId = id;
|
||||
};
|
||||
|
||||
const resolveAccessToken = async (): Promise<string | null> => {
|
||||
if (cachedAccessToken) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
const { data } = await authClient.convex.token({
|
||||
fetchOptions: { throw: false },
|
||||
});
|
||||
cachedAccessToken = data?.token ?? null;
|
||||
return cachedAccessToken;
|
||||
};
|
||||
|
||||
const resolveFlueHeaders = async (): Promise<Record<string, string>> => {
|
||||
const headers: Record<string, string> = {};
|
||||
const accessToken = await resolveAccessToken();
|
||||
if (accessToken) {
|
||||
headers.authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (currentSendRequestId) {
|
||||
headers["x-zopu-request-id"] = currentSendRequestId;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const flueFetch: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
if (
|
||||
@@ -67,6 +110,7 @@ const flueFetch: typeof fetch = async (input, init) => {
|
||||
export const flueClient = createFlueClient({
|
||||
baseUrl: flueBaseUrl.toString(),
|
||||
fetch: flueFetch,
|
||||
headers: resolveFlueHeaders,
|
||||
});
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
|
||||
Reference in New Issue
Block a user