feat(chat): scope agent to current organization

This commit is contained in:
-Puter
2026-07-23 16:31:31 +05:30
parent 535e9dde62
commit 5d5139dc95
11 changed files with 1084 additions and 11 deletions

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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">