Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0df6242128 |
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack";
|
||||
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
@@ -26,23 +26,6 @@ function renderStack(props: Props) {
|
||||
return { store, ...utils };
|
||||
}
|
||||
|
||||
describe("agentGlyphBackground", () => {
|
||||
it("is deterministic for a given agent name", () => {
|
||||
expect(agentGlyphBackground("Researcher")).toBe(
|
||||
agentGlyphBackground("Researcher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("differs by name and stays a fixed dark shade (readable emoji)", () => {
|
||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
||||
agentGlyphBackground("Нарратор"),
|
||||
);
|
||||
// Only the hue varies; saturation/lightness are pinned low so the glyph is
|
||||
// always a dark circle.
|
||||
expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, 45%, 24%\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentAvatarStack", () => {
|
||||
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
|
||||
const { container } = renderStack({
|
||||
|
||||
@@ -23,34 +23,14 @@ export interface LauncherInfo {
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
// Same violet token as the former AiAgentBadge (which used color="violet").
|
||||
const AGENT_COLOR = "violet";
|
||||
const GLYPH_SIZE = 38;
|
||||
const LAUNCHER_SIZE = 22;
|
||||
// How far the launcher avatar sticks out past the agent's top-right corner, so
|
||||
// How far the launcher avatar sticks out past the agent's bottom-right corner, so
|
||||
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
|
||||
const LAUNCHER_OVERHANG = 8;
|
||||
|
||||
// Small deterministic string hash (same algorithm as custom-avatar's initials
|
||||
// hash) used to pick a stable per-agent glyph color.
|
||||
function hashName(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic DARK background for an emoji/sparkles agent glyph. The hue is
|
||||
* derived from the agent-name hash so distinct agents get distinct circles;
|
||||
* saturation and lightness are pinned low ("shifted into darkness") so a bright
|
||||
* emoji or the white sparkles icon stays legible on top (#300).
|
||||
*/
|
||||
export function agentGlyphBackground(name: string): string {
|
||||
const hue = hashName(name) % 360;
|
||||
return `hsl(${hue}, 45%, 24%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The front avatar. Image-source priority (#300):
|
||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||
@@ -68,18 +48,9 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji/sparkles glyphs sit on a per-agent dark circle (hashed from the agent
|
||||
// name) so different agents are visually distinct, while the dark background
|
||||
// keeps the emoji / white sparkles icon readable.
|
||||
const bg = agentGlyphBackground(agent.name);
|
||||
const glyphStyles = {
|
||||
root: { background: bg },
|
||||
placeholder: { background: bg, color: "var(--mantine-color-white)" },
|
||||
};
|
||||
|
||||
if (agent.emoji) {
|
||||
return (
|
||||
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
|
||||
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
|
||||
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
||||
{agent.emoji}
|
||||
</span>
|
||||
@@ -88,7 +59,7 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
|
||||
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
|
||||
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
||||
</Avatar>
|
||||
);
|
||||
@@ -185,7 +156,7 @@ export function AgentAvatarStack({
|
||||
: {})}
|
||||
>
|
||||
{launcher && (
|
||||
<Box pos="absolute" top={0} right={0} style={{ zIndex: 0 }}>
|
||||
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
|
||||
<CustomAvatar
|
||||
size={LAUNCHER_SIZE}
|
||||
avatarUrl={launcher.avatarUrl}
|
||||
@@ -194,8 +165,8 @@ export function AgentAvatarStack({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* The agent glyph keeps its own size (flex-centered in the container); the
|
||||
launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}
|
||||
{/* Pin the agent glyph to the top-left at its own size; the launcher then
|
||||
overhangs it by LAUNCHER_OVERHANG at the bottom-right and stays visible. */}
|
||||
<Box
|
||||
style={{
|
||||
position: "relative",
|
||||
|
||||
@@ -27,7 +27,9 @@ export function useOpenAiChatForCurrentPage() {
|
||||
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
|
||||
// see :pageSlug — match the full path against the authenticated page route.
|
||||
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageId = extractPageSlugId(match?.params?.pageSlug);
|
||||
// A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
|
||||
// real page uuid (PageRepo.findById accepts slugId or uuid).
|
||||
const slugId = extractPageSlugId(match?.params?.pageSlug);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Re-clicks while the window is already open (incl. minimized) must NOT
|
||||
@@ -40,9 +42,9 @@ export function useOpenAiChatForCurrentPage() {
|
||||
// connection the first click reads as a hung control until the POST returns.
|
||||
setWindowOpen(true);
|
||||
let resolved: string | null = activeChatId; // off-a-page: keep current
|
||||
if (pageId) {
|
||||
if (slugId) {
|
||||
try {
|
||||
resolved = await getBoundChat(pageId); // null => fresh chat
|
||||
resolved = await getBoundChat(slugId); // null => fresh chat
|
||||
} catch {
|
||||
resolved = null; // fail-soft: a fresh chat is always a safe fallback
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export function useOpenAiChatForCurrentPage() {
|
||||
}, [
|
||||
windowOpen,
|
||||
activeChatId,
|
||||
pageId,
|
||||
slugId,
|
||||
setWindowOpen,
|
||||
setActiveChatId,
|
||||
setDraft,
|
||||
|
||||
@@ -46,9 +46,11 @@ export async function getAiChatMessages(
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
*/
|
||||
export async function getBoundChat(pageId: string): Promise<string | null> {
|
||||
export async function getBoundChat(slugId: string): Promise<string | null> {
|
||||
// The `pageId` body field accepts a page slugId or a uuid; the server resolves
|
||||
// it to the real page uuid (the wire key stays `pageId` for the DTO).
|
||||
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
|
||||
pageId,
|
||||
pageId: slugId,
|
||||
});
|
||||
return req.data.chatId;
|
||||
}
|
||||
|
||||
@@ -2,43 +2,91 @@ import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward
|
||||
* the requesting user + workspace + pageId to findLatestByPage and return the
|
||||
* matched chat's id, or `{ chatId: null }` when there is none. The repo already
|
||||
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match
|
||||
* (null) — no extra page-access check is needed. Exercised with hand-rolled
|
||||
* mocks, no Nest graph and no DB.
|
||||
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint, hardened for
|
||||
* #312. `dto.pageId` carries either a page slugId (10-char nanoid, off a slug
|
||||
* URL) or a page uuid, so the controller must FIRST resolve it to a real page
|
||||
* uuid via PageRepo.findById (which accepts both) — passing the raw slugId into
|
||||
* the uuid ai_chats.page_id column caused a Postgres 22P02 500. Only then is the
|
||||
* caller's most-recent OWN chat for that page looked up (by the resolved uuid),
|
||||
* and a page in a different workspace (or an unknown id) yields { chatId: null }
|
||||
* without ever touching the chat lookup. Exercised with hand-rolled mocks, no
|
||||
* Nest graph and no DB.
|
||||
*/
|
||||
describe('AiChatController.boundChat', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeController(chat: unknown) {
|
||||
function makeController(opts: { page: unknown; chat?: unknown }) {
|
||||
const aiChatRepo = {
|
||||
findLatestByPage: jest.fn().mockResolvedValue(chat),
|
||||
findLatestByPage: jest.fn().mockResolvedValue(opts.chat),
|
||||
};
|
||||
const pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.page),
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
aiChatRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
pageRepo as never,
|
||||
);
|
||||
return { controller, aiChatRepo };
|
||||
return { controller, aiChatRepo, pageRepo };
|
||||
}
|
||||
|
||||
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => {
|
||||
const { controller, aiChatRepo } = makeController({
|
||||
id: 'c1',
|
||||
creatorId: 'u1',
|
||||
it('resolves a slugId to the page uuid and returns the owned chat id', async () => {
|
||||
const { controller, aiChatRepo, pageRepo } = makeController({
|
||||
// findById accepts a slugId and returns the page with its real uuid.
|
||||
page: { id: 'page-uuid-1', workspaceId: 'ws1' },
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
});
|
||||
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace);
|
||||
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1');
|
||||
// The client sends a 10-char nanoid slugId, NOT a uuid.
|
||||
const res = await controller.boundChat(
|
||||
{ pageId: 'i82qXsivsx' },
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('i82qXsivsx');
|
||||
// findLatestByPage must receive the RESOLVED uuid, never the raw slugId.
|
||||
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith(
|
||||
'u1',
|
||||
'ws1',
|
||||
'page-uuid-1',
|
||||
);
|
||||
expect(res).toEqual({ chatId: 'c1' });
|
||||
});
|
||||
|
||||
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => {
|
||||
const { controller } = makeController(undefined);
|
||||
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace);
|
||||
it('returns { chatId: null } for a page in a DIFFERENT workspace without a chat lookup', async () => {
|
||||
const { controller, aiChatRepo, pageRepo } = makeController({
|
||||
page: { id: 'page-uuid-2', workspaceId: 'other-ws' },
|
||||
});
|
||||
const res = await controller.boundChat(
|
||||
{ pageId: 'foreignSlug' },
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('foreignSlug');
|
||||
// No cross-workspace leak: the chat lookup must never run.
|
||||
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
|
||||
expect(res).toEqual({ chatId: null });
|
||||
});
|
||||
|
||||
it('returns { chatId: null } for an unknown id without throwing or looking up a chat', async () => {
|
||||
const { controller, aiChatRepo } = makeController({ page: undefined });
|
||||
const res = await controller.boundChat(
|
||||
{ pageId: 'does-not-exist' },
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
|
||||
expect(res).toEqual({ chatId: null });
|
||||
});
|
||||
|
||||
it('returns { chatId: null } when the resolved page has no owned chat', async () => {
|
||||
const { controller } = makeController({
|
||||
page: { id: 'page-uuid-3', workspaceId: 'ws1' },
|
||||
chat: undefined,
|
||||
});
|
||||
const res = await controller.boundChat({ pageId: 'p3' }, user, workspace);
|
||||
expect(res).toEqual({ chatId: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('AiChatController.export', () => {
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { controller, aiChatRepo, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
@@ -55,6 +56,7 @@ export class AiChatController {
|
||||
private readonly aiChatRepo: AiChatRepo,
|
||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||
private readonly aiTranscription: AiTranscriptionService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
) {}
|
||||
|
||||
/** List the requesting user's chats in this workspace (paginated). */
|
||||
@@ -71,9 +73,15 @@ export class AiChatController {
|
||||
/**
|
||||
* Resolve the chat bound to a document for the requesting user: the most-recent
|
||||
* non-deleted chat created on that page (ai_chats.page_id). Returns
|
||||
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page
|
||||
* access check needed: only the caller's OWN chats are matched, so a foreign
|
||||
* pageId reveals nothing.
|
||||
* { chatId: null } when the page has no owned chat (-> a fresh chat).
|
||||
*
|
||||
* `dto.pageId` carries EITHER a page slugId (10-char nanoid, sent by the client
|
||||
* off a slug URL) OR a page uuid, so it must be resolved to a real page uuid
|
||||
* before it touches the uuid ai_chats.page_id column — passing a slugId straight
|
||||
* through triggered a Postgres 22P02 "invalid input syntax for type uuid" 500
|
||||
* (#312). PageRepo.findById accepts both forms. The workspace guard rejects an
|
||||
* unknown or cross-workspace page (-> { chatId: null }) so a foreign id cannot
|
||||
* probe another workspace's chats. Only the caller's OWN chats are then matched.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('bound-chat')
|
||||
@@ -82,10 +90,14 @@ export class AiChatController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ chatId: string | null }> {
|
||||
const page = await this.pageRepo.findById(dto.pageId); // accepts slugId OR uuid
|
||||
if (!page || page.workspaceId !== workspace.id) {
|
||||
return { chatId: null }; // unknown or foreign-workspace page — no binding, no leak
|
||||
}
|
||||
const chat = await this.aiChatRepo.findLatestByPage(
|
||||
user.id,
|
||||
workspace.id,
|
||||
dto.pageId,
|
||||
page.id, // the real uuid, never the incoming slugId
|
||||
);
|
||||
return { chatId: chat?.id ?? null };
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('AiChatController.generatePageTitle', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { controller, aiChatService };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user