Compare commits
25 Commits
1095c5679f
...
310b54a6da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310b54a6da | ||
|
|
7db3f007cb | ||
|
|
74e2b7ad7f | ||
|
|
a86d0c7c3b | ||
|
|
569da822b6 | ||
|
|
4720705155 | ||
|
|
ce60498a90 | ||
| 4a22cc1955 | |||
|
|
b83a5d4597 | ||
|
|
d4658d4cb3 | ||
|
|
4105836a2d | ||
|
|
f5a45d5453 | ||
|
|
9fad6ab73b | ||
|
|
194924c3ba | ||
|
|
c7f0b51389 | ||
|
|
ebfe56a684 | ||
|
|
e12ddaa2c8 | ||
|
|
6397b500ba | ||
|
|
c3161a05dd | ||
|
|
06bfca5fdb | ||
|
|
04f05626ad | ||
|
|
f9757fda12 | ||
|
|
19cd73a5aa | ||
|
|
e6b1170553 | ||
|
|
2e0f4456e1 |
@@ -147,8 +147,8 @@ MCP_DOCMOST_PASSWORD=
|
||||
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
|
||||
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
|
||||
# offline is safer than an unbounded bill). Override the hourly cap below
|
||||
# (default: 300 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300
|
||||
# (default: 100 calls per workspace per rolling hour).
|
||||
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
|
||||
#
|
||||
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
||||
# Worst-case output per accepted call = agent steps (5) × this value.
|
||||
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -10,8 +10,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public share AI: default per-workspace hourly assistant cap lowered
|
||||
300 → 100.** The limiter falls back to this default whenever
|
||||
`SHARE_AI_WORKSPACE_MAX_PER_HOUR` is unset, so a `0.93.0` deployment that
|
||||
never set the env var has its anonymous public-share assistant hourly cap
|
||||
cut from 300 to 100 on upgrade. Set `SHARE_AI_WORKSPACE_MAX_PER_HOUR` to
|
||||
keep the previous limit. (#62)
|
||||
|
||||
## [0.93.0] - 2026-06-21
|
||||
|
||||
This release builds on the 0.91.0 AI foundation: admin-defined AI agent roles,
|
||||
an anonymous AI assistant on public shares, server-side voice dictation, an
|
||||
editor footnotes model, live page-template embeds, and sandboxed arbitrary-HTML
|
||||
embeds — plus a large batch of security hardening and test coverage.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
|
||||
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
|
||||
`X-MCP-Token` header. The `Authorization` header is now reserved for per-user
|
||||
HTTP Basic / Bearer access-JWT credentials, so each `/mcp` request
|
||||
authenticates as a specific user (the `MCP_DOCMOST_*` service account is only
|
||||
a fallback). Existing MCP clients (e.g. Claude Desktop) configured with
|
||||
`Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
|
||||
`X-MCP-Token: <MCP_TOKEN>` instead. See `MCP_TOKEN` in `.env.example`. As a
|
||||
one-time aid, the server logs a single migration warning when it sees the
|
||||
old-style header.
|
||||
|
||||
### Added
|
||||
|
||||
- **AI agent roles**: admin-defined assistant personas with an optional
|
||||
per-role model override, selectable in chat.
|
||||
- **Anonymous AI assistant on public shares**: public-share visitors can chat
|
||||
with a selectable agent-role identity that reuses the internal chat
|
||||
presentation, with per-request output-token caps and a fail-closed Redis
|
||||
limiter.
|
||||
- **Voice dictation (STT)**: server-side speech-to-text with a mic button in
|
||||
the chat and the editor, OpenRouter STT support, an endpoint test, and real
|
||||
provider-error surfacing.
|
||||
- **Realtime streaming dictation**: a new live-dictation mic mode layered on top
|
||||
of the existing batch STT. Audio streams over a dedicated `/ai-realtime`
|
||||
Socket.IO namespace and text is inserted as you speak (interim partials shown
|
||||
@@ -26,9 +64,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
balancing) lets a user or workspace exceed these caps, since each process
|
||||
counts only its own sessions. Treat the limits as per-process until the
|
||||
counters are moved to a shared store.
|
||||
- Admin-only "Analytics / tracker" workspace setting: a raw HTML/JS snippet
|
||||
- **Footnotes**: an editor footnotes model (inline references + a definitions
|
||||
list).
|
||||
- **Page templates**: live whole-page embed (MVP) with a template-marker icon
|
||||
in the page tree and a working Refresh action.
|
||||
- **Arbitrary HTML/CSS/JS embeds**: a sandboxed-iframe embed block gated by a
|
||||
per-workspace toggle (default OFF); insertable by any member when the toggle
|
||||
is on.
|
||||
- Admin-only **"Analytics / tracker"** workspace setting: a raw HTML/JS snippet
|
||||
injected into the `<head>` of public share pages only (for analytics such as
|
||||
Google Analytics or Yandex.Metrika).
|
||||
Google Analytics or Yandex.Metrika), kept separate from the member-facing
|
||||
HTML-embed feature.
|
||||
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
|
||||
embedded `/mcp` endpoint.
|
||||
- **Page tree**: Expand all / Collapse all for the space tree, and
|
||||
server-authoritative realtime tree updates.
|
||||
- **AI chat UX**: a `get_current_page` tool for proxy-robust page context, a
|
||||
current-context-size readout, an agent step cap raised 8→20 with a forced
|
||||
final text answer, and auto-collapse of the chat window on page focus.
|
||||
- **AI settings**: a Clear control inside the API-key field and an endpoint
|
||||
status dot bound to "configured × enabled".
|
||||
- **Client**: an always-visible space grid replacing the space-switcher popover,
|
||||
removal of the sidebar Overview item, tighter comments-panel density, and no
|
||||
auto-open of the comments panel when adding a comment.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -42,16 +100,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
server-side strip is the public-share read path, which still honors the
|
||||
workspace HTML-embed toggle.
|
||||
|
||||
### Breaking Changes
|
||||
### Fixed
|
||||
|
||||
- **MCP shared-token auth moved to its own header.** The `/mcp` shared guard
|
||||
no longer reads `Authorization: Bearer <MCP_TOKEN>`; it now reads only the
|
||||
`X-MCP-Token` header. Existing MCP clients (e.g. Claude Desktop) configured
|
||||
with `Authorization: Bearer <MCP_TOKEN>` must be reconfigured to send
|
||||
`X-MCP-Token: <MCP_TOKEN>` instead. The `Authorization` header is now
|
||||
reserved for per-user HTTP Basic / Bearer access JWT credentials. See
|
||||
`MCP_TOKEN` in `.env.example`. As a one-time aid, the server logs a single
|
||||
migration warning when it sees the old-style header.
|
||||
- AI chat: preserve scroll position during streaming, record chats that fail on
|
||||
their first turn, and resolve the current page for agent context behind
|
||||
proxies.
|
||||
- AI roles: guard `update()` against concurrent soft-delete; harden the model
|
||||
override, role-name uniqueness, and id validation; sandwich the safety
|
||||
framework around the role persona.
|
||||
- Auth: handle null-password (SSO/LDAP-only) accounts without a bcrypt throw.
|
||||
- Footnotes: survive duplicate-id definitions without collab divergence.
|
||||
- HTML embed: fix stale iframe height and damp the resize loop; strip embeds at
|
||||
serve time on authenticated read paths and the plain page-create path.
|
||||
- Page templates: import `ThrottleModule` so collab boots, never strand an
|
||||
in-flight page-embed id, and add defense-in-depth workspace checks.
|
||||
- Pages: `movePage` cycle guard with no phantom `PAGE_MOVED` event.
|
||||
- Import: surface the real error cause from `/pages/import` instead of a generic
|
||||
400.
|
||||
|
||||
### Security
|
||||
|
||||
- MCP: close an SSO/MFA bypass on Basic auth and stop minting non-init sessions;
|
||||
close a brute-force limiter check-then-act race.
|
||||
- Public share: block restricted descendants in the anonymous assistant, cap
|
||||
per-request output, fail closed when Redis is unavailable, and reject non-text
|
||||
message parts to close a size-cap bypass.
|
||||
- Make `trustProxy` env-configurable with a safe default.
|
||||
|
||||
### Internal
|
||||
|
||||
- CI: gate the `develop` and release image builds on the test suite, run the
|
||||
suites on push/PR, and build the `:develop` image on push to `develop`.
|
||||
- Docs: replace `CLAUDE.md` with `AGENTS.md` codifying the agent workflow and
|
||||
the release procedure, add migration-ordering guidance, and prune implemented
|
||||
plans.
|
||||
- A large batch of new server/client test coverage.
|
||||
|
||||
## [0.91.0] - 2026-06-18
|
||||
|
||||
@@ -135,5 +218,6 @@ knowledge layer, an embedded MCP server, and the Gitmost rebrand.
|
||||
- Build: drop the private EE submodule, retarget CI to GHCR, and update the
|
||||
Docker image to the GHCR registry.
|
||||
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...HEAD
|
||||
[Unreleased]: https://github.com/vvzvlad/gitmost/compare/v0.93.0...HEAD
|
||||
[0.93.0]: https://github.com/vvzvlad/gitmost/compare/v0.91.0...v0.93.0
|
||||
[0.91.0]: https://github.com/vvzvlad/gitmost/compare/v0.90.1...v0.91.0
|
||||
|
||||
@@ -1144,8 +1144,10 @@
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
"{{name}} is typing…": "{{name}} is typing…",
|
||||
"AI is typing…": "AI is typing…",
|
||||
"Send": "Send",
|
||||
"Stop": "Stop",
|
||||
"Chat menu": "Chat menu",
|
||||
|
||||
@@ -669,8 +669,34 @@
|
||||
"AI Answer": "Ответ ИИ",
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"AI is typing…": "AI печатает…",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||
"Ask a question…": "Задайте вопрос…",
|
||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||
"Ask the AI agent…": "Спросите AI-агента…",
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
"Failed to delete chat": "Не удалось удалить чат",
|
||||
"Failed to rename chat": "Не удалось переименовать чат",
|
||||
"Minimize": "Свернуть",
|
||||
"No chats yet.": "Чатов пока нет.",
|
||||
"Send": "Отправить",
|
||||
"Something went wrong": "Что-то пошло не так",
|
||||
"Stop": "Стоп",
|
||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||
"Universal assistant": "Универсальный ассистент",
|
||||
"You": "Вы",
|
||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||
"Thinking": "Думаю",
|
||||
"Ask a question...": "Задайте вопрос...",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Group, Loader, Select, Tooltip } from "@mantine/core";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconCheck,
|
||||
@@ -145,6 +145,7 @@ export default function AiChatWindow() {
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
@@ -178,8 +179,11 @@ export default function AiChatWindow() {
|
||||
setActiveChatId(chatId);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
// Reset the card-picked role so a stale pick can't leak into the existing
|
||||
// chat's header/assistant-name (which prefers the chat's persisted role).
|
||||
setSelectedRoleId(null);
|
||||
},
|
||||
[setActiveChatId, setDraft],
|
||||
[setActiveChatId, setDraft, setSelectedRoleId],
|
||||
);
|
||||
|
||||
// After a turn finishes, refresh the chat list. For a brand-new chat (no id
|
||||
@@ -199,6 +203,18 @@ export default function AiChatWindow() {
|
||||
);
|
||||
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
|
||||
|
||||
// The role to display in the header and as the assistant's name. Prefer the
|
||||
// persisted role of an existing chat (chat-list JOIN); fall back to the role
|
||||
// picked via a card click for a brand-new or just-adopted chat. selectChat
|
||||
// resets selectedRoleId, so this fallback never leaks into an unrelated chat.
|
||||
const currentRole = useMemo<{ name: string; emoji: string | null } | null>(() => {
|
||||
if (activeChat?.roleName) {
|
||||
return { name: activeChat.roleName, emoji: activeChat.roleEmoji ?? null };
|
||||
}
|
||||
const picked = enabledRoles.find((r) => r.id === selectedRoleId);
|
||||
return picked ? { name: picked.name, emoji: picked.emoji } : null;
|
||||
}, [activeChat, enabledRoles, selectedRoleId]);
|
||||
|
||||
// Build a Markdown export from the already-loaded persisted rows (no network
|
||||
// call) and copy it to the clipboard. The "Copied" notification is the
|
||||
// feedback.
|
||||
@@ -430,12 +446,13 @@ export default function AiChatWindow() {
|
||||
{t("AI chat")}
|
||||
</span>
|
||||
|
||||
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||
chat is bound to a role that still exists. */}
|
||||
{activeChat?.roleName && (
|
||||
{/* Role badge (emoji + name). Shows the persisted role of an existing
|
||||
chat, or the role picked via a card for a brand-new chat. Hidden for
|
||||
a universal (no-role) chat. */}
|
||||
{currentRole && (
|
||||
<span className={classes.badge} title={t("Agent role")}>
|
||||
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
|
||||
{activeChat.roleName}
|
||||
{currentRole.emoji ? `${currentRole.emoji} ` : ""}
|
||||
{currentRole.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -537,28 +554,10 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role picker — only for a NEW chat (before it is created). Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead.
|
||||
Defaults to "Universal assistant" (no role). */}
|
||||
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
|
||||
<div style={{ padding: "4px 8px 0" }}>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Agent role")}
|
||||
value={selectedRoleId ?? ""}
|
||||
onChange={(value) => setSelectedRoleId(value || null)}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={[
|
||||
{ value: "", label: t("Universal assistant") },
|
||||
...enabledRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The role picker for a NEW chat is rendered as the chat's empty-state
|
||||
(colored role cards centered in the empty window) by ChatThread
|
||||
itself — clicking a card starts the chat with that role. Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead. */}
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
@@ -574,6 +573,11 @@ export default function AiChatWindow() {
|
||||
openPage={openPage}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
// Role cards are the new-chat empty-state; offered only when this
|
||||
// is a brand-new chat. Clicking a card starts the chat with it.
|
||||
roles={activeChatId === null ? enabledRoles : undefined}
|
||||
onRolePicked={(role) => setSelectedRoleId(role.id)}
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import MessageList from "@/features/ai-chat/components/message-list.tsx";
|
||||
import ChatInput from "@/features/ai-chat/components/chat-input.tsx";
|
||||
import { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import RoleCards from "@/features/ai-chat/components/role-cards.tsx";
|
||||
import {
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
@@ -29,6 +33,15 @@ interface ChatThreadProps {
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
roleId?: string | null;
|
||||
/** Enabled roles for the new-chat empty state (only meaningful when
|
||||
* `chatId === null`). Rendered as the colored role cards. */
|
||||
roles?: IAiRole[];
|
||||
/** Notify the parent which role was picked via a card, so it can update the
|
||||
* header badge / assistant name for the brand-new chat. */
|
||||
onRolePicked?: (role: IAiRole) => void;
|
||||
/** Display name for the assistant label / typing line (the role name);
|
||||
* forwarded to MessageList. Absent => the generic "AI agent". */
|
||||
assistantName?: string;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
||||
* a new chat, adopts the freshly created chat id. */
|
||||
onTurnFinished: () => void;
|
||||
@@ -66,6 +79,9 @@ export default function ChatThread({
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -159,9 +175,28 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// Clicking a role card both binds the role to THIS new chat and immediately
|
||||
// starts the conversation. roleIdRef is set synchronously here because the
|
||||
// parent's selectedRoleId state update would only reach roleIdRef on the next
|
||||
// render — after this synchronous sendMessage has already read it.
|
||||
const handleRolePick = (role: IAiRole): void => {
|
||||
roleIdRef.current = role.id;
|
||||
onRolePicked?.(role);
|
||||
sendMessage({ text: t("Take a look at the current document") });
|
||||
};
|
||||
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
|
||||
const roleCardsEmptyState = showRoleCards ? (
|
||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Box className={classes.panel}>
|
||||
<MessageList messages={messages} isStreaming={isStreaming} />
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={roleCardsEmptyState}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
|
||||
@@ -43,7 +43,7 @@ interface MessageListProps {
|
||||
const BOTTOM_THRESHOLD = 40;
|
||||
|
||||
/**
|
||||
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
|
||||
* Whether to show the standalone "AI is typing…" indicator. It bridges the
|
||||
* gap between sending and the first streamed content, so it shows only while a
|
||||
* turn is in flight AND the latest assistant message has nothing visible yet:
|
||||
* - the last message is still the user's (assistant hasn't started a row), or
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* Layout only — per-card colors are injected inline via Mantine CSS vars. */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
/* flex-start keeps the first row reachable when the wrapped cards overflow and
|
||||
the container scrolls. With align-content: center, an overflowing top row is
|
||||
pushed out of the scrollable area and becomes unreachable. The parent Mantine
|
||||
Center still vertically centers the whole block when it fits. */
|
||||
align-content: flex-start;
|
||||
gap: 10px;
|
||||
/* Cap the height so a large number of roles scrolls instead of blowing out
|
||||
the empty chat area. */
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 140px;
|
||||
max-width: 200px;
|
||||
min-height: 90px;
|
||||
padding: 12px 10px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The description: small and slightly muted, inheriting the card's color. We
|
||||
reduce opacity instead of using Mantine's `c="dimmed"` so it doesn't clash
|
||||
with the card's inline color. */
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
line-height: 1.3;
|
||||
/* Break long unbreakable tokens (URLs, long foreign words) in the
|
||||
admin-configured description so they wrap instead of overflowing the card
|
||||
width now that the line clamp no longer caps the text. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import RoleCards from "./role-cards";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom
|
||||
// does not implement. Provide a minimal stub so the provider can render.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const roles: IAiRole[] = [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Pirate",
|
||||
emoji: "🏴☠️",
|
||||
description: "Talks like a pirate",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "Grandpa",
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
function renderCards(onPick = vi.fn()) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RoleCards roles={roles} onPick={onPick} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
return onPick;
|
||||
}
|
||||
|
||||
describe("RoleCards", () => {
|
||||
it("renders one card per role with name, emoji, and description", () => {
|
||||
renderCards();
|
||||
expect(screen.getByText("Pirate")).toBeDefined();
|
||||
expect(screen.getByText("Talks like a pirate")).toBeDefined();
|
||||
expect(screen.getByText("Grandpa")).toBeDefined();
|
||||
// The emoji is shown for the role that has one.
|
||||
expect(screen.getByText("🏴☠️")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render a Universal assistant card", () => {
|
||||
renderCards();
|
||||
expect(screen.queryByText("Universal assistant")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onPick with the role object when a card is clicked", () => {
|
||||
const onPick = renderCards();
|
||||
fireEvent.click(screen.getByText("Pirate"));
|
||||
expect(onPick).toHaveBeenCalledWith(roles[0]);
|
||||
});
|
||||
});
|
||||
78
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
78
apps/client/src/features/ai-chat/components/role-cards.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { UnstyledButton, Text } from "@mantine/core";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { roleCardColor } from "@/features/ai-chat/utils/role-card-color.ts";
|
||||
import classes from "@/features/ai-chat/components/role-cards.module.css";
|
||||
|
||||
interface RoleCardsProps {
|
||||
/** The enabled roles to render (one card each). */
|
||||
roles: IAiRole[];
|
||||
/** Called with the picked role when a card is clicked. The parent starts the
|
||||
* chat with this role (binds it and sends the opening message). */
|
||||
onPick: (role: IAiRole) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One role card. Colors are injected inline via theme-aware Mantine CSS vars so
|
||||
* they render correctly in both light and dark themes; the CSS module owns only
|
||||
* the layout. The card shows the emoji (if any), the role name, and a small
|
||||
* dimmed description line (if any).
|
||||
*/
|
||||
function RoleCard({
|
||||
color,
|
||||
name,
|
||||
emoji,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
color: string;
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
description?: string | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={classes.card}
|
||||
style={{
|
||||
backgroundColor: `var(--mantine-color-${color}-light)`,
|
||||
color: `var(--mantine-color-${color}-light-color)`,
|
||||
}}
|
||||
title={description ?? name}
|
||||
onClick={onClick}
|
||||
>
|
||||
{emoji && <span className={classes.emoji}>{emoji}</span>}
|
||||
<Text size="sm" fw={600} lineClamp={2}>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="xs" className={classes.description}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored role cards rendered as the empty-state of a brand-new chat. There is
|
||||
* no Universal assistant card — the universal assistant is the implicit default
|
||||
* the user gets by simply typing into the composer without picking a card.
|
||||
* Clicking a card immediately STARTS the chat with that role (the parent binds
|
||||
* the role to the new chat and sends the opening message).
|
||||
*/
|
||||
export default function RoleCards({ roles, onPick }: RoleCardsProps) {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{roles.map((role, index) => (
|
||||
<RoleCard
|
||||
key={role.id}
|
||||
color={roleCardColor(index)}
|
||||
name={role.name}
|
||||
emoji={role.emoji}
|
||||
description={role.description}
|
||||
onClick={() => onPick(role)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { showTypingIndicator } from "@/features/ai-chat/components/message-list.
|
||||
/**
|
||||
* Pure-helper tests for the typing-indicator bridging logic that the internal
|
||||
* chat and the public share widget now share. This is the behavior that decides
|
||||
* whether the animated "AI agent is typing…" placeholder shows in the gap
|
||||
* whether the animated "AI is typing…" placeholder shows in the gap
|
||||
* between sending and the first streamed token.
|
||||
*/
|
||||
const msg = (
|
||||
|
||||
@@ -19,8 +19,10 @@ interface TypingIndicatorProps {
|
||||
* the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed label), so it reads
|
||||
* as the assistant's bubble taking shape. The label and typing line use the
|
||||
* configured identity name when provided, otherwise the generic "AI agent".
|
||||
* as the assistant's bubble taking shape. The dimmed label uses the configured
|
||||
* identity name when provided (otherwise the generic "AI agent"), while the
|
||||
* typing line is always the generic "AI is typing…" (it never includes the
|
||||
* role/identity name).
|
||||
*/
|
||||
export default function TypingIndicator({ assistantName }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -38,7 +40,7 @@ export default function TypingIndicator({ assistantName }: TypingIndicatorProps)
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{name ? t("{{name}} is typing…", { name }) : t("AI agent is typing…")}
|
||||
{t("AI is typing…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ROLE_CARD_PALETTE, roleCardColor } from "./role-card-color";
|
||||
|
||||
describe("roleCardColor", () => {
|
||||
it("has a 10-color palette", () => {
|
||||
expect(ROLE_CARD_PALETTE).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("maps index 0 to the first palette color (blue)", () => {
|
||||
expect(roleCardColor(0)).toBe("blue");
|
||||
expect(roleCardColor(1)).toBe("grape");
|
||||
});
|
||||
|
||||
it("wraps around at the end of the palette", () => {
|
||||
expect(roleCardColor(10)).toBe("blue");
|
||||
expect(roleCardColor(11)).toBe("grape");
|
||||
});
|
||||
|
||||
it("is safe for negative indices", () => {
|
||||
expect(roleCardColor(-1)).toBe("violet");
|
||||
expect(roleCardColor(-10)).toBe("blue");
|
||||
});
|
||||
});
|
||||
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
25
apps/client/src/features/ai-chat/utils/role-card-color.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Fixed Mantine color palette for the new-chat role cards. Cards cycle through
|
||||
// these names by index; the colors are applied via theme-aware Mantine CSS vars
|
||||
// (`--mantine-color-<name>-light` etc.) so they are correct in both themes.
|
||||
// Universal assistant uses neutral `gray` separately (not part of this palette).
|
||||
export const ROLE_CARD_PALETTE = [
|
||||
"blue",
|
||||
"grape",
|
||||
"teal",
|
||||
"orange",
|
||||
"pink",
|
||||
"cyan",
|
||||
"lime",
|
||||
"indigo",
|
||||
"red",
|
||||
"violet",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Pick a palette color name for a role card by its index. Cycles through the
|
||||
* palette and is safe for negative indices.
|
||||
*/
|
||||
export function roleCardColor(index: number): string {
|
||||
const len = ROLE_CARD_PALETTE.length;
|
||||
return ROLE_CARD_PALETTE[((index % len) + len) % len];
|
||||
}
|
||||
@@ -12,6 +12,15 @@ i18n
|
||||
// init i18next
|
||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init({
|
||||
// i18n maintenance policy:
|
||||
// - en-US is the source of truth for all UI strings (keys are the English text).
|
||||
// - en-US and ru-RU are the fully-maintained locales; in particular, the
|
||||
// AI-chat string set is kept complete in both so the UI never renders
|
||||
// mixed-language (no per-key en-US fallback within a single widget).
|
||||
// - The other 10 locales (fr-FR, de-DE, es-ES, nl-NL, ja-JP, zh-CN, ko-KR,
|
||||
// pt-BR, it-IT, uk-UA) are partial and intentionally rely on the
|
||||
// `fallbackLng: "en-US"` fallback below until translations are
|
||||
// contributed (e.g. via Crowdin).
|
||||
fallbackLng: "en-US",
|
||||
debug: false,
|
||||
showSupportNotice: false,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
|
||||
@@ -82,3 +82,82 @@ describe('buildSystemPrompt role layering', () => {
|
||||
expect(prompt).toContain(SAFETY_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for the "current page" context injected by buildSystemPrompt. When
|
||||
* the client supplies an openedPage with a non-blank id, a CONTEXT line names
|
||||
* the page (title or "Untitled") and its pageId so the agent can resolve "this
|
||||
* page". When no usable id is present, nothing is added. The line always sits
|
||||
* inside the safety sandwich, before the trailing SAFETY copy.
|
||||
*/
|
||||
describe('buildSystemPrompt current-page context', () => {
|
||||
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||
const SAFETY_MARKER = 'Operating rules (always in effect)';
|
||||
|
||||
it('includes the page title and pageId when both are present', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: 'Audio Tract' },
|
||||
});
|
||||
expect(prompt).toContain('currently viewing the page');
|
||||
expect(prompt).toContain('pageId: pg-123');
|
||||
expect(prompt).toContain('"Audio Tract"');
|
||||
});
|
||||
|
||||
it('falls back to "Untitled" when the title is missing', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123' },
|
||||
});
|
||||
expect(prompt).toContain('pageId: pg-123');
|
||||
expect(prompt).toContain('"Untitled"');
|
||||
});
|
||||
|
||||
it('falls back to "Untitled" when the title is only whitespace', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: ' ' },
|
||||
});
|
||||
expect(prompt).toContain('pageId: pg-123');
|
||||
expect(prompt).toContain('"Untitled"');
|
||||
});
|
||||
|
||||
it('adds no page context when openedPage is null', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, openedPage: null });
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('adds no page context when openedPage is omitted', () => {
|
||||
const prompt = buildSystemPrompt({ workspace });
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('adds no page context when openedPage has no id', () => {
|
||||
const prompt = buildSystemPrompt({ workspace, openedPage: { title: 'x' } });
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('adds no page context when the id is only whitespace', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: ' ' },
|
||||
});
|
||||
expect(prompt).not.toContain('currently viewing the page');
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('places the page context inside the safety sandwich (before the closing SAFETY)', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: 'Audio Tract' },
|
||||
});
|
||||
const pageIdx = prompt.indexOf('currently viewing the page');
|
||||
const firstSafety = prompt.indexOf(SAFETY_MARKER);
|
||||
const lastSafety = prompt.lastIndexOf(SAFETY_MARKER);
|
||||
expect(pageIdx).toBeGreaterThan(firstSafety);
|
||||
expect(pageIdx).toBeLessThan(lastSafety);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,7 +386,7 @@ describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () =>
|
||||
it('falls back to the default for an unparseable / NaN value', () => {
|
||||
process.env[ENV] = 'not-a-number';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(100);
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ import type { Redis } from 'ioredis';
|
||||
*/
|
||||
|
||||
/** Default cap: anonymous share-AI calls allowed per workspace per window. */
|
||||
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 300;
|
||||
export const SHARE_AI_WORKSPACE_MAX_PER_WINDOW = 100;
|
||||
/** Default window length: one rolling hour. */
|
||||
export const SHARE_AI_WORKSPACE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
loadDocmostMcp,
|
||||
type DocmostClientLike,
|
||||
} from './docmost-client.loader';
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
|
||||
/**
|
||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||
@@ -222,14 +224,7 @@ export class AiChatToolsService {
|
||||
'or null if the user is not currently on a page. Call this first whenever ' +
|
||||
'the user refers to the current page without giving an explicit id.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
if (!openedPage?.id) {
|
||||
return { page: null };
|
||||
}
|
||||
return {
|
||||
page: { id: openedPage.id, title: openedPage.title ?? '' },
|
||||
};
|
||||
},
|
||||
execute: async () => resolveCurrentPageResult(openedPage),
|
||||
}),
|
||||
|
||||
getPage: tool({
|
||||
@@ -711,14 +706,7 @@ export class AiChatToolsService {
|
||||
// Parity with the standalone MCP server (index.ts patch_node): the
|
||||
// model sometimes serializes the node as a JSON string. Parse it
|
||||
// before the client's typeof-object guard rejects it.
|
||||
let parsedNode = node;
|
||||
if (typeof node === 'string') {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error('node was a string but not valid JSON');
|
||||
}
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.patchNode(pageId, nodeId, parsedNode);
|
||||
},
|
||||
}),
|
||||
@@ -770,14 +758,7 @@ export class AiChatToolsService {
|
||||
// Parity with the standalone MCP server (index.ts insert_node): the
|
||||
// model sometimes serializes the node as a JSON string. Parse it
|
||||
// before the client's typeof-object guard rejects it.
|
||||
let parsedNode = node;
|
||||
if (typeof node === 'string') {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error('node was a string but not valid JSON');
|
||||
}
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
@@ -826,14 +807,9 @@ export class AiChatToolsService {
|
||||
let doc;
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
} else if (typeof content === 'string') {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error('content was a string but not valid JSON');
|
||||
}
|
||||
} else {
|
||||
doc = content;
|
||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
||||
doc = parseNodeArg(content, 'content was a string but not valid JSON');
|
||||
}
|
||||
return await client.updatePageJson(pageId, doc, title);
|
||||
},
|
||||
|
||||
43
apps/server/src/core/ai-chat/tools/current-page.util.spec.ts
Normal file
43
apps/server/src/core/ai-chat/tools/current-page.util.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
|
||||
/**
|
||||
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
|
||||
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
|
||||
* otherwise { page: { id, title } } with title defaulting to ''.
|
||||
*/
|
||||
describe('resolveCurrentPageResult', () => {
|
||||
it('returns { page: null } when openedPage is undefined', () => {
|
||||
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns { page: null } when openedPage is null', () => {
|
||||
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns { page: null } when openedPage has no id', () => {
|
||||
expect(resolveCurrentPageResult({})).toEqual({ page: null });
|
||||
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns { page: null } when id is an empty string', () => {
|
||||
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
|
||||
});
|
||||
|
||||
it('returns the page id and title when both are present', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
|
||||
page: { id: 'p1', title: 'Hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults title to "" when it is missing', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
||||
page: { id: 'p1', title: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicit empty-string title as ""', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
||||
page: { id: 'p1', title: '' },
|
||||
});
|
||||
});
|
||||
});
|
||||
21
apps/server/src/core/ai-chat/tools/current-page.util.ts
Normal file
21
apps/server/src/core/ai-chat/tools/current-page.util.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface CurrentPageInput {
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CurrentPageResult {
|
||||
page: { id: string; title: string } | null;
|
||||
}
|
||||
|
||||
// Resolve the "current page" tool result from the client-supplied open-page
|
||||
// context. Returns { page: null } when no page is open (no id), otherwise the
|
||||
// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage
|
||||
// tool's contract so it can be unit-tested without the ESM Docmost client.
|
||||
export function resolveCurrentPageResult(
|
||||
openedPage?: CurrentPageInput | null,
|
||||
): CurrentPageResult {
|
||||
if (!openedPage?.id) {
|
||||
return { page: null };
|
||||
}
|
||||
return { page: { id: openedPage.id, title: openedPage.title ?? '' } };
|
||||
}
|
||||
37
apps/server/src/core/ai-chat/tools/parse-node-arg.spec.ts
Normal file
37
apps/server/src/core/ai-chat/tools/parse-node-arg.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
|
||||
/**
|
||||
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
|
||||
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
|
||||
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
|
||||
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
|
||||
*/
|
||||
describe('parseNodeArg', () => {
|
||||
it('passes an object through unchanged', () => {
|
||||
const obj = { type: 'paragraph', content: [] };
|
||||
expect(parseNodeArg(obj)).toBe(obj);
|
||||
});
|
||||
|
||||
it('passes undefined/null through unchanged', () => {
|
||||
expect(parseNodeArg(undefined)).toBeUndefined();
|
||||
expect(parseNodeArg(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a valid JSON string into an object', () => {
|
||||
expect(parseNodeArg('{"type":"paragraph"}')).toEqual({
|
||||
type: 'paragraph',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the default message on an invalid JSON string', () => {
|
||||
expect(() => parseNodeArg('{not json')).toThrow(
|
||||
'node was a string but not valid JSON',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a custom message on an invalid JSON string', () => {
|
||||
expect(() =>
|
||||
parseNodeArg('{not json', 'content was a string but not valid JSON'),
|
||||
).toThrow('content was a string but not valid JSON');
|
||||
});
|
||||
});
|
||||
26
apps/server/src/core/ai-chat/tools/parse-node-arg.ts
Normal file
26
apps/server/src/core/ai-chat/tools/parse-node-arg.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||
// invalid JSON), pass an object through unchanged. Shared by patchNode /
|
||||
// insertNode (and the analogous updatePageJson content parsing).
|
||||
//
|
||||
// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts`
|
||||
// (the function logic, default/explicit throw messages and branch order match;
|
||||
// only comments and quote style differ). We cannot import that helper here:
|
||||
// `@docmost/mcp` is ESM-only and this server
|
||||
// compiles with module:commonjs, so it is loaded at runtime via the
|
||||
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
|
||||
// runtime code across that ESM/CJS boundary by a normal import is impossible,
|
||||
// hence the mirrored copy.
|
||||
export function parseNodeArg(
|
||||
node: unknown,
|
||||
errMsg = 'node was a string but not valid JSON',
|
||||
): unknown {
|
||||
if (typeof node === 'string') {
|
||||
try {
|
||||
return JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
* fan-out per user, sockets with no userId skipped).
|
||||
*
|
||||
* Both private methods are exercised through their public entry points:
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
|
||||
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
|
||||
* mocked socket.io server, so no live infra is needed.
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via the
|
||||
* restricted-page path of emitTreeEvent. WsService is constructed with mocked
|
||||
* cache + repo and a mocked socket.io server, so no live infra is needed.
|
||||
*/
|
||||
|
||||
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
|
||||
@@ -127,7 +127,7 @@ describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitTreeEvent restricted path)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
@@ -167,6 +167,12 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
in: serverIn,
|
||||
};
|
||||
service.setServer(server as never);
|
||||
|
||||
// Reach broadcastToAuthorizedUsers through emitTreeEvent's restricted path:
|
||||
// the space has restrictions (cache miss -> repo says true) and the page has
|
||||
// a restricted ancestor, so the emit is scoped to the authorized users.
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
|
||||
@@ -180,7 +186,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
await service.emitTreeEvent('space-1', 'page-1', data);
|
||||
|
||||
// The authorized set is resolved from the candidate userIds present on the
|
||||
// sockets (deduped), then only those users' sockets get the event.
|
||||
@@ -203,7 +209,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
await service.emitTreeEvent('space-1', 'page-1', data);
|
||||
|
||||
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
|
||||
expect(tab1).toHaveBeenCalledWith('message', data);
|
||||
@@ -227,7 +233,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
await service.emitTreeEvent('space-1', 'page-1', data);
|
||||
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).not.toHaveBeenCalled();
|
||||
@@ -241,7 +247,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
it('no sockets in the room -> no repo lookup, no emit', async () => {
|
||||
fetchSockets.mockResolvedValue([]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -252,7 +258,7 @@ describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUser
|
||||
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
|
||||
]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
|
||||
await service.emitTreeEvent('space-7', 'page-1', { op: 'x' });
|
||||
|
||||
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
|
||||
});
|
||||
|
||||
@@ -27,8 +27,7 @@ describe('WsTreeService', () => {
|
||||
let wsService: {
|
||||
emitTreeEvent: jest.Mock;
|
||||
emitToSpaceRoom: jest.Mock;
|
||||
emitDeleteToUnauthorized: jest.Mock;
|
||||
emitToAuthorizedUsers: jest.Mock;
|
||||
emitMoveWithRestrictionSplit: jest.Mock;
|
||||
};
|
||||
let pagePermissionRepo: { hasRestrictedAncestor: jest.Mock };
|
||||
|
||||
@@ -36,8 +35,7 @@ describe('WsTreeService', () => {
|
||||
wsService = {
|
||||
emitTreeEvent: jest.fn().mockResolvedValue(undefined),
|
||||
emitToSpaceRoom: jest.fn(),
|
||||
emitDeleteToUnauthorized: jest.fn().mockResolvedValue(undefined),
|
||||
emitToAuthorizedUsers: jest.fn().mockResolvedValue(undefined),
|
||||
emitMoveWithRestrictionSplit: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pagePermissionRepo = {
|
||||
// Default: not restricted, so broadcastPageMoved skips the compensating
|
||||
@@ -150,14 +148,13 @@ describe('WsTreeService', () => {
|
||||
|
||||
await service.broadcastPageMoved(event);
|
||||
|
||||
// Normal path: move goes to the whole room via emitTreeEvent, and neither
|
||||
// the authorized-only move path nor the compensating delete fire.
|
||||
// Normal path: move goes to the whole room via emitTreeEvent, and the
|
||||
// single-snapshot move/delete split does not fire.
|
||||
expect(wsService.emitTreeEvent).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitToAuthorizedUsers).not.toHaveBeenCalled();
|
||||
expect(wsService.emitDeleteToUnauthorized).not.toHaveBeenCalled();
|
||||
expect(wsService.emitMoveWithRestrictionSplit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcastPageMoved into a RESTRICTED subtree routes the move to authorized users only AND emits a compensating delete to unauthorized — from one fresh decision', async () => {
|
||||
it('broadcastPageMoved into a RESTRICTED subtree drives the move + compensating delete from ONE single-snapshot split call', async () => {
|
||||
// Destination is now under a restricted ancestor.
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(true);
|
||||
|
||||
@@ -180,11 +177,18 @@ describe('WsTreeService', () => {
|
||||
// which could leak the move to the whole room during the stale-cache window.
|
||||
expect(wsService.emitTreeEvent).not.toHaveBeenCalled();
|
||||
|
||||
// The move is delivered to authorized users only.
|
||||
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitToAuthorizedUsers).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
// BOTH the move and the compensating delete are driven from ONE call, so a
|
||||
// single socket/access snapshot partitions the room (no race window).
|
||||
expect(wsService.emitMoveWithRestrictionSplit).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [spaceId, pageId, movePayload, deletePayload] =
|
||||
wsService.emitMoveWithRestrictionSplit.mock.calls[0];
|
||||
|
||||
expect(spaceId).toBe('space-1');
|
||||
expect(pageId).toBe('page-1');
|
||||
|
||||
// The move payload is the moveTreeNode for the moved page.
|
||||
expect(movePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
operation: 'moveTreeNode',
|
||||
spaceId: 'space-1',
|
||||
@@ -192,20 +196,23 @@ describe('WsTreeService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// The users who lost access get a deleteTreeNode for the moved node, scoped
|
||||
// to the same page id (same fresh authorized set → disjoint from the move).
|
||||
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(wsService.emitDeleteToUnauthorized).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
// The delete payload is the compensating deleteTreeNode, scoped to the same
|
||||
// page id and carrying the OLD parent id (so it disappears from where it was
|
||||
// last visible).
|
||||
expect(deletePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
operation: 'deleteTreeNode',
|
||||
spaceId: 'space-1',
|
||||
payload: {
|
||||
node: expect.objectContaining({ id: 'page-1', slugId: 'slug-1' }),
|
||||
node: expect.objectContaining({
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
parentPageId: 'old-parent',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(deletePayload.payload.node.parentPageId).toBe(event.oldParentId);
|
||||
});
|
||||
|
||||
it('broadcastRefetchRoot emits refetchRootTreeNodeEvent to the space room', async () => {
|
||||
@@ -339,7 +346,7 @@ describe('WsService.emitTreeEvent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('emitDeleteToUnauthorized sends ONLY to sockets whose user lacks page access', async () => {
|
||||
it('emitMoveWithRestrictionSplit partitions the room from one snapshot: authorized -> move, unauthorized + anonymous -> delete', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
@@ -348,38 +355,49 @@ describe('WsService.emitTreeEvent', () => {
|
||||
const sockets = [
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
||||
// Unauthenticated socket (no userId) — must also receive the delete.
|
||||
// Unauthenticated socket (no userId) — must receive the delete.
|
||||
{ id: 's3', data: {}, emit: anonEmit },
|
||||
];
|
||||
server.in.mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
});
|
||||
|
||||
const data = { operation: 'deleteTreeNode' };
|
||||
await service.emitDeleteToUnauthorized('space-1', 'page-1', data);
|
||||
const movePayload = { operation: 'moveTreeNode' };
|
||||
const deletePayload = { operation: 'deleteTreeNode' };
|
||||
await service.emitMoveWithRestrictionSplit(
|
||||
'space-1',
|
||||
'page-1',
|
||||
movePayload,
|
||||
deletePayload,
|
||||
);
|
||||
|
||||
// Authorized user does NOT get the delete (they got the move instead).
|
||||
expect(okEmit).not.toHaveBeenCalled();
|
||||
// Unauthorized + anonymous sockets DO get the delete.
|
||||
expect(noEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', data);
|
||||
// Authorized socket gets ONLY the move.
|
||||
expect(okEmit).toHaveBeenCalledWith('message', movePayload);
|
||||
expect(okEmit).not.toHaveBeenCalledWith('message', deletePayload);
|
||||
// Unauthorized + anonymous sockets get ONLY the delete.
|
||||
expect(noEmit).toHaveBeenCalledWith('message', deletePayload);
|
||||
expect(noEmit).not.toHaveBeenCalledWith('message', movePayload);
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', deletePayload);
|
||||
expect(anonEmit).not.toHaveBeenCalledWith('message', movePayload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
|
||||
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
|
||||
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
|
||||
// disjoint and together cover every socket — and an anonymous (no-userId)
|
||||
// socket lands in the delete set. We wire a REAL WsService (only its repo,
|
||||
// cache and socket server mocked) so both broadcasts run against the SAME fixed
|
||||
// socket set, the way they do in production.
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room from a
|
||||
// SINGLE snapshot. emitMoveWithRestrictionSplit performs exactly one
|
||||
// fetchSockets + one getUserIdsWithPageAccess; the authorized set (gets the
|
||||
// moveTreeNode) and its complement (gets the deleteTreeNode) are disjoint and
|
||||
// together cover every socket — and an anonymous (no-userId) socket lands in
|
||||
// the delete set. We wire a REAL WsService (only its repo, cache and socket
|
||||
// server mocked) so the partition runs against the SAME fixed socket set, the
|
||||
// way it does in production.
|
||||
let treeService: WsTreeService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let fetchSockets: jest.Mock;
|
||||
|
||||
// Fixed room: two authorized users (one with two sockets), one unauthorized
|
||||
// user, one anonymous socket.
|
||||
@@ -429,11 +447,12 @@ describe('move-into-restricted disjointness contract (WsTreeService + real WsSer
|
||||
}).compile();
|
||||
|
||||
const wsService = module.get<WsService>(WsService);
|
||||
// Capture fetchSockets so the test can assert the SINGLE-snapshot contract:
|
||||
// exactly one fetchSockets call drives the whole partition.
|
||||
fetchSockets = jest.fn().mockResolvedValue(sockets);
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: jest.fn().mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
}),
|
||||
in: jest.fn().mockReturnValue({ fetchSockets }),
|
||||
};
|
||||
wsService.setServer(server as never);
|
||||
|
||||
@@ -469,5 +488,12 @@ describe('move-into-restricted disjointness contract (WsTreeService + real WsSer
|
||||
// The anonymous socket specifically lands in the DELETE set, never the move.
|
||||
expect(deleteSet.has('s-anon')).toBe(true);
|
||||
expect(moveSet.has('s-anon')).toBe(false);
|
||||
|
||||
// SINGLE SNAPSHOT: the whole partition (move + compensating delete) is driven
|
||||
// from exactly ONE fetchSockets and exactly ONE getUserIdsWithPageAccess.
|
||||
// This is what closes the race window — there is no second, independent
|
||||
// snapshot that could disagree with the first.
|
||||
expect(fetchSockets).toHaveBeenCalledTimes(1);
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,22 +131,20 @@ export class WsTreeService {
|
||||
}
|
||||
|
||||
// Restricted case: a move can push a previously-visible page UNDER a
|
||||
// restricted ancestor. Route the move to authorized users ONLY (same fresh
|
||||
// getUserIdsWithPageAccess set the delete uses) and send the compensating
|
||||
// delete to everyone else. Both sets come from one fresh decision, so they
|
||||
// are guaranteed disjoint: authorized users get exactly the moveTreeNode,
|
||||
// unauthorized users get exactly the deleteTreeNode, nobody gets both.
|
||||
// restricted ancestor. The move (to authorized users) and the compensating
|
||||
// delete (to everyone else) are now driven from ONE socket/access snapshot:
|
||||
// emitMoveWithRestrictionSplit performs a single fetchSockets + a single
|
||||
// getUserIdsWithPageAccess and partitions the room from that one snapshot.
|
||||
// This eliminates the race window that existed when the move and the delete
|
||||
// each resolved the audience independently — a socket could otherwise have
|
||||
// landed in both sets (leaking the restricted node) or in neither (losing the
|
||||
// compensating delete). Authorized users get exactly the moveTreeNode,
|
||||
// everyone else (unauthorized + anonymous) gets exactly the deleteTreeNode.
|
||||
//
|
||||
// Users who LOSE visibility need the delete because otherwise the node would
|
||||
// linger in their tree at its old parent with its real title/slugId/icon
|
||||
// (existence + metadata leak).
|
||||
await this.wsService.emitToAuthorizedUsers(
|
||||
node.spaceId,
|
||||
node.id,
|
||||
movePayload,
|
||||
);
|
||||
|
||||
await this.wsService.emitDeleteToUnauthorized(node.spaceId, node.id, {
|
||||
await this.wsService.emitMoveWithRestrictionSplit(node.spaceId, node.id, movePayload, {
|
||||
operation: 'deleteTreeNode',
|
||||
spaceId: node.spaceId,
|
||||
payload: {
|
||||
|
||||
@@ -118,19 +118,42 @@ export class WsService {
|
||||
this.server.to(getSpaceRoomName(spaceId)).emit('message', data);
|
||||
}
|
||||
|
||||
// Broadcast `data` (a deleteTreeNode) to every socket in the space room whose
|
||||
// user is NOT authorized to see `pageId`. Used to compensate a move that pushes
|
||||
// a previously-visible page UNDER a restricted ancestor: authorized users get
|
||||
// the moveTreeNode (via emitTreeEvent), everyone else gets a deleteTreeNode so
|
||||
// the now-restricted node disappears from their tree instead of lingering with
|
||||
// its real title/slugId/icon. The two event sets are disjoint by construction
|
||||
// (a user is either authorized or not), so no socket receives both.
|
||||
async emitDeleteToUnauthorized(
|
||||
// Single-snapshot move broadcast. This is the ONE place that fans out a move
|
||||
// under a restricted ancestor together with its compensating delete, resolving
|
||||
// the audience EXACTLY ONCE so the two never disagree.
|
||||
//
|
||||
// It takes a SINGLE socket snapshot (`this.server.in(room).fetchSockets()` is
|
||||
// called exactly once) and a SINGLE authorization resolution
|
||||
// (`getUserIdsWithPageAccess` is called exactly once). From that one snapshot it
|
||||
// partitions the room into two groups and emits to each:
|
||||
// - authorized users (their userId is in the authorized set) receive
|
||||
// `movePayload` (the moveTreeNode);
|
||||
// - everyone else — unauthorized users AND anonymous/no-userId sockets —
|
||||
// receive `deletePayload` (the compensating deleteTreeNode) so a now-hidden
|
||||
// node disappears from their tree instead of lingering with its real
|
||||
// title/slugId/icon.
|
||||
// Because both groups are derived from the same socket array and the same
|
||||
// authorized set, the partition is guaranteed DISJOINT (no socket gets both)
|
||||
// and COMPLETE (every socket gets exactly one). This closes the race window
|
||||
// that existed when the move and the compensating delete each ran their own
|
||||
// independent fetchSockets + getUserIdsWithPageAccess: between those two
|
||||
// snapshots a socket could connect/disconnect or its access change, so a socket
|
||||
// could end up in both sets (leaking the restricted node, then no delete) or in
|
||||
// neither (losing the compensating delete).
|
||||
//
|
||||
// It deliberately does NOT consult the cached spaceHasRestrictions: the caller
|
||||
// (broadcastPageMoved) has already established, freshly and uncached, that the
|
||||
// page is restricted, so we must not risk a stale cache fanning the move out to
|
||||
// the whole room.
|
||||
async emitMoveWithRestrictionSplit(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
movePayload: any,
|
||||
deletePayload: any,
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
|
||||
// ONE socket snapshot for the whole partition.
|
||||
const sockets = await this.server.in(room).fetchSockets();
|
||||
if (sockets.length === 0) return;
|
||||
|
||||
@@ -141,39 +164,26 @@ export class WsService {
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
);
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const authorizedUserIds =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds);
|
||||
// ONE authorization resolution for the whole partition.
|
||||
const authorizedUserIds = userIds.length
|
||||
? await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, userIds)
|
||||
: [];
|
||||
const authorizedSet = new Set(authorizedUserIds);
|
||||
|
||||
for (const socket of sockets) {
|
||||
const userId = socket.data.userId as string;
|
||||
// Unauthenticated sockets (no userId) cannot see restricted content; send
|
||||
// them the delete too so a leaked node can't linger.
|
||||
if (!userId || !authorizedSet.has(userId)) {
|
||||
socket.emit('message', data);
|
||||
if (userId && authorizedSet.has(userId)) {
|
||||
// Authorized: deliver the move.
|
||||
socket.emit('message', movePayload);
|
||||
} else {
|
||||
// Unauthorized OR anonymous (no userId): deliver the compensating
|
||||
// delete so the now-hidden node can't linger.
|
||||
socket.emit('message', deletePayload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server-origin broadcast of `data` to exactly the users in the space room who
|
||||
// ARE authorized to see `pageId`. This is the counterpart of
|
||||
// emitDeleteToUnauthorized: both resolve the authorized set from the SAME
|
||||
// fetchSockets + getUserIdsWithPageAccess call shape, so a caller that drives
|
||||
// both from one decision gets two disjoint sets (authorized vs. not) with no
|
||||
// socket in both. Unlike emitTreeEvent, this does NOT consult the cached
|
||||
// spaceHasRestrictions: the caller already knows the page is restricted, so we
|
||||
// must not risk a stale cache fanning the move out to the whole room.
|
||||
async emitToAuthorizedUsers(
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const room = getSpaceRoomName(spaceId);
|
||||
await this.broadcastToAuthorizedUsers(room, null, pageId, data);
|
||||
}
|
||||
|
||||
private async broadcastToAuthorizedUsers(
|
||||
room: string,
|
||||
excludeSocketId: string | null,
|
||||
|
||||
78
apps/server/test/integration/ai-agent-roles-repo.int-spec.ts
Normal file
78
apps/server/test/integration/ai-agent-roles-repo.int-spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
||||
|
||||
/**
|
||||
* B — AiAgentRoleRepo: tenant isolation + soft-delete-aware lookups + the
|
||||
* partial unique index `WHERE deleted_at IS NULL` (migration
|
||||
* 20260620T120000-ai-agent-roles.ts). Exercises real SQL constraints.
|
||||
*/
|
||||
describe('AiAgentRoleRepo isolation + partial unique index [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: AiAgentRoleRepo;
|
||||
let w1: string;
|
||||
let w2: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
repo = new AiAgentRoleRepo(db as any);
|
||||
w1 = (await createWorkspace(db)).id;
|
||||
w2 = (await createWorkspace(db)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
it('findById / listByWorkspace exclude soft-deleted rows', async () => {
|
||||
const live = await repo.insert({ workspaceId: w1, name: 'Live', instructions: 'x' });
|
||||
const dead = await repo.insert({ workspaceId: w1, name: 'Dead', instructions: 'x' });
|
||||
await repo.softDelete(dead.id, w1);
|
||||
|
||||
expect(await repo.findById(live.id, w1)).toBeDefined();
|
||||
expect(await repo.findById(dead.id, w1)).toBeUndefined();
|
||||
|
||||
const names = (await repo.listByWorkspace(w1)).map((r) => r.name);
|
||||
expect(names).toContain('Live');
|
||||
expect(names).not.toContain('Dead');
|
||||
});
|
||||
|
||||
it('findById of a W2 role from W1 context returns undefined (tenant isolation)', async () => {
|
||||
const w2role = await repo.insert({ workspaceId: w2, name: 'W2Role', instructions: 'x' });
|
||||
|
||||
expect(await repo.findById(w2role.id, w2)).toBeDefined();
|
||||
// Same id, wrong workspace context -> not visible.
|
||||
expect(await repo.findById(w2role.id, w1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('duplicate (name, workspace) while not-deleted throws 23505 unique violation', async () => {
|
||||
await repo.insert({ workspaceId: w1, name: 'Dup', instructions: 'x' });
|
||||
|
||||
let code: string | undefined;
|
||||
try {
|
||||
await repo.insert({ workspaceId: w1, name: 'Dup', instructions: 'x' });
|
||||
} catch (err: any) {
|
||||
code = err?.code ?? err?.cause?.code;
|
||||
}
|
||||
expect(code).toBe('23505');
|
||||
});
|
||||
|
||||
it('same name is reusable after softDelete (partial unique index WHERE deleted_at IS NULL)', async () => {
|
||||
const first = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
|
||||
await repo.softDelete(first.id, w1);
|
||||
|
||||
// Now inserting the same name must succeed because the soft-deleted row is
|
||||
// excluded from the partial unique index.
|
||||
const second = await repo.insert({ workspaceId: w1, name: 'Reusable', instructions: 'x' });
|
||||
expect(second.id).toBeDefined();
|
||||
expect(second.id).not.toBe(first.id);
|
||||
});
|
||||
|
||||
it('same name in W1 and W2 is allowed (unique is per-workspace)', async () => {
|
||||
const a = await repo.insert({ workspaceId: w1, name: 'CrossTenant', instructions: 'x' });
|
||||
const b = await repo.insert({ workspaceId: w2, name: 'CrossTenant', instructions: 'x' });
|
||||
expect(a.id).toBeDefined();
|
||||
expect(b.id).toBeDefined();
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createUser,
|
||||
createRole,
|
||||
createChat,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* E (stretch) — AiChatRepo.findByCreator role-badge LEFT JOIN. The badge
|
||||
* (roleName/roleEmoji) is populated ONLY when the bound role is live AND
|
||||
* enabled; a soft-deleted or disabled role resolves to NULL, matching the
|
||||
* stream's resolveRoleForRequest downgrade. Real SQL join, not a mock.
|
||||
*/
|
||||
describe('AiChatRepo.findByCreator role-badge join [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: AiChatRepo;
|
||||
let roleRepo: AiAgentRoleRepo;
|
||||
let workspaceId: string;
|
||||
let creatorId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
repo = new AiChatRepo(db as any);
|
||||
roleRepo = new AiAgentRoleRepo(db as any);
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
creatorId = (await createUser(db, workspaceId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
async function badgeFor(chatId: string) {
|
||||
const { items } = await repo.findByCreator(creatorId, workspaceId, {
|
||||
limit: 50,
|
||||
} as any);
|
||||
const row = items.find((c: any) => c.id === chatId);
|
||||
expect(row).toBeDefined();
|
||||
return { roleName: (row as any).roleName, roleEmoji: (row as any).roleEmoji };
|
||||
}
|
||||
|
||||
it('enabled role -> roleName/roleEmoji populated', async () => {
|
||||
const role = await createRole(db, {
|
||||
workspaceId,
|
||||
name: 'Proofreader',
|
||||
emoji: '📝',
|
||||
enabled: true,
|
||||
});
|
||||
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
|
||||
|
||||
const badge = await badgeFor(chat.id);
|
||||
expect(badge.roleName).toBe('Proofreader');
|
||||
expect(badge.roleEmoji).toBe('📝');
|
||||
});
|
||||
|
||||
it('soft-deleted role -> badge NULL', async () => {
|
||||
const role = await createRole(db, {
|
||||
workspaceId,
|
||||
name: 'Deleted Persona',
|
||||
emoji: '🗑️',
|
||||
enabled: true,
|
||||
});
|
||||
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
|
||||
await roleRepo.softDelete(role.id, workspaceId);
|
||||
|
||||
const badge = await badgeFor(chat.id);
|
||||
expect(badge.roleName).toBeNull();
|
||||
expect(badge.roleEmoji).toBeNull();
|
||||
});
|
||||
|
||||
it('disabled role -> badge NULL (mirrors resolveRoleForRequest downgrade)', async () => {
|
||||
const role = await createRole(db, {
|
||||
workspaceId,
|
||||
name: 'Disabled Persona',
|
||||
emoji: '🚫',
|
||||
enabled: false,
|
||||
});
|
||||
const chat = await createChat(db, { workspaceId, creatorId, roleId: role.id });
|
||||
|
||||
const badge = await badgeFor(chat.id);
|
||||
expect(badge.roleName).toBeNull();
|
||||
expect(badge.roleEmoji).toBeNull();
|
||||
});
|
||||
|
||||
it('chat with no role -> badge NULL', async () => {
|
||||
const chat = await createChat(db, { workspaceId, creatorId, roleId: null });
|
||||
const badge = await badgeFor(chat.id);
|
||||
expect(badge.roleName).toBeNull();
|
||||
expect(badge.roleEmoji).toBeNull();
|
||||
});
|
||||
});
|
||||
194
apps/server/test/integration/db.ts
Normal file
194
apps/server/test/integration/db.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CamelCasePlugin, Kysely } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
|
||||
/**
|
||||
* Isolated test database connection string. The dev DB is `docmost`; tests run
|
||||
* against a dedicated `docmost_test` that global-setup drops + recreates +
|
||||
* migrates so nothing here touches dev data. Overridable via env (global-setup
|
||||
* also sets it so the value is consistent across the run).
|
||||
*/
|
||||
export const TEST_DATABASE_URL =
|
||||
process.env.TEST_DATABASE_URL ??
|
||||
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
|
||||
|
||||
/**
|
||||
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
|
||||
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
|
||||
* parsing (to:20 / from:[20,1700] / serialize toString / parse parseInt). The
|
||||
* repos rely on camelCase columns + bigint-as-number, so the test Kysely must
|
||||
* match or queries break.
|
||||
*/
|
||||
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
|
||||
return new Kysely<any>({
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres(url, {
|
||||
max: 5,
|
||||
onnotice: () => {},
|
||||
types: {
|
||||
bigint: {
|
||||
to: 20,
|
||||
from: [20, 1700],
|
||||
serialize: (value: number) => value.toString(),
|
||||
parse: (value: string) => Number.parseInt(value),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
}
|
||||
|
||||
let singleton: Kysely<any> | undefined;
|
||||
|
||||
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
|
||||
export function getTestDb(): Kysely<any> {
|
||||
if (!singleton) {
|
||||
singleton = buildTestDb();
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
export async function destroyTestDb(): Promise<void> {
|
||||
if (singleton) {
|
||||
await singleton.destroy();
|
||||
singleton = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seeding helpers ---------------------------------------------------------
|
||||
// Insert minimal valid rows (only the columns the tests need + NOT NULL ones).
|
||||
// Plain randomUUID() is fine for FK integrity in tests (the app uses uuid v7).
|
||||
|
||||
export async function createWorkspace(
|
||||
db: Kysely<any>,
|
||||
overrides: { settings?: unknown; name?: string } = {},
|
||||
): Promise<{ id: string; settings: any }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('workspaces')
|
||||
.values({
|
||||
id,
|
||||
name: overrides.name ?? `ws-${id.slice(0, 8)}`,
|
||||
// hostname is uniquely constrained; keep it unique per workspace.
|
||||
hostname: `host-${id.slice(0, 8)}`,
|
||||
settings: overrides.settings === undefined ? null : (overrides.settings as any),
|
||||
})
|
||||
.returning(['id', 'settings'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string, settings: row.settings };
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
overrides: { email?: string; name?: string } = {},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('users')
|
||||
.values({
|
||||
id,
|
||||
email: overrides.email ?? `user-${id.slice(0, 8)}@example.test`,
|
||||
name: overrides.name ?? `user-${id.slice(0, 8)}`,
|
||||
workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createSpace(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
overrides: { slug?: string; name?: string } = {},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('spaces')
|
||||
.values({
|
||||
id,
|
||||
name: overrides.name ?? `space-${id.slice(0, 8)}`,
|
||||
// slug is unique per workspace + NOT NULL.
|
||||
slug: overrides.slug ?? `space-${id.slice(0, 8)}`,
|
||||
workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createPage(
|
||||
db: Kysely<any>,
|
||||
args: { workspaceId: string; spaceId: string; title?: string },
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
id,
|
||||
// slug_id is NOT NULL + globally unique.
|
||||
slugId: `slug-${id.slice(0, 8)}`,
|
||||
title: args.title ?? `page-${id.slice(0, 8)}`,
|
||||
spaceId: args.spaceId,
|
||||
workspaceId: args.workspaceId,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
workspaceId: string;
|
||||
creatorId?: string | null;
|
||||
name: string;
|
||||
emoji?: string | null;
|
||||
instructions?: string;
|
||||
enabled?: boolean;
|
||||
deletedAt?: Date | null;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('aiAgentRoles')
|
||||
.values({
|
||||
id,
|
||||
workspaceId: args.workspaceId,
|
||||
creatorId: args.creatorId ?? null,
|
||||
name: args.name,
|
||||
emoji: args.emoji ?? null,
|
||||
instructions: args.instructions ?? 'be helpful',
|
||||
enabled: args.enabled ?? true,
|
||||
deletedAt: args.deletedAt ?? null,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
|
||||
export async function createChat(
|
||||
db: Kysely<any>,
|
||||
args: {
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
roleId?: string | null;
|
||||
title?: string;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const id = randomUUID();
|
||||
const row = await db
|
||||
.insertInto('aiChats')
|
||||
.values({
|
||||
id,
|
||||
workspaceId: args.workspaceId,
|
||||
creatorId: args.creatorId,
|
||||
roleId: args.roleId ?? null,
|
||||
title: args.title ?? `chat-${id.slice(0, 8)}`,
|
||||
})
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { id: row.id as string };
|
||||
}
|
||||
79
apps/server/test/integration/global-setup.ts
Normal file
79
apps/server/test/integration/global-setup.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as path from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { TEST_DATABASE_URL, buildTestDb } from './db';
|
||||
|
||||
const MAINTENANCE_URL =
|
||||
process.env.TEST_MAINTENANCE_DATABASE_URL ??
|
||||
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost';
|
||||
|
||||
const TEST_DB_NAME = 'docmost_test';
|
||||
|
||||
// migrate.ts points FileMigrationProvider at src/database/migrations; mirror it.
|
||||
const migrationFolder = path.resolve(
|
||||
__dirname,
|
||||
'../../src/database/migrations',
|
||||
);
|
||||
|
||||
/**
|
||||
* Jest globalSetup: (re)create the isolated test database and migrate it to
|
||||
* latest. Mirrors apps/server/src/database/migrate.ts (Kysely Migrator +
|
||||
* FileMigrationProvider) so the schema is exactly what the app expects.
|
||||
*/
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
// 1. DROP/CREATE the test DB via the maintenance connection. These statements
|
||||
// cannot run inside a transaction; use the raw postgres client's simple
|
||||
// query (`.simple()`) so the driver does not wrap them.
|
||||
const maintenance = postgres(MAINTENANCE_URL, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await maintenance`DROP DATABASE IF EXISTS docmost_test WITH (FORCE)`.simple();
|
||||
await maintenance`CREATE DATABASE docmost_test`.simple();
|
||||
} finally {
|
||||
await maintenance.end({ timeout: 5 });
|
||||
}
|
||||
|
||||
// 2. Enable pgvector on the fresh DB (migrations create vector columns).
|
||||
const ext = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await ext`CREATE EXTENSION IF NOT EXISTS vector`.simple();
|
||||
} finally {
|
||||
await ext.end({ timeout: 5 });
|
||||
}
|
||||
|
||||
// 3. Run all migrations to latest against docmost_test.
|
||||
const db: Kysely<any> = new Kysely<any>({
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres(TEST_DATABASE_URL, { onnotice: () => {} }),
|
||||
}),
|
||||
});
|
||||
const migrator = new Migrator({
|
||||
db,
|
||||
provider: new FileMigrationProvider({ fs, path, migrationFolder }),
|
||||
});
|
||||
|
||||
const { error, results } = await migrator.migrateToLatest();
|
||||
// Fail loud on ANY errored migration, even if Migrator did not also surface a
|
||||
// top-level `error` — never run the suite against a half-migrated schema.
|
||||
const failed = (results ?? []).filter((r) => r.status === 'Error');
|
||||
await db.destroy();
|
||||
|
||||
if (error || failed.length > 0) {
|
||||
const names = failed.map((r) => r.migrationName).join(', ');
|
||||
throw new Error(
|
||||
`Test DB migration failed${names ? ` (${names})` : ''}: ${
|
||||
(error as Error)?.message ?? error ?? 'errored migration result'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Pin the URL for the test workers (db.ts reads it from env).
|
||||
process.env.TEST_DATABASE_URL = TEST_DATABASE_URL;
|
||||
|
||||
// Sanity touch: open + close the shared test Kysely once so a bad connection
|
||||
// surfaces here rather than mid-suite.
|
||||
const probe = buildTestDb();
|
||||
await probe.selectFrom('workspaces').select('id').limit(1).execute();
|
||||
await probe.destroy();
|
||||
}
|
||||
11
apps/server/test/integration/global-teardown.ts
Normal file
11
apps/server/test/integration/global-teardown.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { destroyTestDb } from './db';
|
||||
|
||||
/**
|
||||
* Jest globalTeardown: close any pools opened in the setup-process scope so jest
|
||||
* exits cleanly. The test workers destroy their own connections in afterAll.
|
||||
* We intentionally LEAVE docmost_test in place for post-mortem debuggability;
|
||||
* global-setup drops + recreates it on the next run.
|
||||
*/
|
||||
export default async function globalTeardown(): Promise<void> {
|
||||
await destroyTestDb();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
createPage,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* C — page_template_references FK onDelete('cascade') (migration
|
||||
* 20260620T131000-page-template-references.ts). Both reference_page_id and
|
||||
* source_page_id reference pages.id ON DELETE CASCADE; deleting either page
|
||||
* must remove the reference row.
|
||||
*/
|
||||
describe('page_template_references FK cascade [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let workspaceId: string;
|
||||
let spaceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
workspaceId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, workspaceId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
async function seedRef() {
|
||||
const source = await createPage(db, { workspaceId, spaceId, title: 'source' });
|
||||
const reference = await createPage(db, { workspaceId, spaceId, title: 'reference' });
|
||||
const ref = await db
|
||||
.insertInto('pageTemplateReferences')
|
||||
.values({ workspaceId, sourcePageId: source.id, referencePageId: reference.id })
|
||||
.returning(['id'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return { source, reference, refId: ref.id as string };
|
||||
}
|
||||
|
||||
async function refExists(refId: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.selectFrom('pageTemplateReferences')
|
||||
.select('id')
|
||||
.where('id', '=', refId)
|
||||
.executeTakeFirst();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
it('deleting the referenced page cascades the reference row away', async () => {
|
||||
const { reference, refId } = await seedRef();
|
||||
expect(await refExists(refId)).toBe(true);
|
||||
|
||||
await db.deleteFrom('pages').where('id', '=', reference.id).execute();
|
||||
|
||||
expect(await refExists(refId)).toBe(false);
|
||||
});
|
||||
|
||||
it('deleting the source page also cascades the reference row away', async () => {
|
||||
const { source, refId } = await seedRef();
|
||||
expect(await refExists(refId)).toBe(true);
|
||||
|
||||
await db.deleteFrom('pages').where('id', '=', source.id).execute();
|
||||
|
||||
expect(await refExists(refId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import Redis from 'ioredis';
|
||||
import { PublicShareWorkspaceLimiter } from 'src/core/ai-chat/public-share-workspace-limiter';
|
||||
|
||||
/**
|
||||
* D — PublicShareWorkspaceLimiter against REAL Redis (logical DB 15, so nothing
|
||||
* touches dev data). This exercises the actual Lua EVAL — including
|
||||
* ZREMRANGEBYSCORE eviction and the `ZCARD >= max` boundary — which a FakeRedis
|
||||
* cannot faithfully reproduce.
|
||||
*/
|
||||
describe('PublicShareWorkspaceLimiter vs real Redis [integration]', () => {
|
||||
let redis: Redis;
|
||||
|
||||
beforeAll(async () => {
|
||||
// db:15 keeps this off the app's db 0, so dev Redis data is never touched.
|
||||
const url = process.env.TEST_REDIS_URL ?? 'redis://127.0.0.1:6379';
|
||||
redis = new Redis(url, { db: 15, lazyConnect: false });
|
||||
// Surface an unreachable/wrong Redis here with a clear error, not mid-test.
|
||||
await redis.ping();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redis.flushdb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await redis.quit();
|
||||
});
|
||||
|
||||
it('admits the first max calls and denies the next, then re-admits after the window slides', async () => {
|
||||
let nowMs = 1_000_000;
|
||||
const now = () => nowMs;
|
||||
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
|
||||
const key = 'ws-sliding';
|
||||
|
||||
// First 3 admitted.
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
// 4th denied (cap reached; ZCARD >= max).
|
||||
expect(await limiter.tryConsume(key)).toBe(false);
|
||||
|
||||
// Advance time past the window so all 3 entries fall out of the trailing
|
||||
// windowMs and ZREMRANGEBYSCORE evicts them.
|
||||
nowMs += 1500;
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
});
|
||||
|
||||
it('counts 3 distinct same-millisecond calls distinctly, then denies the 4th', async () => {
|
||||
// Fixed `now` => all attempts share the same timestamp. Unique member ids
|
||||
// (counter + random suffix) keep them distinct in the sorted set so the
|
||||
// count is not under-reported by score collision.
|
||||
const now = () => 2_000_000;
|
||||
const limiter = new PublicShareWorkspaceLimiter(redis, 3, 1000, now);
|
||||
const key = 'ws-same-ms';
|
||||
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
expect(await limiter.tryConsume(key)).toBe(true);
|
||||
expect(await limiter.tryConsume(key)).toBe(false);
|
||||
|
||||
// Confirm the sorted set actually holds 3 distinct members at one score.
|
||||
const card = await redis.zcard('share-ai:ws:' + key);
|
||||
expect(card).toBe(3);
|
||||
});
|
||||
|
||||
it('keys are isolated per workspace', async () => {
|
||||
const now = () => 3_000_000;
|
||||
const limiter = new PublicShareWorkspaceLimiter(redis, 1, 1000, now);
|
||||
|
||||
expect(await limiter.tryConsume('ws-a')).toBe(true);
|
||||
expect(await limiter.tryConsume('ws-a')).toBe(false);
|
||||
// Different key has its own independent budget.
|
||||
expect(await limiter.tryConsume('ws-b')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
||||
|
||||
/**
|
||||
* A — WorkspaceRepo.updateSetting jsonb-MERGE (the html-embed kill-switch
|
||||
* write-half). Setting a single top-level key must NOT clobber sibling
|
||||
* settings namespaces. This is real SQL: the repo does
|
||||
* `COALESCE(settings,'{}') || jsonb_build_object(key, value)`.
|
||||
*/
|
||||
describe('WorkspaceRepo.updateSetting (jsonb merge) [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let repo: WorkspaceRepo;
|
||||
|
||||
beforeAll(() => {
|
||||
db = getTestDb();
|
||||
// Repos are plain classes taking @InjectKysely() db — instantiate directly.
|
||||
repo = new WorkspaceRepo(db as any);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
it('persists htmlEmbed:true without clobbering sibling ai/sharing settings', async () => {
|
||||
const ws = await createWorkspace(db, {
|
||||
settings: { ai: { chat: true }, sharing: { x: 1 } },
|
||||
});
|
||||
|
||||
const updated = await repo.updateSetting(ws.id, 'htmlEmbed', true);
|
||||
|
||||
// Returned row carries the merged settings.
|
||||
expect(updated.settings).toMatchObject({
|
||||
htmlEmbed: true,
|
||||
ai: { chat: true },
|
||||
sharing: { x: 1 },
|
||||
});
|
||||
|
||||
// Re-read from the DB to confirm it actually persisted (not just returning()).
|
||||
const row = await db
|
||||
.selectFrom('workspaces')
|
||||
.select(['settings'])
|
||||
.where('id', '=', ws.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.settings).toEqual({
|
||||
ai: { chat: true },
|
||||
sharing: { x: 1 },
|
||||
htmlEmbed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes settings from NULL via COALESCE without error', async () => {
|
||||
const ws = await createWorkspace(db, { settings: undefined });
|
||||
|
||||
const updated = await repo.updateSetting(ws.id, 'htmlEmbed', false);
|
||||
|
||||
expect(updated.settings).toEqual({ htmlEmbed: false });
|
||||
});
|
||||
});
|
||||
23
apps/server/test/jest-integration.json
Normal file
23
apps/server/test/jest-integration.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts", "tsx"],
|
||||
"rootDir": "..",
|
||||
"testRegex": ".*\\.int-spec\\.ts$",
|
||||
"testPathIgnorePatterns": ["/node_modules/"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||
],
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 60000,
|
||||
"maxWorkers": 1,
|
||||
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
||||
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
||||
"moduleNameMapper": {
|
||||
"^@docmost/db/(.*)$": "<rootDir>/src/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/src/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/src/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/src/$1"
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
# Авто-сворачивание AI-чата в заголовок при фокусе на странице, разворот по клику
|
||||
|
||||
## Контекст (запрос)
|
||||
|
||||
Плавающее окно AI-чата (`AiChatWindow`) сейчас перекрывает контент страницы:
|
||||
если открыть чат и начать читать/листать вики-страницу под ним, окно остаётся
|
||||
во весь рост и закрывает таблицу/текст (см. скриншот: окно поверх «Аудио-тракт в
|
||||
умных колонках»). Свернуть можно только вручную — кнопкой «—» (Minimize) в шапке.
|
||||
|
||||
Хотим, чтобы окно **само сворачивалось в свою шапку, как только пользователь
|
||||
переключается на страницу** (кликает мимо окна — в редактор/в контент), и
|
||||
**разворачивалось обратно по клику на шапку**. Тогда чат не мешает читать
|
||||
страницу, но остаётся под рукой одним кликом.
|
||||
|
||||
Важно: сворачивание — это именно визуальный коллапс (как нынешний Minimize), а
|
||||
**не** закрытие. Поток ответа агента не должен прерываться.
|
||||
|
||||
## Как сейчас устроено (цепочка)
|
||||
|
||||
Всё во фронтенде, в одном компоненте окна:
|
||||
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx`
|
||||
(+ его CSS `ai-chat-window.module.css`).
|
||||
|
||||
- **Состояние «свёрнуто»** уже есть: `const [minimized, setMinimized] = useState(false)`
|
||||
— строка ~108.
|
||||
- **Переключатель** `toggleMinimize` (строки ~319-321) просто инвертирует флаг;
|
||||
привязан к кнопке «—» (`IconMinus`) в шапке (строки ~366-374).
|
||||
- **Визуальный коллапс уже реализован в CSS** (`ai-chat-window.module.css`):
|
||||
- `.minimized { height: auto !important; min-height: 0 !important; resize: none; }`
|
||||
(строки ~40-44) — окно схлопывается до высоты шапки;
|
||||
- `.minimized .content { display: none; }` (строки ~56-58) — тело
|
||||
(история + тред) скрывается, но **не размонтируется**: `ChatThread` остаётся
|
||||
в DOM, поэтому идущий стрим/`AbortController` не обрывается (это явно описано в
|
||||
комментариях у `.content` и в `toggleMinimize`).
|
||||
- При `minimized` инлайновая `height` не задаётся (строка ~334), чтобы победила
|
||||
auto-высота из CSS; резайз-ручка скрыта (строки ~454-458).
|
||||
- **Шапка = `.dragBar`** (JSX строки ~338-385) с `onMouseDown={startDrag}`.
|
||||
- `startDrag` (строки ~262-314) игнорирует нажатия на кнопках
|
||||
(`if ((e.target).closest("button")) return;`, строка ~264) — чтобы «—»/«×»/«+»
|
||||
не таскали окно.
|
||||
- В `mouseup` (`up`, строки ~290-308) сохраняется итоговая позиция в `geom`.
|
||||
- **Клика-для-разворота сейчас нет**: одиночный клик по шапке только инициирует
|
||||
перетаскивание, развернуть свёрнутое окно можно лишь повторным нажатием «—».
|
||||
- Окно смонтировано глобально и плавает над всем: `<AiChatWindow />` в
|
||||
`apps/client/src/components/layouts/global/global-app-shell.tsx` (строка ~159),
|
||||
`position: fixed`, `z-index: 105` (ниже оверлеев Mantine: modal=200, menu=300,
|
||||
notifications=400 — это нам важно, см. «Тонкие моменты»).
|
||||
- Композер автофокусится при монтировании треда (`autoFocus` в
|
||||
`chat-input.tsx`) — это фокус **внутри** окна, не на странице.
|
||||
|
||||
Итого: «свёрнутый» вид готов. Нужно добавить **два триггера**: (1) авто-сворот при
|
||||
взаимодействии со страницей и (2) разворот по клику на шапку.
|
||||
|
||||
## Решение (точечное, только клиент)
|
||||
|
||||
Файл: `apps/client/src/features/ai-chat/components/ai-chat-window.tsx`
|
||||
(+ пара строк CSS, опционально + i18n-ключ).
|
||||
|
||||
### Часть 1 — авто-сворачивание при взаимодействии со страницей
|
||||
|
||||
Слушаем `mousedown`/`pointerdown` на `document` (в capture-фазе), но **только**
|
||||
когда окно открыто и ещё не свёрнуто. Если нажатие пришло **вне окна** и **не
|
||||
внутри портала Mantine** — сворачиваем.
|
||||
|
||||
```ts
|
||||
// Auto-collapse the window into its header as soon as the user interacts with
|
||||
// anything outside it (clicks the page/editor). Active only while open and
|
||||
// expanded. Capture phase so a child's stopPropagation can't hide the event.
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const el = winRef.current;
|
||||
if (!el || !target) return;
|
||||
// Inside the window itself -> not an "away" interaction.
|
||||
if (el.contains(target)) return;
|
||||
// Inside a Mantine portal the chat owns (kebab Menu dropdown, delete-confirm
|
||||
// modal, the context-size Tooltip, notifications). Mantine's Portal sets
|
||||
// data-portal="true" on its node, so this reliably excludes ALL of them.
|
||||
if (target.closest("[data-portal]")) return;
|
||||
setMinimized(true);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown, true);
|
||||
}, [windowOpen, minimized]);
|
||||
```
|
||||
|
||||
Почему `mousedown` (а не `focusin`):
|
||||
- Клик по **не-фокусируемому** элементу страницы (ячейка таблицы, обычный текст —
|
||||
ровно случай со скриншота) фокус-событие не порождает, но это и есть «ушёл на
|
||||
страницу». `mousedown` ловит любой клик. `focusin` пропустил бы такие клики.
|
||||
- Минус: `mousedown` не ловит переход фокуса с клавиатуры (Tab в редактор). Если
|
||||
это нужно — добавить параллельно `focusin`-слушатель с тем же гардом (см.
|
||||
«Открытые вопросы»). По умолчанию — только указатель, как и просит запрос
|
||||
(«смена фокуса на страницу» = клик мимо окна).
|
||||
|
||||
Почему гард `[data-portal]` обязателен:
|
||||
- Кебаб-меню списка чатов рендерит `Menu.Dropdown` в портал (вне DOM окна) —
|
||||
`conversation-list.tsx` строки ~123-149; удаление — `modals.openConfirmModal`
|
||||
(строка ~56), тоже портал. Без гарда клик по пункту «Rename»/«Delete» свернул
|
||||
бы чат прямо в момент выбора. Mantine на узле портала ставит
|
||||
`data-portal="true"` (подтверждено в `node_modules/@mantine/core` →
|
||||
`Portal.cjs`), поэтому `target.closest("[data-portal]")` исключает их все
|
||||
(а заодно Tooltip размера контекста и нотификации — они тоже порталы).
|
||||
|
||||
Регистрация в `useEffect` с deps `[windowOpen, minimized]`: слушатель вешается
|
||||
только когда `windowOpen && !minimized`, и снимается при сворачивании/закрытии —
|
||||
не делаем лишней работы и не дёргаем `setMinimized(true)` повторно.
|
||||
|
||||
### Часть 2 — разворот по клику на шапку
|
||||
|
||||
Нужно отличить **клик** по шапке (развернуть) от **перетаскивания** свёрнутой
|
||||
плашки (она остаётся таскаемой). Нельзя просто навесить `onClick` на `.dragBar`:
|
||||
браузер шлёт `click` и в конце драга (mousedown+mouseup на том же элементе), и
|
||||
плашка разворачивалась бы после любого перетаскивания.
|
||||
|
||||
Решение — доработать существующий `startDrag`: запомнить стартовые координаты,
|
||||
а в `mouseup` посчитать смещение; если оно ниже порога (≈4px) **и** окно сейчас
|
||||
свёрнуто — развернуть.
|
||||
|
||||
```ts
|
||||
const startDrag = useCallback((e: React.MouseEvent): void => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
const el = winRef.current;
|
||||
if (!el) return;
|
||||
const sx = e.clientX;
|
||||
const sy = e.clientY;
|
||||
// ... (ol/ot + move() unchanged)
|
||||
|
||||
const up = (ev: MouseEvent): void => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = "";
|
||||
// Treat a near-zero-movement press as a click. When minimized, a click on
|
||||
// the header expands the window (drag still repositions the collapsed bar).
|
||||
const moved =
|
||||
Math.abs(ev.clientX - sx) > 4 || Math.abs(ev.clientY - sy) > 4;
|
||||
if (!moved && minimizedRef.current) {
|
||||
setMinimized(false);
|
||||
return; // nothing to persist: position didn't change
|
||||
}
|
||||
// ... (persist geom as before)
|
||||
};
|
||||
// ...
|
||||
}, []);
|
||||
```
|
||||
|
||||
Подводный камень — **stale closure**: `startDrag` обёрнут в `useCallback([])`,
|
||||
поэтому замыкает устаревший `minimized`. Два варианта:
|
||||
- держать `minimizedRef = useRef(minimized)` и синхронизировать его в эффекте
|
||||
(`minimizedRef.current = minimized`) — тогда `useCallback([])` остаётся (как в
|
||||
коде выше); **рекомендуется**, не пересоздаёт хендлер;
|
||||
- либо добавить `minimized` в deps `useCallback` — проще, но пересоздаёт `startDrag`
|
||||
на каждом тоггле (дёшево, но дёргает `onMouseDown`-проп).
|
||||
|
||||
Кнопка «—» остаётся как явный тоггл (`toggleMinimize` уже инвертирует флаг), так
|
||||
что развернуть можно и ей. Менять её не нужно.
|
||||
|
||||
### Часть 3 (рекомендуется) — аффорданс и доступность шапки
|
||||
|
||||
- **Курсор**: в свёрнутом виде шапка кликабельна — заменить `grab` на `pointer`:
|
||||
```css
|
||||
/* ai-chat-window.module.css — hint that the collapsed header expands on click */
|
||||
.minimized .dragBar { cursor: pointer; }
|
||||
```
|
||||
- **Клавиатура/скринридер**: `.dragBar` — это `div`. В свёрнутом состоянии дать
|
||||
ему `role="button"`, `tabIndex={0}`, `aria-label={t("Expand")}` и обработчик
|
||||
Enter/Space → `setMinimized(false)`. Иначе развернуть без мыши нельзя.
|
||||
|
||||
## Тонкие моменты / edge cases
|
||||
|
||||
- **Стрим не прерывается.** Авто-сворот выставляет `minimized=true` — `ChatThread`
|
||||
остаётся смонтированным (только `.content` скрывается). Ответ агента
|
||||
достреливается в фоне; развернув шапку, пользователь видит результат. Это
|
||||
желаемое поведение (он специально ушёл читать страницу).
|
||||
- **Автофокус композера при открытии.** Открытие окна автофокусит textarea —
|
||||
это `focus` **внутри** окна, а не внешний `mousedown`, поэтому ложного
|
||||
немедленного сворота не будет.
|
||||
- **Перетаскивание окна** (mousedown по шапке) — это нажатие **внутри**
|
||||
`winRef.current`, гард `el.contains(target)` его пропускает: drag не сворачивает.
|
||||
- **Резайз** нативной ручкой — mousedown тоже внутри окна, не сворачивает.
|
||||
- **Порталы дочерних компонентов** (кебаб-меню, confirm-модалка, tooltip,
|
||||
нотификации) исключены гардом `[data-portal]` — клик по ним не сворачивает.
|
||||
Это ключевая причина не использовать «голый» contains-only outside-click.
|
||||
- **Capture-фаза** слушателя: ловим `mousedown` даже если кто-то на странице
|
||||
вызывает `stopPropagation` в bubble-фазе. На клики внутри окна/порталов не
|
||||
влияет (их отсекают гарды).
|
||||
- **Повторный авто-сворот** не происходит: при `minimized` слушатель снят (deps
|
||||
эффекта). Разворот по клику снова навешивает слушатель — цикл корректен.
|
||||
- **Состояние при закрытии/открытии.** Компонент при `!windowOpen` возвращает
|
||||
`null`, но **не размонтируется**, поэтому `minimized` переживает закрытие.
|
||||
Желательно при каждом открытии показывать окно **развёрнутым**: добавить
|
||||
`setMinimized(false)` в эффект, срабатывающий на переход `windowOpen → true`
|
||||
(можно в тот же `useLayoutEffect`, что вычисляет геометрию, строки ~238-241).
|
||||
См. «Открытые вопросы».
|
||||
- **z-index/оверлеи.** Окно (105) ниже modal/menu/notifications — поэтому
|
||||
confirm-модалка удаления и кебаб-меню рисуются **над** окном; даже если бы чат
|
||||
свернулся за ними, они продолжали бы работать. Но гард `[data-portal]` всё равно
|
||||
не даёт сворачиваться при работе с ними.
|
||||
- **Touch.** Драг сейчас на mouse-событиях (десктоп-фича). Для единообразия
|
||||
внешний слушатель можно сделать `pointerdown` вместо `mousedown` (покроет тач),
|
||||
но тогда и порог-клик в `up` стоит считать на pointer-событиях. По умолчанию —
|
||||
`mousedown`, как у драга.
|
||||
|
||||
## i18n
|
||||
|
||||
- Новые пользовательские строки — **только через `t(...)`** и добавить ключ в
|
||||
`apps/client/public/locales/en-US/translation.json` (каталог ключ==значение).
|
||||
Достаточно `"Expand"` (для `aria-label`/`title` шапки в свёрнутом виде).
|
||||
В шапке уже есть `t("Minimize")`, `t("Close")`, `t("New chat")`.
|
||||
- Комментарии в коде — на английском (правило проекта).
|
||||
|
||||
## Тесты
|
||||
|
||||
- Вынести чистые хелперы и покрыть Vitest:
|
||||
- `shouldCollapseOnOutsidePointer(target, windowEl): boolean`
|
||||
(`windowEl.contains(target)` + `target.closest("[data-portal]")`) —
|
||||
`(внутри окна) → false`, `(в портале) → false`, `(на странице) → true`.
|
||||
- `isHeaderClick(dx, dy, threshold=4): boolean` — порог клик-vs-драг.
|
||||
- Компонентный тест (`@testing-library/react`): открыть окно → диспатчить
|
||||
`mousedown` по `document.body` → окно получает класс `.minimized`; клик по
|
||||
`.dragBar` (без движения) в свёрнутом виде → класс снят. Проверить, что
|
||||
`mousedown` по узлу с `data-portal` сворота не вызывает.
|
||||
- Прогнать `pnpm --filter client lint` и `pnpm --filter client test`.
|
||||
|
||||
## Файлы к изменению
|
||||
|
||||
- `apps/client/src/features/ai-chat/components/ai-chat-window.tsx`
|
||||
— внешний `mousedown`-эффект (Часть 1); доработка `startDrag` + `minimizedRef`
|
||||
(Часть 2); опц. `setMinimized(false)` при открытии; a11y-атрибуты на `.dragBar`.
|
||||
- `apps/client/src/features/ai-chat/components/ai-chat-window.module.css`
|
||||
— опц. `.minimized .dragBar { cursor: pointer; }`.
|
||||
- `apps/client/public/locales/en-US/translation.json` — ключ `"Expand"` (если
|
||||
добавляем aria/title).
|
||||
|
||||
## Альтернативы / расширения (вне базового объёма)
|
||||
|
||||
- **`useClickOutside` из `@mantine/hooks`** вместо ручного слушателя. Минус:
|
||||
порталы дочерних меню/модалок нужно явно передавать как `nodes` для игнора, а
|
||||
они создаются динамически — ручной гард `[data-portal]` проще и надёжнее.
|
||||
Поэтому ручной слушатель предпочтительнее.
|
||||
- **Учитывать клавиатурный фокус** (`focusin`) дополнительно к `mousedown` — если
|
||||
захотим сворачивать и при Tab в редактор.
|
||||
- **Не сворачивать во время стрима** — если решим, что во время генерации окно
|
||||
должно оставаться раскрытым (противоречит идее «ушёл читать страницу», поэтому
|
||||
по умолчанию сворачиваем всегда).
|
||||
- **Анимация коллапса/разворота** (height/opacity transition) — косметика, можно
|
||||
добавить позже в `.window`/`.content`.
|
||||
|
||||
## Принятые решения (базовый объём)
|
||||
|
||||
- **Триггер авто-сворота — только клик** (`mousedown` в capture-фазе).
|
||||
`focusin` не добавляем: запрос — про переключение на страницу кликом, а клик по
|
||||
не-фокусируемому контенту (ячейка таблицы) фокус-событие не даёт.
|
||||
- **При каждом открытии окна показываем его развёрнутым** —
|
||||
`setMinimized(false)` на переход `windowOpen → true`. Свёрнутое состояние не
|
||||
«залипает» между сессиями открытия.
|
||||
- **Во время стрима сворачиваем как обычно.** Поток не прерывается (`ChatThread`
|
||||
остаётся смонтированным), результат виден после разворота — это и есть смысл
|
||||
«ушёл читать страницу».
|
||||
- **Клавиатурный разворот шапки входит в базовый объём** — в свёрнутом виде
|
||||
`.dragBar` получает `role="button"`, `tabIndex={0}`, `aria-label={t("Expand")}`
|
||||
и обработку Enter/Space. Доступность без мыши обязательна.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Хрупкая передача «текущей страницы» в AI-агента
|
||||
|
||||
Контекст: агент не понимает «эта/текущая страница». В разговоре через
|
||||
CLIProxyAPI он отвечает «я не вижу текущую страницу» и просит уточнить
|
||||
id/название. Пользователь сообщает: **без CLIProxyAPI (прямой эндпоинт)
|
||||
работает**. То есть проблема воспроизводится на прокси-пути, но сама
|
||||
механика передачи страницы хрупкая по двум независимым причинам (см. ниже),
|
||||
поэтому фиксируем в беклоге целиком.
|
||||
|
||||
## Как сейчас инжектится текущая страница (цепочка)
|
||||
|
||||
Страница передаётся **только текстом в системный промпт** — отдельной
|
||||
строкой. Это единственная точка, где агент узнаёт pageId «этой страницы».
|
||||
Нет ни инструмента «get current page», ни поля в user-сообщении.
|
||||
|
||||
1. Клиент вычисляет `openPage` из роута:
|
||||
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx:124-131`
|
||||
— `const { pageSlug } = useParams();` →
|
||||
`usePageQuery({ pageId: extractPageSlugId(pageSlug) })` →
|
||||
`openPage = openPageData ? { id, title } : null`. Передаётся в `ChatThread`
|
||||
(`:391`).
|
||||
2. Транспорт кладёт `openPage` в тело запроса:
|
||||
`apps/client/src/features/ai-chat/components/chat-thread.tsx:107-127`
|
||||
(`prepareSendMessagesRequest`, поле на `:121`), POST `/api/ai-chat/stream`.
|
||||
3. Контроллер читает тело СЫРЫМ (намеренно без DTO, чтобы глобальный
|
||||
`ValidationPipe { whitelist: true }` не выкинул незадекларированное поле):
|
||||
`apps/server/src/core/ai-chat/ai-chat.controller.ts:103-135`
|
||||
(`const body = (req.body ?? {}) as AiChatStreamBody;`).
|
||||
4. Сервис прокидывает `body.openPage` → `openedPage`:
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts:146-149`
|
||||
(тип поля — `:32`, `openPage?: { id?; title? } | null`).
|
||||
5. `buildSystemPrompt` дописывает строку контекста в системный промпт:
|
||||
`apps/server/src/core/ai-chat/ai-chat.prompt.ts:94-101`
|
||||
— `The user is currently viewing the page "<title>" (pageId: <id>)...`.
|
||||
Добавляется в секцию контекста (после persona, ПЕРЕД safety-framework).
|
||||
6. Уходит как роль `system` в `streamText({ system, ... })`:
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts:237-239`
|
||||
на OpenAI-совместимый `/chat/completions` по настроенному `baseURL`
|
||||
(это и есть CLIProxyAPI):
|
||||
`apps/server/src/integrations/ai/ai.service.ts:46-52`
|
||||
(`createOpenAI({ apiKey, baseURL }).chat(model)`).
|
||||
|
||||
## Хрупкость №1 — клиентская: openPage по исходнику всегда null
|
||||
|
||||
`AiChatWindow` примонтирован в глобальной оболочке:
|
||||
`apps/client/src/components/layouts/global/global-app-shell.tsx:159`,
|
||||
которую рендерит `Layout` (`apps/client/src/components/layouts/global/layout.tsx:7-19`).
|
||||
`Layout` — это **pathless родительский layout-роут**
|
||||
(`<Route element={<Layout/>}>` без своего пути), а сегмент `:pageSlug`
|
||||
матчится только дочерним роутом `/s/:spaceSlug/p/:pageSlug` → `<Page/>`
|
||||
(`apps/client/src/App.tsx:56-66`).
|
||||
|
||||
В react-router-dom@7.13.1 `useParams()` возвращает
|
||||
`matches[matches.length-1].params` (проверено в исходнике
|
||||
`node_modules/react-router/dist/development/chunk-XOLAXE2Z.js:6891-6895`).
|
||||
На уровне шелла последний матч — это pathless `Layout` (params `{}`),
|
||||
параметры дочернего роута через `<Outlet/>` родителю НЕ видны. Значит в
|
||||
`AiChatWindow` `pageSlug === undefined` → `extractPageSlugId(undefined)`
|
||||
возвращает `undefined` (`apps/client/src/lib/utils.tsx:14-23`) →
|
||||
`usePageQuery` отключён (`enabled: !!pageInput.pageId`,
|
||||
`apps/client/src/features/page/queries/page-query.ts:44-52`) →
|
||||
`openPage = null`.
|
||||
|
||||
Ловушка — комментарий «same source the breadcrumb uses». Хлебные крошки
|
||||
используют ТОТ ЖЕ `useParams()` (`apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx:37`)
|
||||
и работают — но лишь потому, что рендерятся ВНУТРИ `<Page/>` (дочерний роут,
|
||||
где `:pageSlug` уже заматчен). Один хук, разная глубина в дереве → разный
|
||||
результат.
|
||||
|
||||
Косвенное подтверждение того же антипаттерна рядом: `Layout` тоже делает
|
||||
`const { spaceSlug } = useParams()` (`layout.tsx:8`) и тоже получает
|
||||
`undefined` → `SearchSpotlight` получает `spaceId={undefined}` и тихо
|
||||
работает без привязки к спейсу. Никем не замечено, потому что некритично.
|
||||
|
||||
**ПРОТИВОРЕЧИЕ, которое надо разрешить перед фиксом:** по исходнику
|
||||
`openPage` должен быть `null` В ОБОИХ режимах (и через прокси, и напрямую),
|
||||
а пользователь говорит, что напрямую РАБОТАЕТ. Значит либо рантайм/сборка
|
||||
расходится с рабочим деревом, либо страница доезжает иным путём. Проверить
|
||||
фактом (см. открытые вопросы) ДО того, как чинить клиент.
|
||||
|
||||
## Хрупкость №2 — прокси: контекст живёт только в system-сообщении
|
||||
|
||||
Поскольку pageId передаётся ТОЛЬКО строкой в роли `system`, любой прокси,
|
||||
который переписывает/дополняет системный промпт, может её потерять или
|
||||
«утопить». gitmost формирует `system` одинаково независимо от эндпоинта —
|
||||
строка идентична для direct и для прокси. Значит если напрямую работает, а
|
||||
через CLIProxyAPI нет, расхождение возникает ВНУТРИ трансляции прокси
|
||||
(CLIProxyAPI оборачивает CLI-бэкенды — Gemini CLI / Claude Code / Codex /
|
||||
Qwen — у которых свой объёмный системный промпт; наш system может быть
|
||||
склеен с их преамбулой, перенесён в `systemInstruction`, обрезан или
|
||||
недооценён моделью). Пользователь ранее отмечал «она вроде не стирает
|
||||
системный промпт, а просто дополняет» — это надо подтвердить захватом
|
||||
реального запроса.
|
||||
|
||||
## Открытые вопросы (проверить ДО реализации)
|
||||
|
||||
- [ ] Что реально уходит в `system`? Залогировать строку перед `streamText`
|
||||
(`ai-chat.service.ts:~237`) и сравнить direct vs proxy — строка должна
|
||||
быть БАЙТ-В-БАЙТ одинаковой.
|
||||
- [ ] Долетает ли `openPage` непустым до сервера? Залогировать `body.openPage`
|
||||
в `ai-chat.service.ts:~149` в обоих режимах. Если null даже на direct —
|
||||
проблема №1 реальна и для direct (тогда «работает» означало что-то иное).
|
||||
Если непустой — клиентская теория про `useParams` неверна для рантайма,
|
||||
надо понять почему (другая сборка? другой м压онт?).
|
||||
- [ ] Что CLIProxyAPI шлёт апстриму? Снять HTTP апстрим-запрос прокси
|
||||
(логи прокси / mitmproxy) — присутствует ли строка `pageId: ...` в
|
||||
системной инструкции, что отдаётся модели.
|
||||
|
||||
## Варианты фикса (выбрать после разрешения противоречия)
|
||||
|
||||
Клиентская часть (проблема №1), если подтвердится:
|
||||
- A. В `AiChatWindow` заменить `useParams()` на `useMatch("/s/:spaceSlug/p/:pageSlug")`
|
||||
или `matchPath` по `useLocation().pathname` — матчится по полному URL
|
||||
независимо от позиции в дереве. Минимально и точечно.
|
||||
- B. Завести jotai-атом текущей страницы, который выставляет `Page`
|
||||
(он внутри дочернего роута, видит params), и читать его в окне чата.
|
||||
Заодно чинит тот же баг в `Layout`/`SearchSpotlight`.
|
||||
|
||||
Прокси-устойчивость (проблема №2):
|
||||
- C. Дублировать контекст страницы НЕ только в system: добавить короткий
|
||||
скрытый префикс в user-сообщение, либо дать агенту инструмент
|
||||
`get_current_page` (берёт pageId из серверной сессии запроса), чтобы
|
||||
идентичность страницы не зависела от сохранности system-промпта прокси.
|
||||
- D. Если CLIProxyAPI обрезает/переносит system — настроить его так, чтобы
|
||||
наш system сохранялся (вне кода gitmost; задокументировать требование).
|
||||
|
||||
Рекомендация: сначала разрешить противоречие логами (дёшево), потом A или B
|
||||
для клиента + C для устойчивости к прокси (C — единственное, что реально
|
||||
лечит исходный симптом «через прокси не видит страницу»).
|
||||
@@ -1,165 +0,0 @@
|
||||
# Выбор agent role карточками в пустом окне чата (вместо выпадающего списка)
|
||||
|
||||
Контекст: при создании нового чата identity (agent role) выбирается из
|
||||
выпадающего списка Mantine `<Select>`. Просьба: заменить список на **карточки
|
||||
разных цветов с названием identity по центру пустого окна чата**. Клик по
|
||||
карточке применяет роль; если пользователь карточку не нажал и просто написал
|
||||
сообщение — срабатывает дефолтный Universal assistant.
|
||||
|
||||
Скриншот текущего поведения приложил пользователь: «Agent role» + раскрытый
|
||||
список (Universal assistant ✓, Пират, Дедушка).
|
||||
|
||||
## Как сейчас устроен выбор роли (цепочка)
|
||||
|
||||
1. Picker рисуется только для нового чата (`activeChatId === null`), когда есть
|
||||
включённые роли, как `<Select label="Agent role">`:
|
||||
`apps/client/src/features/ai-chat/components/ai-chat-window.tsx:543-561`.
|
||||
Значение `""` → «Universal assistant» (роль `null`); остальные опции —
|
||||
`enabledRoles` (эмодзи + имя).
|
||||
2. Список включённых ролей фильтруется клиентом из всех живых ролей:
|
||||
`ai-chat-window.tsx:144-147` (`enabledRoles = roles.filter(r => r.enabled)`).
|
||||
Источник — `useAiRolesQuery(windowOpen)`
|
||||
(`apps/client/src/features/ai-chat/queries/ai-chat-query.ts:131-137`).
|
||||
3. Выбранный id хранится в jotai-атоме:
|
||||
`apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts:23`
|
||||
(`selectedAiRoleIdAtom`, `null` = Universal assistant). Сбрасывается в `null`
|
||||
при «New chat»: `ai-chat-window.tsx:168-174` (`startNewChat`).
|
||||
4. Выбранный id прокидывается в тред и уходит в теле первого запроса:
|
||||
`ai-chat-window.tsx:570-578` (`roleId={activeChatId === null ? selectedRoleId : null}`)
|
||||
→ `apps/client/src/features/ai-chat/components/chat-thread.tsx:95-96, 128-138`
|
||||
(`roleIdRef` → `prepareSendMessagesRequest` кладёт `roleId` в body).
|
||||
Сервер учитывает `roleId` ТОЛЬКО при создании чата и фиксирует роль навсегда;
|
||||
для существующего чата роль читается из строки чата (бейдж в шапке окна:
|
||||
`ai-chat-window.tsx:433-440`).
|
||||
5. Пустая область чата сейчас — бледный текст по центру:
|
||||
`apps/client/src/features/ai-chat/components/message-list.tsx:130-140`
|
||||
(`<Center>` + `emptyState ?? t("Ask the AI agent anything...")`).
|
||||
Важно: `MessageList` УЖЕ принимает произвольный `emptyState: ReactNode`
|
||||
(`message-list.tsx:10-33, 64-70`) — этим пользуется публичный шэр.
|
||||
|
||||
Данные роли в picker-представлении (доступны не-админам):
|
||||
`id, name, emoji, description, enabled` —
|
||||
`apps/server/src/core/ai-chat/roles/ai-agent-roles.service.ts:35-41, 164-173`.
|
||||
То есть для карточек есть эмодзи и название (описание опционально).
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
- Вместо `<Select>` — карточки разных цветов по центру пустого окна чата.
|
||||
- Каждая карточка = identity (роль), отдельный цвет, по центру эмодзи + имя.
|
||||
- Отдельная карточка **Universal assistant** (дефолт), подсвечена по умолчанию.
|
||||
- Клик по карточке выбирает/применяет identity (визуальная подсветка выбранной).
|
||||
- Если ни одна карточка не нажата и пользователь отправил сообщение → роль `null`
|
||||
→ Universal assistant (текущая дефолтная ветка сервера).
|
||||
- После отправки первого сообщения карточки исчезают (чат больше не пуст).
|
||||
|
||||
## Ключевое архитектурное решение
|
||||
|
||||
Рисовать карточки **как empty-state** окна чата через уже существующий проп
|
||||
`emptyState` у `MessageList`, а НЕ отдельным блоком над полем ввода. Почему так:
|
||||
|
||||
- «посреди пустого окна чата» получается само: `MessageList` оборачивает
|
||||
`emptyState` в `<Center>` (`message-list.tsx:130-140`).
|
||||
- «не нажал и написал сообщение → дефолт» получается само: как только
|
||||
`messages.length > 0`, empty-state (и карточки) не рендерится, а
|
||||
`selectedRoleId` остаётся `null` → Universal assistant. Никакой логики
|
||||
«сбросить выбор при отправке» не нужно.
|
||||
- Состояние выбора остаётся в том же `selectedAiRoleIdAtom`, поэтому вся
|
||||
серверная обвязка (`roleId` в body, фиксация роли при создании чата) **не
|
||||
меняется** — изменения чисто фронтовые.
|
||||
|
||||
Поток: `AiChatWindow` собирает узел карточек → новый проп `emptyState` у
|
||||
`ChatThread` → форвард в `MessageList`.
|
||||
|
||||
## Состав изменений
|
||||
|
||||
1. **Новый компонент `role-cards.tsx`** (+ `role-cards.module.css`),
|
||||
`apps/client/src/features/ai-chat/components/`:
|
||||
- Пропсы: `roles: IAiRole[]`, `selectedRoleId: string | null`,
|
||||
`onSelect: (id: string | null) => void`.
|
||||
- Рендер: контейнер карточек с переносом (flex-wrap), по центру:
|
||||
- первая карточка — Universal assistant (значение `null`), нейтрально-серая,
|
||||
подсвечена когда `selectedRoleId === null`;
|
||||
- по карточке на каждую роль: цвет по индексу, по центру эмодзи (если есть)
|
||||
+ имя; подсвечена когда `selectedRoleId === r.id`.
|
||||
- Карточка — `UnstyledButton` (доступность + темизация Mantine). Клик →
|
||||
`onSelect(value)`. Выбранная — более яркий бордер/кольцо + галочка.
|
||||
- Цвета — фиксированная палитра имён Mantine, циклично по индексу:
|
||||
`blue, grape, teal, orange, pink, cyan, lime, indigo, red, violet`.
|
||||
Через theme-aware CSS-переменные (корректны и в светлой, и в тёмной теме):
|
||||
фон `var(--mantine-color-${c}-light)`, текст
|
||||
`var(--mantine-color-${c}-light-color)`, бордер выбранной
|
||||
`var(--mantine-color-${c}-filled)`. Universal — `gray`.
|
||||
- Раскладка (размер карточек ~100–130px, отступы, hover, кольцо выбора,
|
||||
прокрутка при большом числе ролей) — в CSS-модуле; цвет инжектится инлайн.
|
||||
|
||||
2. **`ai-chat-window.tsx`**:
|
||||
- Удалить блок `<Select>` (`:543-561`) и импорт `Select` (`:9`, используется
|
||||
только там — проверить, что `Group/Loader/Tooltip` остаются нужны).
|
||||
- Собрать узел карточек только когда `activeChatId === null &&
|
||||
enabledRoles.length > 0`, иначе `undefined`.
|
||||
- Передать его в `<ChatThread emptyState={...} />` (`:570-578`). Существующее
|
||||
`roleId={...}` без изменений.
|
||||
|
||||
3. **`chat-thread.tsx`**:
|
||||
- Добавить необязательный проп `emptyState?: ReactNode` (импорт `ReactNode`)
|
||||
и форварднуть в `<MessageList emptyState={...} />` (`:164`).
|
||||
|
||||
4. **`message-list.tsx`** — без изменений (проп `emptyState` уже поддержан).
|
||||
|
||||
Иллюстративный набросок (НЕ финальный код), `AiChatWindow`:
|
||||
|
||||
```tsx
|
||||
// Role cards become the empty-state ONLY for a brand-new chat that has roles.
|
||||
const roleCardsNode =
|
||||
activeChatId === null && enabledRoles.length > 0 ? (
|
||||
<RoleCards
|
||||
roles={enabledRoles}
|
||||
selectedRoleId={selectedRoleId}
|
||||
onSelect={setSelectedRoleId}
|
||||
/>
|
||||
) : undefined;
|
||||
// ...
|
||||
<ChatThread
|
||||
...
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
emptyState={roleCardsNode}
|
||||
/>
|
||||
```
|
||||
|
||||
## Краевые случаи
|
||||
|
||||
- **Нет включённых ролей** → карточки не показываем (`emptyState = undefined`),
|
||||
остаётся обычный дефолтный текст empty-state.
|
||||
- **Существующий чат** (`activeChatId !== null`) → карточек нет; роль уже
|
||||
зафиксирована и показана бейджем в шапке (`ai-chat-window.tsx:433-440`).
|
||||
- **Сброс выбора** при «New chat» уже делается (`setSelectedRoleId(null)`,
|
||||
`startNewChat`) — поведение сохраняется.
|
||||
- **Много ролей** → контейнер с переносом и прокруткой, чтобы не ломать пустую
|
||||
область чата.
|
||||
- **Тёмная тема** → за счёт `-light`/`-filled` переменных Mantine цвета
|
||||
корректны в обеих темах.
|
||||
- **Эмодзи нет** → карточка показывает только имя (как сейчас в `<Select>`:
|
||||
`r.emoji ? ... : ''`).
|
||||
|
||||
## Локализация
|
||||
|
||||
Новых ключей не требуется: переиспользуем существующие `t("Agent role")` и
|
||||
`t("Universal assistant")` (есть в `apps/client/public/locales/en-US/translation.json:1220-1221`;
|
||||
остальные локали падают на ключ — как сейчас у `<Select>`). Если решим добавить
|
||||
подпись-подсказку (например «или просто начните печатать») — это один новый ключ
|
||||
в `en-US/translation.json`; по умолчанию в объём не закладываю.
|
||||
|
||||
## Режим работы при реализации
|
||||
|
||||
Изменение нетривиальное (новый компонент + логика выбора/цветов + интеграция с
|
||||
empty-state), поэтому — делегирование кодеру с обязательным последующим ревью
|
||||
(`review` subagent), затем верификация перечитыванием файлов.
|
||||
|
||||
## Открытые вопросы (решить перед/во время реализации)
|
||||
|
||||
- [ ] Нужна ли карточка Universal assistant отдельной плиткой, или достаточно
|
||||
«ничего не выбрано = дефолт»? Предлагается отдельная карточка (явный
|
||||
возврат к дефолту после клика по роли) — подтвердить.
|
||||
- [ ] Показывать ли `description` роли на карточке (есть в picker-view) или
|
||||
только эмодзи + имя? По умолчанию — только эмодзи + имя, описание в `title`.
|
||||
- [ ] Нужна ли подпись-подсказка над карточками (тогда +1 ключ локали).
|
||||
33
docs/backlog/ai-chat-stream-integration-coverage.md
Normal file
33
docs/backlog/ai-chat-stream-integration-coverage.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Отложенные интеграционные тесты `AiChatService.stream`
|
||||
|
||||
Статус: **открыто.** Это остаток от прежнего документа
|
||||
`feature-test-coverage-deferred.md` (хвост тест-плана PR #49). Два из трёх
|
||||
его разделов уже закрыты новой интеграционной обвязкой против реального
|
||||
Postgres/Redis (`apps/server/test/integration/`, PR #115):
|
||||
|
||||
- ✅ **Раздел 1 — repo-тесты против БД.** Закрыт `ai-agent-roles-repo`,
|
||||
`ai-chat-repo-find-by-creator`, `page-template-references-cascade`,
|
||||
`workspace-repo-update-setting` (`*.int-spec.ts`).
|
||||
- ✅ **Раздел 2 — достоверность Lua-окна cost-cap против реального Redis.**
|
||||
Закрыт `public-share-workspace-limiter.int-spec.ts`.
|
||||
- ⬜ **Раздел 3 (ниже) — полная интеграция `AiChatService.stream`.** Всё ещё
|
||||
не реализован; держим запись открытой, чтобы тест-долг не потерялся при
|
||||
удалении исходного документа.
|
||||
|
||||
## Полная интеграция `AiChatService.stream` (рефактор R1-stream)
|
||||
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
|
||||
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
|
||||
сценарии всё ещё отложены:
|
||||
|
||||
- **Запись чата, упавшего на первом ходу** (`onError`) — ассистентская
|
||||
запись об ошибке должна сохраняться, даже когда первый ход стрима падает.
|
||||
- **Жизненный цикл external-MCP клиентов** — клиенты закрываются и при
|
||||
`throw`, и при `onFinish` (нет утечки соединений).
|
||||
- **Анти-tamper: история восстанавливается из БД, а не из `body.messages`** —
|
||||
клиент не может подменить историю через тело запроса.
|
||||
|
||||
Эти сценарии требуют сидирования SDK `streamText` (инъекция/seam колбэков
|
||||
`onError` / `onFinish` / `onAbort` + `res.hijack`). Отложено, чтобы не
|
||||
дестабилизировать 287-строчный `stream()`; делать вместе с выносом testable
|
||||
turn-pipeline.
|
||||
@@ -1,8 +1,26 @@
|
||||
# Дублирование определений инструментов: in-app агент vs standalone MCP-пакет
|
||||
|
||||
Статус: **зафиксировано в беклоге, код не менялся.** Это forward-looking
|
||||
стоимость поддержки, НЕ баг — код корректен сегодня. Фиксируем, чтобы при
|
||||
росте набора инструментов (см. §16) долг не разъезжался молча.
|
||||
Статус: **частично закрыто.** Квирк «node как объект ИЛИ JSON-строка» вынесен
|
||||
в общий хелпер `parseNodeArg` (см. «Прогресс» ниже); остальной долг (единый
|
||||
реестр спеков + унификация конвертера) всё ещё открыт. Это forward-looking
|
||||
стоимость поддержки, НЕ баг — код корректен сегодня. Держим запись открытой,
|
||||
чтобы при росте набора инструментов долг не разъезжался молча.
|
||||
|
||||
## Прогресс
|
||||
|
||||
- ✅ **Квирк node-arg вынесен в хелпер** (`refactor/ai-chat-tool-spec-registry`,
|
||||
PR #114). Шесть рукописных копий нормализации «node как объект ИЛИ
|
||||
JSON-строка» свёрнуты в `parseNodeArg`: по одному источнику на пакет —
|
||||
`packages/mcp/src/lib/parse-node-arg.ts` (standalone) и
|
||||
`apps/server/src/core/ai-chat/tools/parse-node-arg.ts` (in-app). Две копии
|
||||
намеренны (ESM/CJS-граница), поведение тождественно.
|
||||
- ⏳ **Единый реестр спеков** (схема + описание на инструмент) и **вывод
|
||||
`DocmostClientLike` из реального типа** — отложены (см. «Фикс»): требуют
|
||||
пересечения ESM/CJS-границы для данных+zod и ломают тест-стабы in-app
|
||||
инструментов при точных типах. Делать инкрементально.
|
||||
- ⏳ **Унификация конвертера ProseMirror ↔ Markdown** — открыта (см. раздел
|
||||
«Расширение …» ниже); на неё опирается план git-синка
|
||||
(`docs/git-sync-plan.md`).
|
||||
|
||||
## Суть
|
||||
|
||||
@@ -28,12 +46,13 @@ parity-баги (расхождение копий) приходится чин
|
||||
## Что именно продублировано (с подтверждением по коду)
|
||||
|
||||
- **zod-схема + описание** каждого инструмента — в слоях 1 и 2 целиком.
|
||||
- **Квирк «node как объект ИЛИ JSON-строка»** реализован дважды (НЕ в общем
|
||||
клиенте):
|
||||
- in-app: `ai-chat-tools.service.ts:686` (patchNode), `:745` (insertNode),
|
||||
`:800` (updatePageJson);
|
||||
- standalone: `index.ts:526` (patch_node), `:578` (insert_node), `:350`
|
||||
(update_page_json).
|
||||
- ~~**Квирк «node как объект ИЛИ JSON-строка»** реализован дважды (НЕ в общем
|
||||
клиенте)~~ — **закрыто (PR #114):** вынесен в `parseNodeArg` (по хелперу на
|
||||
пакет), 6 inline-копий устранены:
|
||||
- in-app: `patchNode`, `insertNode`, `updatePageJson` →
|
||||
`apps/server/src/core/ai-chat/tools/parse-node-arg.ts`;
|
||||
- standalone: `patch_node`, `insert_node`, `update_page_json` →
|
||||
`packages/mcp/src/lib/parse-node-arg.ts`.
|
||||
- **Guardrail/семантика `transformPage` (dryRun)** описана в обоих:
|
||||
`ai-chat-tools.service.ts:~935` и `index.ts:~1006`.
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
|
||||
|
||||
## Контекст
|
||||
|
||||
PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
|
||||
основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
|
||||
client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
|
||||
тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
|
||||
editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
|
||||
|
||||
Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
|
||||
тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
|
||||
сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
|
||||
почему, чтобы не потерять.
|
||||
|
||||
---
|
||||
|
||||
## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
|
||||
|
||||
Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
|
||||
исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
|
||||
(testcontainers или сервис в pipeline) + хелпер миграций.
|
||||
|
||||
- **`AiAgentRoleRepo` — изоляция и индексы.**
|
||||
`apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
|
||||
Проверить против реальной БД: `findById`/`listByWorkspace` исключают
|
||||
soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
|
||||
(tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
|
||||
переиспользуемо после softDelete (partial unique index
|
||||
`WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
|
||||
одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
|
||||
сейчас только предполагается unit-моками.
|
||||
|
||||
- **`AiChatRepo.findByCreator` — join role-badge.**
|
||||
`apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
|
||||
Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
|
||||
бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
|
||||
`resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
|
||||
ambiguous column после join). Не проверяемо чистым unit-ом.
|
||||
|
||||
- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
|
||||
`apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
|
||||
`apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
|
||||
Сейчас покрыта только форма вызова сервиса
|
||||
(`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
|
||||
персистится через jsonb-merge **не затирая** соседние настройки (ai,
|
||||
sharing). Это и есть «kill-switch пишется» — критично, что write-половина
|
||||
тоггла не ломает остальной settings-namespace.
|
||||
|
||||
- **FK `page_template_references` onDelete('cascade').**
|
||||
Миграция `20260620T131000-page-template-references.ts`. Проверить, что
|
||||
удаление source/reference-страницы каскадит строки ссылок.
|
||||
|
||||
## 2. HTTP / e2e-харнес (его нет в apps/server)
|
||||
|
||||
- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
|
||||
per-workspace cap держит.**
|
||||
Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
|
||||
(`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
|
||||
из Fastify `trustProxy` → `X-Forwarded-For`. Единственный оправданный e2e
|
||||
(named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
|
||||
лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
|
||||
поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
|
||||
|
||||
- **Достоверность Lua-окна cost-cap против реального Redis.**
|
||||
`apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
|
||||
(`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
|
||||
`FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
|
||||
(`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
|
||||
против реального/testcontainers Redis.
|
||||
|
||||
## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
|
||||
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
|
||||
покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
|
||||
сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
|
||||
цикл external-MCP клиентов (закрытие при throw/onFinish), и
|
||||
**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
|
||||
требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
|
||||
`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
|
||||
287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Сопутствующие НЕ-тестовые находки
|
||||
|
||||
Вынесены в отдельные issues (всплыли во время написания тестов):
|
||||
|
||||
- #52 — ai-roles: нет серверной валидации модели роли + дрейф enum драйверов.
|
||||
- #53 — ws: `invalidateSpaceRestrictionCache` без вызывающих (30с stale-окно).
|
||||
- #54 — page-embed: серверный guard глубины/циклов раскрытия.
|
||||
- #55 — transclusion: cycle-guard в `collectPageEmbedsFromPmJson`.
|
||||
- #56 — test-infra: jest DI + lib0 ESM (16 падающих сьютов).
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { DocmostClient } from "./client.js";
|
||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
||||
// directly — for the credentials variant OR the per-user getToken variant.
|
||||
@@ -245,16 +246,9 @@ export function createDocmostMcpServer(config) {
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
}
|
||||
else if (typeof content === "string") {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
}
|
||||
catch {
|
||||
throw new Error("content was a string but not valid JSON");
|
||||
}
|
||||
}
|
||||
else {
|
||||
doc = content;
|
||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
||||
doc = parseNodeArg(content, "content was a string but not valid JSON");
|
||||
}
|
||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
||||
return jsonContent(result);
|
||||
@@ -379,18 +373,7 @@ export function createDocmostMcpServer(config) {
|
||||
"JSON object or JSON string both accepted."),
|
||||
},
|
||||
}, async ({ pageId, nodeId, node }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
}
|
||||
catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
}
|
||||
else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
return jsonContent(result);
|
||||
});
|
||||
@@ -425,18 +408,7 @@ export function createDocmostMcpServer(config) {
|
||||
anchorText: z.string().optional(),
|
||||
},
|
||||
}, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
}
|
||||
catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
}
|
||||
else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
|
||||
15
packages/mcp/build/lib/parse-node-arg.js
Normal file
15
packages/mcp/build/lib/parse-node-arg.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
||||
// insert_node (and the analogous update_page_json content parsing).
|
||||
export function parseNodeArg(node, errMsg = "node was a string but not valid JSON") {
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
return JSON.parse(node);
|
||||
}
|
||||
catch {
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||
|
||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
||||
@@ -354,14 +355,9 @@ server.registerTool(
|
||||
let doc;
|
||||
if (content === undefined || content === null) {
|
||||
doc = undefined;
|
||||
} else if (typeof content === "string") {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
} catch {
|
||||
throw new Error("content was a string but not valid JSON");
|
||||
}
|
||||
} else {
|
||||
doc = content;
|
||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
||||
doc = parseNodeArg(content, "content was a string but not valid JSON");
|
||||
}
|
||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
||||
return jsonContent(result);
|
||||
@@ -529,16 +525,7 @@ server.registerTool(
|
||||
},
|
||||
},
|
||||
async ({ pageId, nodeId, node }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
} else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
return jsonContent(result);
|
||||
},
|
||||
@@ -581,16 +568,7 @@ server.registerTool(
|
||||
},
|
||||
},
|
||||
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
} else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
|
||||
17
packages/mcp/src/lib/parse-node-arg.ts
Normal file
17
packages/mcp/src/lib/parse-node-arg.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
||||
// insert_node (and the analogous update_page_json content parsing).
|
||||
export function parseNodeArg(
|
||||
node: unknown,
|
||||
errMsg = "node was a string but not valid JSON",
|
||||
): unknown {
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
return JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
32
packages/mcp/test/unit/parse-node-arg.test.mjs
Normal file
32
packages/mcp/test/unit/parse-node-arg.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseNodeArg } from "../../build/lib/parse-node-arg.js";
|
||||
|
||||
test("parseNodeArg passes an object through unchanged", () => {
|
||||
const obj = { type: "paragraph", content: [] };
|
||||
assert.strictEqual(parseNodeArg(obj), obj);
|
||||
});
|
||||
|
||||
test("parseNodeArg passes undefined/null through unchanged", () => {
|
||||
assert.strictEqual(parseNodeArg(undefined), undefined);
|
||||
assert.strictEqual(parseNodeArg(null), null);
|
||||
});
|
||||
|
||||
test("parseNodeArg parses a valid JSON string", () => {
|
||||
const parsed = parseNodeArg('{"type":"paragraph"}');
|
||||
assert.deepStrictEqual(parsed, { type: "paragraph" });
|
||||
});
|
||||
|
||||
test("parseNodeArg throws the default message on invalid JSON string", () => {
|
||||
assert.throws(() => parseNodeArg("{not json"), {
|
||||
message: "node was a string but not valid JSON",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseNodeArg throws a custom message on invalid JSON string", () => {
|
||||
assert.throws(
|
||||
() => parseNodeArg("{not json", "content was a string but not valid JSON"),
|
||||
{ message: "content was a string but not valid JSON" },
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user