Merge branch 'develop' into feat/ai-chat-review-followups
Integrate the already-merged step-limit work from develop. Only conflict was ai-chat.service.spec.ts: both sides appended a describe block and edited the import line. Resolved as a union — keep compactToolOutput + the assistantParts/ serializeSteps/rowToUiMessage suites (this branch) AND the prepareAgentStep suite (develop), importing all symbols from ai-chat.service. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -977,6 +977,9 @@
|
||||
"Page menu": "Page menu",
|
||||
"Expand": "Expand",
|
||||
"Collapse": "Collapse",
|
||||
"Expand all": "Expand all",
|
||||
"Collapse all": "Collapse all",
|
||||
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
|
||||
"Comment menu": "Comment menu",
|
||||
"Group menu": "Group menu",
|
||||
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
|
||||
@@ -1162,6 +1165,10 @@
|
||||
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||
"Test endpoint": "Test endpoint",
|
||||
"Save endpoints": "Save endpoints",
|
||||
"Configured and enabled": "Configured and enabled",
|
||||
"Configured but disabled": "Configured but disabled",
|
||||
"Enabled but not configured": "Enabled but not configured",
|
||||
"Not configured": "Not configured",
|
||||
"External tools": "External tools",
|
||||
"Gitmost as MCP client": "Gitmost as MCP client",
|
||||
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
||||
|
||||
@@ -116,8 +116,8 @@ function CommentListItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb="xs">
|
||||
<Group>
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={comment.creator.avatarUrl}
|
||||
@@ -126,7 +126,7 @@ function CommentListItem({
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={500} lineClamp={1}>
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{comment.creator.name}
|
||||
</Text>
|
||||
|
||||
@@ -177,7 +177,7 @@ function CommentListItem({
|
||||
tabIndex={0}
|
||||
aria-label={t("Jump to comment selection")}
|
||||
>
|
||||
<Text size="sm">{comment?.selection}</Text>
|
||||
<Text size="xs">{comment?.selection}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -121,8 +121,8 @@ function CommentListWithTabs() {
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="sm"
|
||||
mb="sm"
|
||||
p="xs"
|
||||
mb="xs"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
@@ -145,7 +145,7 @@ function CommentListWithTabs() {
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<Divider my={2} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
.wrapper {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.focused-thread {
|
||||
border: 2px solid #8d7249;
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
padding: 8px;
|
||||
padding: 6px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
@@ -32,6 +28,9 @@
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* Denser comments: override the global 16px ProseMirror body size with 14px
|
||||
and tighten the rhythm vs. the comment header. Scoped to the comment
|
||||
editor only - the page editor is unaffected. */
|
||||
.ProseMirror :global(.ProseMirror){
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
@@ -39,7 +38,9 @@
|
||||
word-break: break-word;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@ export async function getAllSidebarPages(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSpaceTree(params: {
|
||||
spaceId: string;
|
||||
pageId?: string;
|
||||
}): Promise<IPage[]> {
|
||||
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||
return req.data.items;
|
||||
}
|
||||
|
||||
export async function getPageBreadcrumbs(
|
||||
pageId: string,
|
||||
): Promise<Partial<IPage[]>> {
|
||||
|
||||
@@ -16,6 +16,11 @@ import { treeModel } from '../model/tree-model';
|
||||
import { DocTreeRow } from './doc-tree-row';
|
||||
import styles from '../styles/tree.module.css';
|
||||
|
||||
// Page-tree row heights. STANDARD is the safe default density; COMPACT is the
|
||||
// denser layout gated behind the COMPACT_PAGE_TREE feature flag.
|
||||
export const ROW_HEIGHT_STANDARD = 32;
|
||||
export const ROW_HEIGHT_COMPACT = 26;
|
||||
|
||||
export type RenderRowProps<T extends object> = {
|
||||
node: TreeNode<T>;
|
||||
level: number;
|
||||
@@ -122,11 +127,11 @@ function DocTreeInner<T extends object>(
|
||||
selectedId,
|
||||
renderRow,
|
||||
indentPerLevel = 8,
|
||||
// Compact vertical density: each virtualized row occupies exactly this
|
||||
// many px (the virtualizer stride). Row content is ~22px (18px icon /
|
||||
// 14px text / 20px action icons), so 26px keeps a small, even gap between
|
||||
// nodes without clipping. Lower => denser tree.
|
||||
rowHeight = 26,
|
||||
// Each virtualized row occupies exactly this many px (the virtualizer
|
||||
// stride). Default is standard density (32px); the denser compact layout
|
||||
// (26px) is opt-in and driven by the COMPACT_PAGE_TREE feature flag in
|
||||
// consumers. Lower => denser tree.
|
||||
rowHeight = ROW_HEIGHT_STANDARD,
|
||||
onMove,
|
||||
onToggle,
|
||||
onSelect,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
fetchAllAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
@@ -16,13 +25,23 @@ import {
|
||||
buildTree,
|
||||
buildTreeWithChildren,
|
||||
mergeRootTrees,
|
||||
collectAllIds,
|
||||
collectBranchIds,
|
||||
} from "@/features/page/tree/utils/utils.ts";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
|
||||
import {
|
||||
getPageBreadcrumbs,
|
||||
getSpaceTree,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { DocTree } from "./doc-tree";
|
||||
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
|
||||
import {
|
||||
DocTree,
|
||||
ROW_HEIGHT_COMPACT,
|
||||
ROW_HEIGHT_STANDARD,
|
||||
} from "./doc-tree";
|
||||
import { SpaceTreeRow } from "./space-tree-row";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
@@ -30,10 +49,21 @@ interface SpaceTreeProps {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
export type SpaceTreeApi = {
|
||||
expandAll: () => Promise<void>;
|
||||
collapseAll: () => void;
|
||||
isExpanding: boolean;
|
||||
};
|
||||
|
||||
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
{ spaceId, readOnly },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const compactTree = isCompactPageTreeEnabled();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const [isExpanding, setIsExpanding] = useState(false);
|
||||
const { handleMove } = useTreeMutation(spaceId);
|
||||
const {
|
||||
data: pagesData,
|
||||
@@ -186,6 +216,64 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
[data, spaceId],
|
||||
);
|
||||
|
||||
const expandAll = useCallback(async () => {
|
||||
const startSpaceId = spaceIdRef.current;
|
||||
setIsExpanding(true);
|
||||
try {
|
||||
// One request: the entire space tree, permission-filtered server-side.
|
||||
const items = await getSpaceTree({ spaceId: startSpaceId });
|
||||
// Space switched mid-flight — abort merge/expand.
|
||||
if (spaceIdRef.current !== startSpaceId) return;
|
||||
|
||||
const fullTree = buildTreeWithChildren(buildTree(items));
|
||||
|
||||
setData((prev) => {
|
||||
// Replace current-space nodes with the full tree; keep other spaces intact.
|
||||
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
|
||||
return [...others, ...fullTree];
|
||||
});
|
||||
|
||||
// Open every branch node (node with children) of the current space only.
|
||||
const branchIds = collectBranchIds(fullTree);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of branchIds) next[id] = true;
|
||||
return next;
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Never swallow: log full error + surface the real reason.
|
||||
console.error("[tree] expandAll failed", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Couldn't expand the tree: {{reason}}", {
|
||||
reason:
|
||||
err?.response?.data?.message ?? err?.message ?? String(err),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
setIsExpanding(false);
|
||||
}
|
||||
}, [setData, setOpenTreeNodes, t]);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
// The open-map is shared across spaces; collapse only current-space ids so
|
||||
// other spaces' expanded state is left intact.
|
||||
const ids = collectAllIds(filteredData);
|
||||
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
});
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({ expandAll, collapseAll, isExpanding }),
|
||||
[expandAll, collapseAll, isExpanding],
|
||||
);
|
||||
|
||||
// Stable callbacks for DocTree. Without these, every parent render recreates
|
||||
// the props and tears down every row's draggable/dropTarget subscription,
|
||||
// defeating memo(DocTreeRow).
|
||||
@@ -219,6 +307,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
renderRow={renderRow}
|
||||
onMove={handleMove}
|
||||
onToggle={handleToggle}
|
||||
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
|
||||
readOnly={readOnly}
|
||||
disableDrag={disableDragDrop}
|
||||
disableDrop={disableDragDrop}
|
||||
@@ -228,4 +317,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default SpaceTree;
|
||||
|
||||
@@ -216,3 +216,33 @@ export function mergeRootTrees(
|
||||
|
||||
return sortPositionKeys(merged);
|
||||
}
|
||||
|
||||
// Collect every node id in the tree (roots, branches, leaves). Used by
|
||||
// collapseAll to clear the open-state map for all current-space nodes.
|
||||
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: SpaceTreeNode[]) => {
|
||||
for (const n of list) {
|
||||
ids.push(n.id);
|
||||
if (n.children?.length) walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
|
||||
// open every branch in the open-state map; leaves need no entry.
|
||||
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: SpaceTreeNode[]) => {
|
||||
for (const n of list) {
|
||||
if (n.children?.length) {
|
||||
ids.push(n.id);
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
DocTree,
|
||||
type DocTreeApi,
|
||||
type RenderRowProps,
|
||||
ROW_HEIGHT_COMPACT,
|
||||
ROW_HEIGHT_STANDARD,
|
||||
} from "@/features/page/tree/components/doc-tree";
|
||||
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
|
||||
import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom";
|
||||
|
||||
interface SharedTreeProps {
|
||||
@@ -36,6 +39,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
const treeRef = useRef<DocTreeApi | null>(null);
|
||||
const { pageSlug } = useParams();
|
||||
const compactTree = isCompactPageTreeEnabled();
|
||||
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
|
||||
|
||||
const currentNodeId = extractPageSlugId(pageSlug);
|
||||
@@ -100,6 +104,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
|
||||
renderRow={SharedTreeRow}
|
||||
onMove={noopMove}
|
||||
onToggle={handleToggle}
|
||||
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
|
||||
getDragLabel={getDragLabel}
|
||||
aria-label={t("Pages")}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -23,14 +25,16 @@ import {
|
||||
useUnwatchSpaceMutation,
|
||||
} from "@/features/space/queries/space-watcher-query.ts";
|
||||
import classes from "./space-sidebar.module.css";
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||
import SpaceTree, {
|
||||
SpaceTreeApi,
|
||||
} from "@/features/page/tree/components/space-tree.tsx";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
@@ -57,6 +61,7 @@ export function SpaceSidebar() {
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useSpaceAbility(spaceRules);
|
||||
const { handleCreate } = useTreeMutation(space?.id ?? "");
|
||||
const treeRef = useRef<SpaceTreeApi | null>(null);
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
@@ -100,6 +105,7 @@ export function SpaceSidebar() {
|
||||
SpaceCaslSubject.Page,
|
||||
)}
|
||||
onSpaceSettings={openSettings}
|
||||
treeRef={treeRef}
|
||||
/>
|
||||
|
||||
{spaceAbility.can(
|
||||
@@ -122,6 +128,7 @@ export function SpaceSidebar() {
|
||||
|
||||
<div className={classes.pages}>
|
||||
<SpaceTree
|
||||
ref={treeRef}
|
||||
spaceId={space.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
@@ -145,13 +152,25 @@ interface SpaceMenuProps {
|
||||
spaceId: string;
|
||||
canManagePages: boolean;
|
||||
onSpaceSettings: () => void;
|
||||
treeRef: React.RefObject<SpaceTreeApi | null>;
|
||||
}
|
||||
function SpaceMenu({
|
||||
spaceId,
|
||||
canManagePages,
|
||||
onSpaceSettings,
|
||||
treeRef,
|
||||
}: SpaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleExpandAll = () => {
|
||||
// Fire-and-forget: expandAll already surfaces its own error notification.
|
||||
// The menu closes on click (consistent with Collapse all), so there is no
|
||||
// in-menu loading state to track here.
|
||||
treeRef.current?.expandAll();
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
treeRef.current?.collapseAll();
|
||||
};
|
||||
const { spaceSlug } = useParams();
|
||||
const [importOpened, { open: openImportModal, close: closeImportModal }] =
|
||||
useDisclosure(false);
|
||||
@@ -201,6 +220,22 @@ function SpaceMenu({
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleExpandAll}
|
||||
leftSection={<IconChevronsDown size={16} />}
|
||||
>
|
||||
{t("Expand all")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleCollapseAll}
|
||||
leftSection={<IconChevronsUp size={16} />}
|
||||
>
|
||||
{t("Collapse all")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleToggleFavorite}
|
||||
leftSection={
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveCardStatus } from './ai-provider-settings';
|
||||
|
||||
describe('resolveCardStatus', () => {
|
||||
it('returns "off" when not configured and not enabled', () => {
|
||||
expect(resolveCardStatus(false, false)).toBe('off');
|
||||
});
|
||||
|
||||
it('returns "warning" when enabled but not configured (misconfig, not silent "off")', () => {
|
||||
expect(resolveCardStatus(false, true)).toBe('warning');
|
||||
});
|
||||
|
||||
it('returns "configured" when configured but disabled', () => {
|
||||
expect(resolveCardStatus(true, false)).toBe('configured');
|
||||
});
|
||||
|
||||
it('returns "ready" when configured and enabled', () => {
|
||||
expect(resolveCardStatus(true, true)).toBe('ready');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Anchor,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -60,8 +61,15 @@ const formSchema = z.object({
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
// Status of an endpoint card, drives the little status dot color.
|
||||
type CardStatus = "ok" | "error" | "idle";
|
||||
// Four-state endpoint health shown by the header dot. Derived synchronously
|
||||
// from the form values + feature toggle — never from a network probe (the
|
||||
// "Test endpoint" button still surfaces the live probe result as text).
|
||||
// "ready" (green) — required fields filled AND the feature is ON
|
||||
// "configured"(yellow) — required fields filled but the feature is OFF
|
||||
// "off" (gray) — required fields missing (nothing to enable)
|
||||
// "warning" (orange) — feature is ON but required fields are missing
|
||||
// (a real misconfiguration: it won't work as-is)
|
||||
type CardStatus = "ready" | "configured" | "off" | "warning";
|
||||
|
||||
// Resolve a "Base URL + path" hint defensively: trim a single trailing slash
|
||||
// off the base, then append the path. Empty base falls back to `fallback`
|
||||
@@ -71,21 +79,53 @@ function resolveUrl(base: string, path: string, fallback = ""): string {
|
||||
return `${trimmed}${path}`;
|
||||
}
|
||||
|
||||
// Small colored dot used in each card header.
|
||||
function StatusDot({ status }: { status: CardStatus }) {
|
||||
// Pure + unit-testable. `configured` = the endpoint has the fields it needs
|
||||
// to work; `enabled` = the workspace feature toggle for this endpoint is ON.
|
||||
// The "enabled && !configured" case is surfaced as "warning" instead of "off"
|
||||
// so a misconfiguration (feature on, endpoint not filled) is not hidden.
|
||||
export function resolveCardStatus(
|
||||
configured: boolean,
|
||||
enabled: boolean,
|
||||
): CardStatus {
|
||||
if (configured) return enabled ? "ready" : "configured";
|
||||
return enabled ? "warning" : "off";
|
||||
}
|
||||
|
||||
// Translate the dot's tooltip label. Kept in one place so all three endpoint
|
||||
// cards share identical wording.
|
||||
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return t("Configured and enabled");
|
||||
case "configured":
|
||||
return t("Configured but disabled");
|
||||
case "warning":
|
||||
return t("Enabled but not configured");
|
||||
default:
|
||||
return t("Not configured");
|
||||
}
|
||||
}
|
||||
|
||||
// Small colored dot used in each card header, with a tooltip label so the
|
||||
// state is readable without relying on color alone (colorblind access).
|
||||
function StatusDot({ status, label }: { status: CardStatus; label: string }) {
|
||||
const theme = useMantineTheme();
|
||||
const color =
|
||||
status === "ok"
|
||||
status === "ready"
|
||||
? theme.colors.green[6]
|
||||
: status === "error"
|
||||
? theme.colors.red[6]
|
||||
: theme.colors.gray[5];
|
||||
: status === "configured"
|
||||
? theme.colors.yellow[6]
|
||||
: status === "warning"
|
||||
? theme.colors.orange[6]
|
||||
: theme.colors.gray[5];
|
||||
return (
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
<Tooltip label={label} position="top" withArrow>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -353,21 +393,23 @@ export default function AiProviderSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
const chatStatus: CardStatus = chatTest.data
|
||||
? chatTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
const embedStatus: CardStatus = embedTest.data
|
||||
? embedTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
const sttStatus: CardStatus = sttTest.data
|
||||
? sttTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
// Per-endpoint "configured" predicate, derived from the LIVE form values
|
||||
// (the dot reacts as the admin types). A key is NOT required — local
|
||||
// servers (Ollama, speaches) work without one. Embeddings and Voice
|
||||
// inherit the chat base URL when their own is empty (see resolveUrl).
|
||||
const v = form.values;
|
||||
const chatBase = v.baseUrl.trim();
|
||||
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
|
||||
const embedConfigured =
|
||||
v.embeddingModel.trim() !== "" &&
|
||||
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const sttConfigured =
|
||||
v.sttModel.trim() !== "" &&
|
||||
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
|
||||
|
||||
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
|
||||
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
|
||||
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
|
||||
|
||||
const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions");
|
||||
const embedResolved = resolveUrl(
|
||||
@@ -404,7 +446,7 @@ export default function AiProviderSettings() {
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={chatStatus} />
|
||||
<StatusDot status={chatStatus} label={cardStatusLabel(chatStatus, t)} />
|
||||
<Text fw={600}>{t("Chat / LLM")}</Text>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("root")}
|
||||
@@ -430,19 +472,34 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear
|
||||
action in the right section. Passing rightSection suppresses the eye
|
||||
(Mantine). While typing a new key (buffer non-empty) fall back to
|
||||
the default eye so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasApiKey && form.values.apiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -514,7 +571,7 @@ export default function AiProviderSettings() {
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={embedStatus} />
|
||||
<StatusDot status={embedStatus} label={cardStatusLabel(embedStatus, t)} />
|
||||
<Text fw={600}>{t("Embeddings")}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
@@ -535,29 +592,38 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("embeddingModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
{hasEmbeddingApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear
|
||||
action in the right section. Passing rightSection suppresses the eye
|
||||
(Mantine). While typing a new key (buffer non-empty) fall back to
|
||||
the default eye so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasEmbeddingApiKey && form.values.embeddingApiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -631,7 +697,7 @@ export default function AiProviderSettings() {
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={sttStatus} />
|
||||
<StatusDot status={sttStatus} label={cardStatusLabel(sttStatus, t)} />
|
||||
<Text fw={600}>{t("Voice / STT")}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
@@ -654,29 +720,38 @@ export default function AiProviderSettings() {
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("sttModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={
|
||||
hasSttApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("sttApiKey")}
|
||||
/>
|
||||
{hasSttApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearSttKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear
|
||||
action in the right section. Passing rightSection suppresses the eye
|
||||
(Mantine). While typing a new key (buffer non-empty) fall back to
|
||||
the default eye so the user can verify what they typed. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={
|
||||
hasSttApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasSttApiKey && form.values.sttApiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
type="button"
|
||||
onClick={handleClearSttKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("sttApiKey")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
|
||||
return castToBoolean(getConfigValue("CLOUD"));
|
||||
}
|
||||
|
||||
export function isCompactPageTreeEnabled(): boolean {
|
||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
assistantParts,
|
||||
serializeSteps,
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||
|
||||
@@ -190,3 +193,39 @@ describe('rowToUiMessage', () => {
|
||||
expect(ui.parts).toEqual([{ type: 'text', text: 'hi there' }]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for prepareAgentStep: the pure helper that decides per-step
|
||||
* overrides for the agent loop. Early steps return undefined (default
|
||||
* behavior); the final allowed step (stepNumber === MAX_AGENT_STEPS - 1) forces
|
||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||
* appended onto — not replacing — the original system prompt.
|
||||
*/
|
||||
describe('prepareAgentStep', () => {
|
||||
it('returns undefined for the first step', () => {
|
||||
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a non-final step (just before the last)', () => {
|
||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forces a text-only synthesis on the final allowed step', () => {
|
||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.toolChoice).toBe('none');
|
||||
// The original persona is preserved (prefix), not replaced.
|
||||
expect(result?.system.startsWith('SYS')).toBe(true);
|
||||
// The synthesis instruction is appended.
|
||||
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
});
|
||||
|
||||
it('pins the off-by-one boundary (MAX-2 is not final, MAX-1 is)', () => {
|
||||
// Boundary expressed via the constant, not a hardcoded 18/19, so the test
|
||||
// tracks MAX_AGENT_STEPS if the cap ever changes.
|
||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||
const atBoundary = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
||||
expect(atBoundary).toBeDefined();
|
||||
expect(atBoundary?.toolChoice).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,42 @@ import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||
|
||||
// Max agent steps per turn. One step = one model generation; a step that calls
|
||||
// tools is followed by another step carrying the tool results. Raised from 8 so
|
||||
// multi-search research questions are not cut off mid-investigation.
|
||||
const MAX_AGENT_STEPS = 20;
|
||||
|
||||
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
|
||||
// It forbids further tool calls and tells the model to synthesize the best
|
||||
// answer it can from what it already gathered, so a tool-heavy turn never ends
|
||||
// empty.
|
||||
const FINAL_STEP_INSTRUCTION =
|
||||
'You have reached the maximum number of tool-use steps for this turn. ' +
|
||||
'Do NOT call any more tools. Using only the information already gathered, ' +
|
||||
"write the most complete, useful final answer you can now, in the user's " +
|
||||
'language. If the information is incomplete, say so explicitly: summarize ' +
|
||||
'what you found, what is still missing, and give your best partial conclusion.';
|
||||
|
||||
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
||||
// steps; on the final allowed step forces a text-only synthesis answer.
|
||||
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
||||
// persona/context is preserved — a bare `system` override would REPLACE the
|
||||
// whole system prompt for the step.
|
||||
//
|
||||
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
||||
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
||||
export function prepareAgentStep(
|
||||
stepNumber: number,
|
||||
system: string,
|
||||
): { toolChoice: 'none'; system: string } | undefined {
|
||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||
return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
|
||||
|
||||
/**
|
||||
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
|
||||
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
|
||||
@@ -245,7 +281,13 @@ export class AiChatService {
|
||||
// cap would truncate complex tool calls mid-argument. Let the model use its
|
||||
// natural per-step budget. (Cost/credit limits are an account concern, not
|
||||
// something to enforce by silently breaking the agent.)
|
||||
stopWhen: stepCountIs(8),
|
||||
stopWhen: stepCountIs(MAX_AGENT_STEPS),
|
||||
// Forced finalization: reserve the LAST allowed step for a text-only
|
||||
// answer. Without this, a turn that spends all its steps on tool calls
|
||||
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
||||
// further tool calls and appends a synthesis instruction on that step,
|
||||
// concatenated onto the original `system` so the persona is preserved.
|
||||
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
|
||||
abortSignal: signal,
|
||||
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
|
||||
await persistAssistant({
|
||||
|
||||
@@ -578,6 +578,49 @@ export class PageController {
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/tree')
|
||||
async getPagesTree(
|
||||
@Body() dto: SidebarPageDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
if (!dto.spaceId && !dto.pageId) {
|
||||
throw new BadRequestException(
|
||||
'Either spaceId or pageId must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
let spaceId = dto.spaceId;
|
||||
|
||||
if (dto.pageId) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
spaceId = page.spaceId;
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const spaceCanEdit = ability.can(
|
||||
SpaceCaslAction.Edit,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
|
||||
const items = await this.pageService.getSidebarPagesTree(
|
||||
spaceId,
|
||||
user.id,
|
||||
spaceCanEdit,
|
||||
dto.pageId,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('move-to-space')
|
||||
async movePageToSpace(
|
||||
|
||||
@@ -1137,7 +1137,7 @@ export class PageService {
|
||||
T extends { id: string; parentPageId: string | null },
|
||||
>(
|
||||
pages: T[],
|
||||
rootPageId: string,
|
||||
rootPageId: string | null,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<T[]> {
|
||||
@@ -1153,6 +1153,15 @@ export class PageService {
|
||||
);
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
|
||||
// When no explicit root is given (whole-space tree), every page whose
|
||||
// parent is outside the returned set acts as a root (space root pages have
|
||||
// parentPageId === null). This mirrors the single-root case below.
|
||||
const pageIdSet = new Set(pageIds);
|
||||
const isRoot = (page: T): boolean => {
|
||||
if (rootPageId !== null) return page.id === rootPageId;
|
||||
return !page.parentPageId || !pageIdSet.has(page.parentPageId);
|
||||
};
|
||||
|
||||
// Prune: include a page only if it's accessible AND its parent chain to root is included
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
@@ -1166,7 +1175,7 @@ export class PageService {
|
||||
if (!accessibleSet.has(page.id)) continue;
|
||||
|
||||
// Root page: include if accessible
|
||||
if (page.id === rootPageId) {
|
||||
if (isRoot(page)) {
|
||||
includedIds.add(page.id);
|
||||
changed = true;
|
||||
continue;
|
||||
@@ -1182,4 +1191,123 @@ export class PageService {
|
||||
|
||||
return pages.filter((p) => includedIds.has(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole subtree (pageId) or whole space tree (spaceId only) in a single
|
||||
* query, permission-filtered, returned as a flat list matching the sidebar
|
||||
* item shape (id, slugId, title, icon, position, parentPageId, spaceId,
|
||||
* hasChildren, canEdit) ordered by position. content is never fetched.
|
||||
*
|
||||
* Reproduces the exact two-branch permission logic of getSidebarPages():
|
||||
* - open space (no restrictions): every returned page is visible, canEdit =
|
||||
* spaceCanEdit, hasChildren derived from the returned set.
|
||||
* - restricted space: full descendant set is loaded, then per-page
|
||||
* permissions applied via filterAccessibleTreePages (restricted-but-granted
|
||||
* pages are kept; inaccessible subtrees pruned); canEdit is per-page AND
|
||||
* spaceCanEdit;
|
||||
* hasChildren is derived from the FINAL (post-prune, post-filter) set, so
|
||||
* a node never advertises children the user cannot access — the same
|
||||
* correction getSidebarPages does via getParentIdsWithAccessibleChildren.
|
||||
*/
|
||||
async getSidebarPagesTree(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
spaceCanEdit?: boolean,
|
||||
pageId?: string,
|
||||
): Promise<
|
||||
Array<
|
||||
Pick<
|
||||
Page,
|
||||
| 'id'
|
||||
| 'slugId'
|
||||
| 'title'
|
||||
| 'icon'
|
||||
| 'position'
|
||||
| 'parentPageId'
|
||||
| 'spaceId'
|
||||
> & { hasChildren: boolean; canEdit: boolean }
|
||||
>
|
||||
> {
|
||||
const hasRestrictions =
|
||||
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
||||
|
||||
// Seed: a single page subtree, or all root pages of the space.
|
||||
// Always seed with the FULL (non-excluding) descendant set — in a restricted
|
||||
// space the per-page filtering below (filterAccessibleTreePages) does the
|
||||
// pruning, exactly like getSidebarPages. Seeding with *ExcludingRestricted
|
||||
// would wrongly drop restricted pages the user has an explicit grant for
|
||||
// (and never recurse into their children), diverging from the sidebar.
|
||||
let pages: Array<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
position: string;
|
||||
parentPageId: string | null;
|
||||
spaceId: string;
|
||||
}>;
|
||||
|
||||
if (pageId) {
|
||||
pages = await this.pageRepo.getPageAndDescendants(pageId, {
|
||||
includeContent: false,
|
||||
});
|
||||
} else {
|
||||
pages = await this.pageRepo.getSpaceDescendants(spaceId, {
|
||||
includeContent: false,
|
||||
});
|
||||
}
|
||||
|
||||
let permissionMap: Map<string, boolean> | undefined;
|
||||
|
||||
if (hasRestrictions) {
|
||||
// Fine-grained per-page permissions on top of restricted pruning.
|
||||
pages = await this.filterAccessibleTreePages(
|
||||
pages,
|
||||
pageId ?? null,
|
||||
userId,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
// Per-page canEdit, same source as getSidebarPages.
|
||||
const accessiblePages =
|
||||
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
|
||||
pages.map((p) => p.id),
|
||||
userId,
|
||||
);
|
||||
permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit]));
|
||||
}
|
||||
|
||||
// Derive hasChildren from the FINAL set: a node has children iff some
|
||||
// returned row points to it as parent. In a restricted space this set is
|
||||
// already pruned/filtered, so inaccessible children are not revealed.
|
||||
const parentIds = new Set<string>();
|
||||
for (const p of pages) {
|
||||
if (p.parentPageId) parentIds.add(p.parentPageId);
|
||||
}
|
||||
|
||||
const shaped = pages.map((p) => ({
|
||||
id: p.id,
|
||||
slugId: p.slugId,
|
||||
title: p.title,
|
||||
icon: p.icon,
|
||||
position: p.position,
|
||||
parentPageId: p.parentPageId,
|
||||
spaceId: p.spaceId,
|
||||
hasChildren: parentIds.has(p.id),
|
||||
canEdit: hasRestrictions
|
||||
? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true)
|
||||
: (spaceCanEdit ?? true),
|
||||
}));
|
||||
|
||||
// Order by position with byte order, matching the sidebar's
|
||||
// `position collate "C"` SQL ordering. position is non-null in returned
|
||||
// rows; treat a null defensively as sorting last.
|
||||
shaped.sort((a, b) => {
|
||||
if (a.position == null) return b.position == null ? 0 : 1;
|
||||
if (b.position == null) return -1;
|
||||
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
|
||||
});
|
||||
|
||||
return shaped;
|
||||
}
|
||||
}
|
||||
|
||||
179
apps/server/src/core/page/services/sidebar-pages-tree.spec.ts
Normal file
179
apps/server/src/core/page/services/sidebar-pages-tree.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Pure-logic test for getSidebarPagesTree's shaping/permission logic.
|
||||
*
|
||||
* NOTE: We cannot import PageService directly here — its dependency chain
|
||||
* imports `src/collaboration/collaboration.util` via a bare `src/...` path, and
|
||||
* the server's jest config (package.json "jest".moduleNameMapper) has no
|
||||
* `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a
|
||||
* pre-existing config gap unrelated to this feature. To still cover the
|
||||
* load-bearing logic we replicate the exact shaping algorithm from
|
||||
* PageService.getSidebarPagesTree below and assert against it. If the service
|
||||
* logic changes, keep this mirror in sync.
|
||||
*/
|
||||
|
||||
type RawPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
position: string;
|
||||
parentPageId: string | null;
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree.
|
||||
function shapeTree(
|
||||
pages: RawPage[],
|
||||
opts: {
|
||||
hasRestrictions: boolean;
|
||||
spaceCanEdit?: boolean;
|
||||
permissionMap?: Map<string, boolean>;
|
||||
},
|
||||
) {
|
||||
const parentIds = new Set<string>();
|
||||
for (const p of pages) {
|
||||
if (p.parentPageId) parentIds.add(p.parentPageId);
|
||||
}
|
||||
|
||||
const shaped = pages.map((p) => ({
|
||||
id: p.id,
|
||||
slugId: p.slugId,
|
||||
title: p.title,
|
||||
icon: p.icon,
|
||||
position: p.position,
|
||||
parentPageId: p.parentPageId,
|
||||
spaceId: p.spaceId,
|
||||
hasChildren: parentIds.has(p.id),
|
||||
canEdit: opts.hasRestrictions
|
||||
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
|
||||
: (opts.spaceCanEdit ?? true),
|
||||
}));
|
||||
|
||||
shaped.sort((a, b) => {
|
||||
if (a.position == null) return b.position == null ? 0 : 1;
|
||||
if (b.position == null) return -1;
|
||||
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
|
||||
});
|
||||
|
||||
return shaped;
|
||||
}
|
||||
|
||||
const page = (
|
||||
id: string,
|
||||
parentPageId: string | null,
|
||||
position: string,
|
||||
): RawPage => ({
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: `Page ${id}`,
|
||||
icon: '',
|
||||
position,
|
||||
parentPageId,
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
|
||||
describe('getSidebarPagesTree shaping logic', () => {
|
||||
it('open space: canEdit = spaceCanEdit, hasChildren derived from set', () => {
|
||||
const pages = [
|
||||
page('root', null, 'a0'),
|
||||
page('child', 'root', 'a0'),
|
||||
page('leaf', 'child', 'a0'),
|
||||
];
|
||||
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: true,
|
||||
});
|
||||
|
||||
const byId = new Map(result.map((p) => [p.id, p]));
|
||||
expect(byId.get('root')!.hasChildren).toBe(true);
|
||||
expect(byId.get('child')!.hasChildren).toBe(true);
|
||||
expect(byId.get('leaf')!.hasChildren).toBe(false);
|
||||
expect(result.every((p) => p.canEdit === true)).toBe(true);
|
||||
});
|
||||
|
||||
it('open space: spaceCanEdit=false makes every node read-only', () => {
|
||||
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: false,
|
||||
});
|
||||
expect(result.every((p) => p.canEdit === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('restricted space: hasChildren does not reveal pruned children', () => {
|
||||
// Simulates the filterAccessibleTreePages result: "child" was pruned, so
|
||||
// the returned set has no row with parent === root.
|
||||
const prunedPages = [page('root', null, 'a0')];
|
||||
const result = shapeTree(prunedPages, {
|
||||
hasRestrictions: true,
|
||||
spaceCanEdit: true,
|
||||
permissionMap: new Map([['root', true]]),
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
// root no longer advertises children the user cannot access.
|
||||
expect(result[0].hasChildren).toBe(false);
|
||||
});
|
||||
|
||||
it('restricted space: canEdit is per-page AND spaceCanEdit', () => {
|
||||
const pages = [
|
||||
page('root', null, 'a0'),
|
||||
page('child', 'root', 'a0'),
|
||||
];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: true,
|
||||
spaceCanEdit: true,
|
||||
permissionMap: new Map([
|
||||
['root', true],
|
||||
['child', false],
|
||||
]),
|
||||
});
|
||||
const byId = new Map(result.map((p) => [p.id, p]));
|
||||
expect(byId.get('root')!.canEdit).toBe(true);
|
||||
expect(byId.get('child')!.canEdit).toBe(false);
|
||||
expect(byId.get('root')!.hasChildren).toBe(true);
|
||||
});
|
||||
|
||||
it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => {
|
||||
const pages = [page('root', null, 'a0')];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: true,
|
||||
spaceCanEdit: false,
|
||||
permissionMap: new Map([['root', true]]),
|
||||
});
|
||||
expect(result[0].canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it('orders by position (collate-C style ascending)', () => {
|
||||
const pages = [
|
||||
page('b', null, 'a1'),
|
||||
page('c', null, 'a2'),
|
||||
page('a', null, 'a0'),
|
||||
];
|
||||
const result = shapeTree(pages, {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: true,
|
||||
});
|
||||
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('shape contains exactly the sidebar item fields', () => {
|
||||
const result = shapeTree([page('root', null, 'a0')], {
|
||||
hasRestrictions: false,
|
||||
spaceCanEdit: true,
|
||||
});
|
||||
expect(Object.keys(result[0]).sort()).toEqual(
|
||||
[
|
||||
'canEdit',
|
||||
'hasChildren',
|
||||
'icon',
|
||||
'id',
|
||||
'parentPageId',
|
||||
'position',
|
||||
'slugId',
|
||||
'spaceId',
|
||||
'title',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -672,4 +672,58 @@ export class PageRepo {
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole space tree (all root pages and their descendants) in a single
|
||||
* recursive query. Mirrors getPageAndDescendants but seeded by every root
|
||||
* page of the space (parentPageId IS NULL) instead of a single parent.
|
||||
*/
|
||||
async getSpaceDescendants(
|
||||
spaceId: string,
|
||||
opts: { includeContent: boolean },
|
||||
) {
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('parentPageId', 'is', null)
|
||||
.where('deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
'p.createdAt',
|
||||
'p.updatedAt',
|
||||
])
|
||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.where('p.deletedAt', 'is', null),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,13 @@ export class EnvironmentService {
|
||||
return !this.isCloud();
|
||||
}
|
||||
|
||||
isCompactPageTreeEnabled(): boolean {
|
||||
const compactTree = this.configService
|
||||
.get<string>('COMPACT_PAGE_TREE', 'true')
|
||||
.toLowerCase();
|
||||
return compactTree === 'true';
|
||||
}
|
||||
|
||||
getStripePublishableKey(): string {
|
||||
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class StaticModule implements OnModuleInit {
|
||||
ENV: this.environmentService.getNodeEnv(),
|
||||
APP_URL: this.environmentService.getAppUrl(),
|
||||
CLOUD: this.environmentService.isCloud(),
|
||||
COMPACT_PAGE_TREE: this.environmentService.isCompactPageTreeEnabled(),
|
||||
FILE_UPLOAD_SIZE_LIMIT:
|
||||
this.environmentService.getFileUploadSizeLimit(),
|
||||
FILE_IMPORT_SIZE_LIMIT:
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
# Лимит шагов AI-агента (8 → 20) и принудительный финальный ответ
|
||||
|
||||
Контекст (симптом из реального чата): на узкий поисковый вопрос («Какой
|
||||
процессор в первой версии Яндекс.Колонки?») агент сделал подряд ~8 вызовов
|
||||
`Search_tavily_search` / `Search_tavily_extract` и **остановился без текстового
|
||||
ответа** — ход завершился пустым. Пользователь отправил «?», что стартовало
|
||||
новый ход с новым бюджетом, и агент продолжил. Причина — жёсткий потолок в
|
||||
8 шагов на один ход агента: бюджет был израсходован на инструменты раньше, чем
|
||||
модель дошла до шага с финальным текстом.
|
||||
|
||||
Хотим две вещи:
|
||||
1. поднять лимит шагов с 8 до 20;
|
||||
2. гарантировать непустой ответ — на последнем шаге принудительно запрещать
|
||||
инструменты, чтобы модель синтезировала лучший ответ из уже собранного.
|
||||
|
||||
## Как сейчас устроен лимит (цепочка)
|
||||
|
||||
Единственная точка ограничения — `stopWhen` в вызове `streamText`:
|
||||
|
||||
- Импорт условия: `apps/server/src/core/ai-chat/ai-chat.service.ts:7`
|
||||
(`stepCountIs` из `ai`).
|
||||
- Потолок: `apps/server/src/core/ai-chat/ai-chat.service.ts:247`
|
||||
— `stopWhen: stepCountIs(8)` внутри `streamText({...})` (вызов начинается на
|
||||
`:237`).
|
||||
- Системный промпт, который уходит в `streamText({ system, ... })`, собирается
|
||||
заранее в локальной переменной `system`:
|
||||
`apps/server/src/core/ai-chat/ai-chat.service.ts:146-150`
|
||||
(`buildSystemPrompt({...})`). Эта переменная в области видимости рядом с
|
||||
вызовом `streamText` — её можно переиспользовать в `prepareStep`.
|
||||
- Терминальные колбэки `onFinish` / `onError` / `onAbort`
|
||||
(`ai-chat.service.ts:249-301`) сохраняют ответ ассистента через
|
||||
`persistAssistant` (`:210-230`). При пустом ходе `onFinish` приходит с
|
||||
`text === ''`, и в историю пишется пустое сообщение — это и видит пользователь
|
||||
как «агент ничего не ответил».
|
||||
|
||||
### Что такое «шаг» (семантика AI SDK v6)
|
||||
|
||||
Один шаг = одна генерация модели. Если в шаге есть вызовы инструментов, они
|
||||
выполняются, результат возвращается модели, и запускается следующий шаг.
|
||||
`stopWhen: stepCountIs(N)` останавливает цикл, как только число завершённых
|
||||
шагов достигает `N`. Цикл также завершается естественно, если модель сделала шаг
|
||||
**без** вызова инструментов (выдала финальный текст).
|
||||
|
||||
Важно: `stepNumber` в `prepareStep` нумеруется с нуля; последний из `N` шагов —
|
||||
это `stepNumber === N - 1`. Один шаг может содержать несколько параллельных
|
||||
вызовов инструментов, поэтому `N` шагов ≠ всегда ровно `N` вызовов (в инциденте
|
||||
они шли последовательно — получилось ровно 8).
|
||||
|
||||
## Решение (точечное, только сервер)
|
||||
|
||||
Файл: `apps/server/src/core/ai-chat/ai-chat.service.ts`.
|
||||
|
||||
1. Завести модульную константу вместо «магической» восьмёрки:
|
||||
|
||||
```ts
|
||||
// Max agent steps per turn. One step = one model generation; a step that calls
|
||||
// tools is followed by another step carrying the tool results. Raised from 8 so
|
||||
// multi-search research questions are not cut off mid-investigation.
|
||||
const MAX_AGENT_STEPS = 20;
|
||||
|
||||
// System-prompt addendum injected ONLY on the final step (see prepareStep). It
|
||||
// forbids further tool calls and tells the model to synthesize the best answer
|
||||
// it can from what it already gathered, so a tool-heavy turn never ends empty.
|
||||
const FINAL_STEP_INSTRUCTION =
|
||||
'You have reached the maximum number of tool-use steps for this turn. ' +
|
||||
'Do NOT call any more tools. Using only the information already gathered, ' +
|
||||
"write the most complete, useful final answer you can now, in the user's " +
|
||||
'language. If the information is incomplete, say so explicitly: summarize ' +
|
||||
'what you found, what is still missing, and give your best partial conclusion.';
|
||||
```
|
||||
|
||||
2. Поднять потолок:
|
||||
|
||||
```ts
|
||||
stopWhen: stepCountIs(MAX_AGENT_STEPS),
|
||||
```
|
||||
|
||||
3. Добавить `prepareStep` в опции `streamText({...})` (рядом со `stopWhen`,
|
||||
перед `abortSignal`). На последнем разрешённом шаге запрещаем инструменты
|
||||
(`toolChoice: 'none'` → модель обязана выдать текст) и дополняем системный
|
||||
промпт инструкцией синтеза. На остальных шагах ничего не возвращаем →
|
||||
действуют дефолтные настройки:
|
||||
|
||||
```ts
|
||||
// Forced finalization: reserve the LAST allowed step for a text-only answer.
|
||||
// Without this, a turn that spends all its steps on tool calls ends with no
|
||||
// assistant text (an empty turn). On the final step we forbid further tool
|
||||
// calls and append a synthesis instruction. `system` is the prompt built above
|
||||
// (in scope here); we CONCATENATE so the original persona/context is preserved
|
||||
// — a bare `system` override would REPLACE the whole system prompt for the step.
|
||||
prepareStep: ({ stepNumber }) => {
|
||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||
return {
|
||||
toolChoice: 'none',
|
||||
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
||||
};
|
||||
}
|
||||
return undefined; // default settings for all earlier steps
|
||||
},
|
||||
```
|
||||
|
||||
Итог: до 19 шагов модель свободно работает с инструментами, 20-й (последний)
|
||||
шаг гарантированно текстовый. Если модель завершилась раньше естественным
|
||||
образом — `prepareStep` для ранних шагов возвращает `undefined`, поведение не
|
||||
меняется.
|
||||
|
||||
## Подтверждённые факты по API (установлено: `ai@6.0.207`)
|
||||
|
||||
Проверено по `node_modules/ai/dist/index.d.ts`:
|
||||
|
||||
- `prepareStep({ stepNumber, steps, model, messages }) => PrepareStepResult |
|
||||
void` — колбэк опции `streamText`.
|
||||
- `PrepareStepResult` (строки ~990-1019) содержит поля:
|
||||
`model?`, `toolChoice?`, `activeTools?`, `system?`, `messages?` и др.
|
||||
- `toolChoice?: ToolChoice<TOOLS>`, где
|
||||
`ToolChoice = 'auto' | 'none' | 'required' | { type:'tool', toolName }`
|
||||
(строка 126) — значит `toolChoice: 'none'` валидно и заставляет модель
|
||||
отвечать текстом.
|
||||
- `system?: string | SystemModelMessage | Array<SystemModelMessage>` — override
|
||||
системного сообщения **для шага**; это полная замена, поэтому конкатенируем с
|
||||
исходным `system`, а не пишем голую инструкцию.
|
||||
- `stepNumber` нумеруется с нуля (док. пример: `if (stepNumber === 0) {...}`).
|
||||
|
||||
> ⚠️ При апгрейде до AI SDK v7 поле `system` в `prepareStep` переименовано в
|
||||
> `instructions` (см. migration guide 7.0). На v6 (`^6.0.134`, фактически
|
||||
> 6.0.207) корректно именно `system`. Учесть при будущем bump.
|
||||
|
||||
## Тонкие моменты / edge cases
|
||||
|
||||
- **Резерв ровно одного шага** — на 20-м шаге модель не сможет сделать ещё один
|
||||
«дозапрос». Это осознанный компромисс: гарантированный ответ важнее одного
|
||||
лишнего инструмента. Если захочется буфера — форсить на `stepNumber >=
|
||||
MAX_AGENT_STEPS - 2` (зарезервировать 2 шага), но это режет полезную работу.
|
||||
- **Естественное завершение** до последнего шага — не затрагивается: override
|
||||
применяется только при `stepNumber >= MAX_AGENT_STEPS - 1`.
|
||||
- **finishReason** последнего шага: при `toolChoice:'none'` модель выдаёт текст
|
||||
без tool-calls → цикл завершается как `stop` (а не «оборвался на лимите»).
|
||||
Пустых ходов больше не будет; `onFinish` получит непустой `text`.
|
||||
- **Замена system** override-ом — единственная ловушка: НЕ потерять исходный
|
||||
промпт. Переменная `system` (`ai-chat.service.ts:146`) в замыкании — берём её.
|
||||
- **maxOutputTokens** на агенте намеренно не задан (коммент `:242-246`) — это
|
||||
изменение его не трогает; токенов на финальный текстовый шаг достаточно.
|
||||
- **Клиент не меняется**: рендер шагов и текста уже есть в
|
||||
`apps/client/src/features/ai-chat/components/message-list.tsx`. Раньше пустой
|
||||
ход показывался как ход без текста — после фикса будет нормальный ответ.
|
||||
- **Внешние MCP-клиенты** (tavily и пр.) закрываются в терминальных колбэках
|
||||
(`closeExternalClients`) — путь завершения не меняется, ликов не добавляем.
|
||||
|
||||
## Тестирование
|
||||
|
||||
- Цикл `streamText` целиком юнит-тестировать дорого. Рекомендуется вынести
|
||||
логику выбора шага в чистую экспортируемую функцию (по образцу
|
||||
`compactToolOutput`, который уже тестируется в `ai-chat.service.spec.ts`):
|
||||
|
||||
```ts
|
||||
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
||||
// steps, and forces a text-only synthesis on the final step.
|
||||
export function prepareAgentStep(
|
||||
stepNumber: number,
|
||||
system: string,
|
||||
): { toolChoice: 'none'; system: string } | undefined {
|
||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||
return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
Тогда `prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system)`,
|
||||
а тест проверяет: для `stepNumber < 19` → `undefined`; для `19` → объект с
|
||||
`toolChoice === 'none'` и `system`, начинающимся с исходного промпта и
|
||||
содержащим `FINAL_STEP_INSTRUCTION`.
|
||||
|
||||
## Альтернативы / возможные расширения (вне базового объёма)
|
||||
|
||||
- **Конфигурируемый лимит** — вынести `MAX_AGENT_STEPS` в настройку воркспейса
|
||||
(admin → AI), как системный промпт (`AiSettingsService.resolve`). Сейчас же —
|
||||
просто константа в коде.
|
||||
- **UI-метка «ответ по неполным данным»** — если последний шаг был принудительным,
|
||||
можно прокинуть флажок в metadata и показать бейдж в `message-list.tsx`. Не
|
||||
обязательно для базовой фичи.
|
||||
|
||||
## Открытые вопросы (согласовать перед реализацией)
|
||||
|
||||
- [ ] Значение лимита: 20 — ок? (компромисс «глубина исследования» vs стоимость
|
||||
токенов на ход.)
|
||||
- [ ] Текст `FINAL_STEP_INSTRUCTION` — устраивает формулировка? Язык ответа
|
||||
модель выбирает сама по контексту; инструкция на английском как и весь
|
||||
системный промпт.
|
||||
- [ ] Выносить ли логику шага в чистую функцию ради юнит-теста (рекомендуется),
|
||||
или оставить инлайн в `prepareStep` без отдельного теста.
|
||||
|
||||
## Процесс
|
||||
|
||||
- Сейчас это только план; код НЕ менялся.
|
||||
- Реализация — режим делегирования (по умолчанию): изменение логическое
|
||||
(новый `prepareStep` + константы, >5 строк) → general-purpose кодеру, затем
|
||||
обязательный прогон `review`.
|
||||
- Не коммитить; в конце предложить сообщение коммита.
|
||||
@@ -1,224 +0,0 @@
|
||||
# Индикатор-точка эндпоинта AI: «настроено / включено» вместо «результат теста»
|
||||
|
||||
## Контекст (симптом)
|
||||
|
||||
В админских настройках AI (Workspace settings → AI) у каждой карточки-эндпоинта
|
||||
(«Chat / LLM», «Embeddings», «Voice / STT») слева от заголовка есть маленькая
|
||||
цветная точка. Сейчас её цвет означает **результат последнего ручного теста**
|
||||
кнопкой «Test endpoint», а не состояние настройки:
|
||||
|
||||
- зелёная — тест «Test endpoint» прошёл (`ok`);
|
||||
- красная — тест упал (`error`);
|
||||
- серая — тест ещё **не запускали** (`idle`).
|
||||
|
||||
Поэтому на текущем экране у «Embeddings» точка зелёная (по карточке нажимали
|
||||
«Test endpoint» → «Connection successful»), а у «Voice / STT» — серая, **хотя
|
||||
тумблер «Voice dictation» включён и эндпоинт настроен**. Тумблеры фич
|
||||
(`chat` / `search` / `dictation`) и сам факт заполненности полей (модель +
|
||||
Base URL) на цвет точки сейчас никак не влияют.
|
||||
|
||||
Хотим, чтобы точка читалась с одного взгляда как состояние эндпоинта, без
|
||||
ручного теста:
|
||||
|
||||
- **зелёная** — корректно настроено **и** включено;
|
||||
- **жёлтая** — настроено, но **не** включено;
|
||||
- **серая** — выключено / не настроено (нечего включать).
|
||||
|
||||
## Как сейчас устроено (цепочка)
|
||||
|
||||
Всё в одном файле клиента:
|
||||
`apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`.
|
||||
|
||||
- Тип состояния точки: `type CardStatus = "ok" | "error" | "idle";`
|
||||
— строка ~64.
|
||||
- Компонент `StatusDot` (строки ~75-90) красит круг: `ok` → `green[6]`,
|
||||
`error` → `red[6]`, иначе → `gray[5]`.
|
||||
- Источник статуса — **только** мутации теста (строки ~356-370):
|
||||
|
||||
```ts
|
||||
const chatStatus: CardStatus = chatTest.data
|
||||
? (chatTest.data.ok ? "ok" : "error")
|
||||
: "idle";
|
||||
// аналогично embedStatus (embedTest), sttStatus (sttTest)
|
||||
```
|
||||
|
||||
`chatTest` / `embedTest` / `sttTest` — это `useTestAiConnectionMutation()`
|
||||
(строки ~101-104); их `data` появляется только после нажатия «Test endpoint».
|
||||
- Точки рендерятся в заголовках трёх карточек: `<StatusDot status={chatStatus}/>`
|
||||
(~407), `embedStatus` (~517), `sttStatus` (~634).
|
||||
|
||||
### Какие данные уже доступны в компоненте
|
||||
|
||||
Этого достаточно, чтобы вычислить «настроено» и «включено» синхронно, без сети:
|
||||
|
||||
- **Поля настройки** (живые, из формы) — `form.values`:
|
||||
`chatModel`, `baseUrl`, `embeddingModel`, `embeddingBaseUrl`, `sttModel`,
|
||||
`sttBaseUrl`, `sttApiStyle`, и write-only буферы ключей `apiKey`,
|
||||
`embeddingApiKey`, `sttApiKey`.
|
||||
- **Наличие сохранённых ключей** — состояния `hasApiKey`, `hasEmbeddingApiKey`,
|
||||
`hasSttApiKey` (строки ~122-130), синхронизируются с сервером и обновляются
|
||||
при «Clear» и сохранении.
|
||||
- **Тумблеры фич** (персистятся в `workspace.settings.ai`) — `chatEnabled`
|
||||
(`settings.ai.chat`, строка ~108), `searchEnabled` (`settings.ai.search`,
|
||||
~111), `dictationEnabled` (`settings.ai.dictation`, ~114).
|
||||
- **Семантика наследования** (важно для «настроено»): Embeddings и Voice
|
||||
**наследуют Base URL и ключ от Chat**, если свои не заданы. Это прямо написано
|
||||
в подзаголовке карточки Chat: «root endpoint — Embeddings and Voice inherit its
|
||||
URL and key» (строка ~423), и реализовано в `resolveUrl(..., fallback)`
|
||||
(~373-382). Значит у Embeddings/STT «свой Base URL» не обязателен.
|
||||
|
||||
## Решение (точечное, только клиент)
|
||||
|
||||
Файл: `apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`.
|
||||
Перепривязать цвет точки с «результата теста» на пару булевых признаков
|
||||
**`configured` × `enabled`**. Результат теста остаётся как был — текстом рядом с
|
||||
кнопкой («Connection successful» / ошибка), точку он больше не красит.
|
||||
|
||||
### 1. Новый тип состояния и чистый хелпер выбора цвета
|
||||
|
||||
```ts
|
||||
// Three-state endpoint health shown by the header dot. Derived synchronously
|
||||
// from the form + feature toggle — never from a network probe (the "Test
|
||||
// endpoint" button still surfaces the live probe result as text).
|
||||
// "ready" (green) — required fields are filled AND the feature is ON
|
||||
// "configured" (yellow) — required fields are filled but the feature is OFF
|
||||
// "off" (gray) — required fields missing (nothing to enable)
|
||||
type CardStatus = "ready" | "configured" | "off";
|
||||
|
||||
// Pure + unit-testable. `configured` = the endpoint has everything it needs to
|
||||
// work; `enabled` = the workspace feature toggle for this endpoint is ON.
|
||||
function resolveCardStatus(configured: boolean, enabled: boolean): CardStatus {
|
||||
if (!configured) return "off";
|
||||
return enabled ? "ready" : "configured";
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `StatusDot` — добавить жёлтый
|
||||
|
||||
```ts
|
||||
function StatusDot({ status }: { status: CardStatus }) {
|
||||
const theme = useMantineTheme();
|
||||
const color =
|
||||
status === "ready"
|
||||
? theme.colors.green[6]
|
||||
: status === "configured"
|
||||
? theme.colors.yellow[6] // Mantine default palette has `yellow`
|
||||
: theme.colors.gray[5];
|
||||
return (
|
||||
<Box w={9} h={9} style={{ borderRadius: "50%", background: color, flex: "none" }} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Признак «настроено» для каждой карточки
|
||||
|
||||
Ключ (API key) считаем **необязательным** — локальные серверы (Ollama, speaches
|
||||
/ faster-whisper-server) работают без ключа, поэтому требовать ключ нельзя.
|
||||
«Настроено» = задана модель **и** есть Base URL (свой или унаследованный от Chat):
|
||||
|
||||
```ts
|
||||
const v = form.values;
|
||||
const chatBase = v.baseUrl.trim();
|
||||
|
||||
// Chat is the root: needs its own model + base URL.
|
||||
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
|
||||
|
||||
// Embeddings / Voice inherit the chat base URL when their own is empty.
|
||||
const embedConfigured =
|
||||
v.embeddingModel.trim() !== "" && (v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
|
||||
const sttConfigured =
|
||||
v.sttModel.trim() !== "" && (v.sttBaseUrl.trim() !== "" || chatBase !== "");
|
||||
```
|
||||
|
||||
### 4. Заменить вывод статусов (строки ~356-370)
|
||||
|
||||
```ts
|
||||
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
|
||||
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
|
||||
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
|
||||
```
|
||||
|
||||
`chatTest` / `embedTest` / `sttTest` остаются для текстового результата под
|
||||
кнопкой «Test endpoint» — их `data` просто больше не участвует в цвете точки.
|
||||
|
||||
### 5. (Рекомендуется) Tooltip на точке — цвет не должен быть единственным сигналом
|
||||
|
||||
Цвет в одиночку недоступен дальтоникам и неочевиден. Обернуть `StatusDot` в
|
||||
Mantine `Tooltip` с текстовой расшифровкой (через `t(...)`), напр.:
|
||||
`ready` → «Configured and enabled», `configured` → «Configured but disabled`»,
|
||||
`off` → «Not configured». `Tooltip` уже используется в соседнем
|
||||
`mcp-settings.tsx`, импорт из `@mantine/core`.
|
||||
|
||||
## Тонкие моменты / edge cases
|
||||
|
||||
- **Источник «настроено» — `form.values` (живой), а не persisted `settings`.**
|
||||
Тогда точка реагирует прямо при наборе. Минус: тумблер (`*Enabled`) —
|
||||
персистентный, поэтому после правки полей и **до** «Save endpoints» возможна
|
||||
кратковременная рассинхронизация (поля изменены, но ещё не сохранены). Это
|
||||
приемлемо и логично (точка показывает «то, что введено»). Альтернатива — брать
|
||||
поля из `settings` (тогда точка отражает строго сохранённое состояние,
|
||||
согласованно с тумблером) — см. «Альтернативы».
|
||||
- **Включено, но НЕ настроено** (`enabled && !configured`): админ включил фичу, но
|
||||
не заполнил эндпоинт — реальная мисконфигурация. По строгой трёхцветной схеме
|
||||
это **серый**, что прячет проблему. Варианты: (а) оставить серым (буквально по
|
||||
ТЗ); (б) **рекомендуется** — отдельный «warning»-цвет (красный/оранжевый) и
|
||||
тултип «Enabled but not configured», т.к. фича включена и работать не будет.
|
||||
Решить в «Открытых вопросах».
|
||||
- **Судьба красного «тест упал».** Сейчас красный = упавший тест. В новой схеме
|
||||
цвета красного нет. Падение теста по-прежнему видно текстом под кнопкой, так что
|
||||
сигнал не теряется. Опционально можно сохранить красный как 4-е состояние-оверрайд
|
||||
(если тест **явно** запускали и он упал) — но это усложняет модель; по умолчанию
|
||||
не делаем.
|
||||
- **`yellow` в теме Mantine** есть в дефолтной палитре (Mantine 8) — `yellow[6]`
|
||||
валиден; кастомная тема в проекте палитру не переопределяет (использовать
|
||||
`theme.colors.yellow[6]`).
|
||||
- **Все три карточки** ведут себя единообразно (одна `StatusDot` + один хелпер),
|
||||
включая «Chat / LLM», которой нет на скриншоте, но логика та же.
|
||||
- **Оптимистичные тумблеры**: `*Enabled` обновляются оптимистично и
|
||||
откатываются при ошибке (`handleToggle*`). Цвет точки следует за состоянием
|
||||
тумблера автоматически (реактивный `useState`).
|
||||
- **trim()**: значения могут содержать пробелы — сравнивать после `.trim()` (как
|
||||
в `resolveUrl`).
|
||||
|
||||
## i18n
|
||||
|
||||
- Новые пользовательские строки (тексты тултипов) **только через `t(...)`** и
|
||||
добавить ключи в каталог `apps/client/public/locales/en-US/translation.json`
|
||||
(он английско-ключевой: ключ == значение, напр. `"Configured and enabled"`).
|
||||
Если используется warning-вариант — добавить и его строку.
|
||||
- Комментарии в коде — на английском (правило проекта).
|
||||
|
||||
## Тесты
|
||||
|
||||
- `resolveCardStatus` — чистая функция, легко юнит-тестируется (Vitest на
|
||||
клиенте): `(false, *) → "off"`, `(true, true) → "ready"`, `(true, false) →
|
||||
"configured"`. Если экспортировать `*Configured`-предикаты как чистые
|
||||
функции от `form.values` — их тоже можно покрыть (особенно наследование Base
|
||||
URL у Embeddings/STT).
|
||||
- Запустить `pnpm --filter client lint` и `pnpm --filter client test`.
|
||||
|
||||
## Альтернативы / расширения (вне базового объёма)
|
||||
|
||||
- **Брать «настроено» из persisted `settings`** (а не `form.values`): точка строго
|
||||
отражает сохранённое состояние, согласовано с персистентным тумблером, но не
|
||||
реагирует на ввод до «Save». `settings` (`IAiSettings`) уже содержит
|
||||
`chatModel`/`embeddingModel`/`baseUrl`/`embeddingBaseUrl`/`sttModel`/
|
||||
`sttBaseUrl` + `hasApiKey`/`hasEmbeddingApiKey`/`hasSttApiKey`.
|
||||
- **«настроено» = «тест прошёл»** вместо «поля заполнены»: точнее («корректно»),
|
||||
но требует автопрогона теста на загрузке (сеть, латентность, лимиты провайдера)
|
||||
— против идеи мгновенного индикатора. Не рекомендуется.
|
||||
- **Учитывать ключ для облачных провайдеров**: если Base URL указывает на
|
||||
публичный провайдер (OpenAI/OpenRouter), ключ де-факто обязателен. Можно
|
||||
усложнить предикат (`configured` требует ключ, если host не локальный), но это
|
||||
хрупкая эвристика — оставляем ключ необязательным.
|
||||
|
||||
## Открытые вопросы (согласовать перед реализацией)
|
||||
|
||||
- [ ] Случай «включено, но не настроено»: серый (буквально по ТЗ) или отдельный
|
||||
warning-цвет (рекомендуется, чтобы не прятать мисконфигурацию)?
|
||||
- [ ] Что значит «настроено»: «поля модель + Base URL заполнены» (рекомендуется,
|
||||
ключ необязателен) — ок? Или требовать ещё и ключ?
|
||||
- [ ] Источник полей: живой `form.values` (реактивно при вводе, рекомендуется)
|
||||
или persisted `settings` (строго сохранённое состояние)?
|
||||
- [ ] Добавлять ли `Tooltip` с текстовой расшифровкой (рекомендуется для
|
||||
доступности) и сохранять ли красный как 4-е состояние «тест упал»?
|
||||
@@ -1,157 +0,0 @@
|
||||
# Поле «API key»: убрать бесполезный «глазок», поставить Clear на его место
|
||||
|
||||
Статус: **план, код не менялся.** UI-задача на клиенте. Бэкенда не касается.
|
||||
|
||||
## Суть
|
||||
|
||||
В настройках AI-провайдера (Workspace settings → AI) у каждого из трёх
|
||||
эндпоинтов есть поле `PasswordInput` для API-ключа. Когда ключ уже сохранён на
|
||||
сервере, поле показывает плейсхолдер `•••• set`, а справа — встроенный в
|
||||
Mantine `PasswordInput` тогл видимости («глазок»). Под полем отдельной строкой
|
||||
висит ссылка **Clear**.
|
||||
|
||||
Проблема: **«глазок» бессмысленен.** Поле ключа — write-only буфер: реальный
|
||||
ключ в него никогда не загружается (сервер отдаёт только факт «ключ есть»,
|
||||
`hasApiKey`, см. `ai-provider-settings.tsx:120-130, 154-177`). Когда ключ
|
||||
сохранён, буфер пустой → нажатие «глазка» показывает пустоту. Полезного смысла
|
||||
нет.
|
||||
|
||||
Хотим: **в состоянии «ключ сохранён» показывать кнопку Clear прямо на месте
|
||||
«глазка» (в правой секции поля), а не отдельной ссылкой снизу.** Сделать это во
|
||||
**всех трёх эндпоинтах** (Chat / LLM, Embeddings, Voice / STT).
|
||||
|
||||
## Где править (точные места)
|
||||
|
||||
Один файл:
|
||||
[ai-provider-settings.tsx](apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx)
|
||||
|
||||
Три одинаковых по структуре блока — `<Stack gap={4}>` с `PasswordInput` + ссылкой
|
||||
`<Anchor>Clear</Anchor>` снизу:
|
||||
|
||||
1. **Chat / LLM** — строки ~433-445 (`apiKey`, `hasApiKey`, `handleClearKey`).
|
||||
2. **Embeddings** — строки ~538-560 (`embeddingApiKey`, `hasEmbeddingApiKey`,
|
||||
`handleClearEmbeddingKey`).
|
||||
3. **Voice / STT** — строки ~657-679 (`sttApiKey`, `hasSttApiKey`,
|
||||
`handleClearSttKey`).
|
||||
|
||||
Обработчики очистки (`handleClearKey` / `handleClearEmbeddingKey` /
|
||||
`handleClearSttKey`, строки 239-255) и вся логика буферов/payload
|
||||
(`buildPayload`, строки 179-222) — **остаются без изменений.** Меняется только
|
||||
разметка трёх полей.
|
||||
|
||||
## Ключевой факт Mantine (подтверждён по докам)
|
||||
|
||||
У `PasswordInput`: **если передать свой `rightSection`, встроенный тогл
|
||||
видимости («глазок») не рендерится** (Mantine docs, PasswordInput → «Usage
|
||||
without visibility toggle»: *“When the `rightSection` prop is used, the
|
||||
visibility toggle button is not rendered.”*).
|
||||
|
||||
То есть «поставить Clear на место глазка» = передать в `PasswordInput`
|
||||
`rightSection` с кнопкой Clear. Отдельный костыль для скрытия глазка не нужен.
|
||||
|
||||
## Рекомендуемое поведение
|
||||
|
||||
Показывать Clear в правой секции **только когда ключ сохранён И буфер пуст**
|
||||
(`hasApiKey && form.values.apiKey.length === 0`). Как только пользователь
|
||||
начинает вводить НОВЫЙ ключ (буфер непустой) — возвращать дефолтный «глазок»:
|
||||
вот тут он осмыслен (проверить, что набрал). После клика по Clear обработчик
|
||||
ставит `hasApiKey=false` → `rightSection` снова `undefined` → поле становится
|
||||
обычным пустым `PasswordInput` с глазком для ввода свежего ключа. Поведение
|
||||
самосогласованное.
|
||||
|
||||
Альтернатива (проще, но грубее): показывать Clear всегда, пока `hasApiKey`
|
||||
(без проверки буфера). Тогда при вводе нового поверх старого глазка не будет.
|
||||
Допустимо, но теряем удобную проверку набранного. Рекомендуется вариант с
|
||||
проверкой буфера.
|
||||
|
||||
## Эскиз правки (на примере Chat-поля; для двух других — аналогично)
|
||||
|
||||
Было:
|
||||
```tsx
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
```
|
||||
|
||||
Стало:
|
||||
```tsx
|
||||
{/* The key field is write-only: the stored key never loads back, so the
|
||||
built-in visibility toggle reveals nothing. Replace it with a Clear action
|
||||
in the right section. Passing rightSection suppresses the eye (Mantine).
|
||||
While typing a new key (buffer non-empty) fall back to the default eye. */}
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
rightSection={
|
||||
hasApiKey && form.values.apiKey.length === 0 ? (
|
||||
<Tooltip label={t("Clear")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
aria-label={t("Clear")}
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
```
|
||||
|
||||
Изменения по каждому из трёх блоков:
|
||||
- Убрать обёртку `<Stack gap={4}>…</Stack>` и ссылку `<Anchor>Clear</Anchor>`
|
||||
снизу (Clear переезжает внутрь поля). После удаления `Stack` второй ребёнок
|
||||
`<Group grow>` — сам `PasswordInput`; раскладка «Model | API key» в две
|
||||
колонки сохраняется.
|
||||
- Подставить свои переменные/обработчики: эндпоинт 2 — `hasEmbeddingApiKey` /
|
||||
`embeddingApiKey` / `handleClearEmbeddingKey`; эндпоинт 3 — `hasSttApiKey` /
|
||||
`sttApiKey` / `handleClearSttKey`.
|
||||
|
||||
## Тонкости / на что смотреть
|
||||
|
||||
- **Импорты.** Добавить `ActionIcon`, `Tooltip` из `@mantine/core` и `IconX`
|
||||
из `@tabler/icons-react` (рядом с уже импортируемым `IconPencil`). После
|
||||
переезда Clear внутрь поля `Anchor` может стать неиспользуемым — проверить и
|
||||
убрать из импорта, иначе словим lint-ошибку `no-unused-vars`.
|
||||
- **Кликабельность правой секции.** У `Input`/`PasswordInput` правая секция по
|
||||
умолчанию не всегда принимает клики — задать `rightSectionPointerEvents="all"`,
|
||||
чтобы клик по Clear срабатывал.
|
||||
- **Тип кнопки.** `ActionIcon` рендерит `<button>` (по умолчанию `type="button"`).
|
||||
Формы как `<form onSubmit>` тут нет — Save висит на отдельной `type="button"`
|
||||
кнопке (строки 735-744), так что случайного сабмита не будет. Для надёжности
|
||||
можно явно проставить `type="button"`.
|
||||
- **i18n.** Новый строковый ключ не нужен: `t("Clear")` уже используется
|
||||
(бывшая ссылка). Тултип и `aria-label` переиспользуют его. Плейсхолдер
|
||||
`•••• set` не трогаем.
|
||||
- **Ширина правой секции.** Иконка X помещается в штатный размер секции (как и
|
||||
глазок). Если решат оставить именно слово «Clear» текстом вместо иконки —
|
||||
понадобится `rightSectionWidth`, иначе текст обрежется. Рекомендуется
|
||||
иконка + тултип (компактно, как глазок).
|
||||
- **Доступность.** Обязателен `aria-label={t("Clear")}` на `ActionIcon` (иконка
|
||||
без видимого текста).
|
||||
|
||||
## Опционально (вне «трёх эндпоинтов»)
|
||||
|
||||
Тот же паттерн «бесполезный глазок + Clear снизу» есть в форме внешнего
|
||||
MCP-сервера —
|
||||
[ai-mcp-server-form.tsx](apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx)
|
||||
(поле Authorization-заголовков, `PasswordInput` строка ~193, плейсхолдер
|
||||
`•••• set` строка ~196, `Anchor`-Clear строки ~207-209, обработчик
|
||||
`handleClearHeaders`). В запросе он не входит в «три эндпоинта», но логически
|
||||
страдает тем же. Можно причесать заодно для единообразия — отдельным мелким
|
||||
шагом, по той же схеме.
|
||||
@@ -1,181 +0,0 @@
|
||||
# Панель комментариев: сделать плотнее (меньше воздуха, меньше шрифт)
|
||||
|
||||
Статус: **план, код не менялся.** Чисто UI-задача на клиенте (CSS + пропсы
|
||||
Mantine). Бэкенда, схемы данных и логики не касается.
|
||||
|
||||
## Суть
|
||||
|
||||
Сейчас панель комментариев (правый aside, вкладка «Comments») выглядит
|
||||
разреженной: крупные отступы между карточками и внутри них, большой межстрочный
|
||||
интервал, тело комментария набрано базовым размером редактора (16px). На узкой
|
||||
колонке это «съедает» вертикаль — на экран помещается мало комментариев, много
|
||||
пустого места.
|
||||
|
||||
Хотим: **уплотнить раскладку** — уменьшить внутренние/внешние отступы карточек,
|
||||
зазор «аватар ↔ текст», вертикальный ритм редактора — **и уменьшить шрифт**
|
||||
тела комментария, имени автора и цитаты выделения. Цель — больше комментариев
|
||||
на экран без потери читабельности.
|
||||
|
||||
## Где сейчас живёт «воздух» (точные места)
|
||||
|
||||
Вся вёрстка панели — в фиче `apps/client/src/features/comment/`.
|
||||
|
||||
### 1. Карточка комментария — [comment-list-with-tabs.tsx](apps/client/src/features/comment/components/comment-list-with-tabs.tsx)
|
||||
- `renderComments`, обёртка каждого треда (~строки 121-129):
|
||||
`<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder>` — `p="sm"` (12px
|
||||
внутренний отступ) и `mb="sm"` (12px зазор между комментариями).
|
||||
- Разделитель перед редактором ответа (~строка 148): `<Divider my={4} />`.
|
||||
- Вкладки (`Tabs.Panel pt="xs"`, строки 226 и 245) и пустое состояние
|
||||
(`<Center py="xl">`, строки 228 и 247) — второстепенные источники воздуха.
|
||||
- Нижнее поле ввода `PageCommentInput` (строки ~361-405): `paddingTop` = `sm`,
|
||||
`paddingBottom: 25`, аватар `marginTop: 10`, кнопка отправки спозиционирована
|
||||
`bottom: 30`. Эти величины связаны (плавающая кнопка над полем) — трогать
|
||||
осторожно.
|
||||
|
||||
### 2. Элемент комментария — [comment-list-item.tsx](apps/client/src/features/comment/components/comment-list-item.tsx)
|
||||
- Внешняя обёртка (строка 119): `<Box ref={ref} pb="xs">` — 10px снизу у каждого
|
||||
элемента (включая вложенные ответы).
|
||||
- Шапка «аватар ↔ контент» (строка 120): `<Group>` **без** `gap` → дефолтный
|
||||
`gap="md"` (16px) между аватаром и блоком с именем/телом. Это главный
|
||||
горизонтальный «воздух».
|
||||
- Имя автора (строка 129): `<Text size="sm" fw={500} lineClamp={1}>` — 14px.
|
||||
- Время (строки 157-161): уже `<Text size="xs">` (12px) — оставить.
|
||||
- Цитата выделения (строка 180): `<Text size="sm">{comment?.selection}</Text>` —
|
||||
14px, внутри блока `.textSelection`.
|
||||
|
||||
### 3. Стили — [comment.module.css](apps/client/src/features/comment/components/comment.module.css)
|
||||
- `.textSelection` (строки 9-21): `margin-top: 4px`, `padding: 8px`.
|
||||
- `.commentEditor .ProseMirror :global(.ProseMirror)` (строки 35-44):
|
||||
`margin-top: 10px`, `margin-bottom: 2px`, паддинги 6px. **font-size не задан** —
|
||||
тело комментария наследует глобальный
|
||||
`.ProseMirror { font-size: var(--mantine-font-size-md) }` (16px) из
|
||||
[core.css:10](apps/client/src/features/editor/styles/core.css#L10).
|
||||
- `.wrapper` (строки 1-3) — `padding: md`, **в коде не используется** (можно
|
||||
игнорировать или удалить заодно).
|
||||
|
||||
### 4. Внешняя рамка панели (ВНИМАНИЕ: общая) — [aside.tsx](apps/client/src/components/layouts/global/aside.tsx)
|
||||
- `<Box p="md">` (строка 47) и шапка `<Group ... mb="md">` с
|
||||
`<Title order={2} size="h6">` (строки 50-51) дают 16px отступа по краям панели
|
||||
и под заголовком. **Этот контейнер общий для трёх вкладок** aside
|
||||
(`comments` / `toc` / `details`). Менять его — значит уплотнить заодно
|
||||
«Содержание» и «Детали». См. «Открытые вопросы».
|
||||
|
||||
Шкалы Mantine в проекте без переопределений (`theme.ts` палитру/контраст меняет,
|
||||
но не размеры): шрифт `xs=12px / sm=14px / md=16px`; spacing `xs=10 / sm=12 /
|
||||
md=16`.
|
||||
|
||||
## Решение (точечное, в границах фичи comment)
|
||||
|
||||
Базовый объём — **только компоненты `features/comment/`**, чтобы вкладки
|
||||
«Содержание»/«Детали» (общий `aside.tsx`) не задеть. Уплотнение рамки панели —
|
||||
отдельный опциональный пункт (см. ниже).
|
||||
|
||||
### Правки по файлам
|
||||
|
||||
**`comment-list-with-tabs.tsx`**
|
||||
- `<Paper>` в `renderComments`: `p="sm"` → `p="xs"`, `mb="sm"` → `mb="xs"`
|
||||
(12 → 10px). `shadow="sm"`, `radius="md"`, `withBorder` — оставить.
|
||||
- `<Divider my={4} />` → `my={2}`.
|
||||
|
||||
**`comment-list-item.tsx`**
|
||||
- `<Box ref={ref} pb="xs">` → `pb={6}`.
|
||||
- Шапка `<Group>` (аватар + контент, строка 120): добавить `gap="xs"`
|
||||
(дефолтные 16px → 10px). НЕ трогать внутренние `<Group justify="space-between">`
|
||||
и `<Group gap="xs">`, у них зазор уже задан.
|
||||
- Имя: `<Text size="sm" ...>` → `size="xs"`. `fw={500}` и `lineClamp={1}` —
|
||||
оставить (см. «иерархия шрифта» ниже).
|
||||
- Цитата: `<Text size="sm">{comment?.selection}</Text>` → `size="xs"`.
|
||||
|
||||
**`comment.module.css`**
|
||||
- В `.ProseMirror :global(.ProseMirror)` добавить
|
||||
`font-size: var(--mantine-font-size-sm);` (16 → 14px) и `line-height: 1.4;`,
|
||||
заменить `margin-top: 10px` → `margin-top: 4px`. Остальные декларации
|
||||
(`border-radius`, `max-width`, `white-space`, `word-break`, паддинги,
|
||||
`margin-bottom`) — без изменений.
|
||||
- `.textSelection`: `margin-top: 4px` → `2px`, `padding: 8px` → `6px`.
|
||||
|
||||
### Эскиз (ключевой фрагмент CSS)
|
||||
|
||||
```css
|
||||
.commentEditor {
|
||||
/* ... */
|
||||
.ProseMirror :global(.ProseMirror) {
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
/* Denser comments: shrink body text from the global 16px ProseMirror size
|
||||
to 14px and tighten the rhythm vs. the comment header. */
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: 4px; /* was 10px */
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.textSelection {
|
||||
margin-top: 2px; /* was 4px */
|
||||
padding: 6px; /* was 8px */
|
||||
/* ...остальное без изменений... */
|
||||
}
|
||||
```
|
||||
|
||||
## Тонкие моменты / edge cases
|
||||
|
||||
- **Не трогать `aside.tsx` в базовом объёме.** Его `p="md"` и шапка общие для
|
||||
вкладок `toc`/`details` — правка уплотнит и их. Если это нежелательно, держать
|
||||
изменения строго внутри `features/comment/`.
|
||||
- **Иерархия шрифта (принято).** После правок: имя — `xs` (12px, `fw=500`),
|
||||
тело — `sm` (14px), время — `xs` (12px). Тело крупнее имени — это норма
|
||||
(имя/мета как «капс-лейбл», тело как основной текст).
|
||||
- **`font-size` ставится на внутренний `:global(.ProseMirror)`,** т.к. размер
|
||||
приходит из глобального правила `core.css`. Класс-модуль `.commentEditor`
|
||||
скоупит переопределение только на редактор комментариев — основной редактор
|
||||
страницы не затрагивается.
|
||||
- **Тёмная тема.** Меняем только размеры/отступы, цвета берутся из токенов
|
||||
Mantine — отдельной проверки палитры не требуется, но визуально глянуть стоит.
|
||||
- **Вложенные ответы** рендерятся тем же `CommentListItem` → уплотнение `pb`,
|
||||
`gap`, шрифтов применится и к ним автоматически (так и нужно).
|
||||
- **Markdown/код в теле.** `pre`/`code`/списки внутри комментария наследуют
|
||||
`font-size` от `.ProseMirror` контейнера — после `font-size: sm` они тоже
|
||||
станут компактнее; проверить, что код-блоки не разъезжаются.
|
||||
- **Цитата выделения кликабельна** (`role="button"`, переход к месту в тексте) —
|
||||
уменьшение `padding`/`size` не должно сломать зону клика; визуально проверить.
|
||||
- **Нижнее поле ввода** (`PageCommentInput`) с плавающей кнопкой: величины
|
||||
`paddingBottom: 25` / `bottom: 30` связаны. В базовом объёме не трогаем; если
|
||||
захотим уплотнить и его — менять обе согласованно и проверить, что кнопка
|
||||
отправки не наезжает на текст.
|
||||
|
||||
## Тесты / проверка
|
||||
|
||||
- Прогнать `pnpm --filter client lint` и `pnpm --filter client test`
|
||||
(изменения косметические — падений быть не должно).
|
||||
- Ручная проверка во вкладке Comments: тред с длинным телом, тред с цитатой
|
||||
выделения, вложенные ответы, режим редактирования, светлая/тёмная тема, узкая
|
||||
ширина aside. Убедиться, что вкладки «Содержание»/«Детали» не изменились
|
||||
(если `aside.tsx` не трогали).
|
||||
|
||||
## Опционально / расширения (вне базового объёма)
|
||||
|
||||
- **Уплотнить рамку панели** — `aside.tsx`: `p="md"` → `p="sm"`, шапка
|
||||
`mb="md"` → `mb="sm"`. Даст ощутимо меньше воздуха по краям, **но затронет все
|
||||
вкладки aside** (см. «Открытые вопросы»).
|
||||
- **Компактные вкладки Tabs** — `Tabs.Panel pt="xs"` → `pt={6}`, бейджи счётчиков
|
||||
уже `size="sm"`.
|
||||
- **Удалить мёртвый `.wrapper`** из `comment.module.css` (не используется).
|
||||
- **Уменьшить аватары** с `size="sm"` до `size="xs"` в `CommentListItem` и
|
||||
`PageCommentInput` для ещё большей плотности (проверить, что инициалы/картинка
|
||||
не мельчат до нечитаемости).
|
||||
|
||||
## Принятые решения
|
||||
|
||||
Решения зафиксированы — реализовать можно сразу, без доп. согласований:
|
||||
|
||||
- **Границы правки:** строго `features/comment/`. Общую рамку aside (`p="md"`,
|
||||
шапка `mb="md"`) **не трогаем** — она общая с вкладками «Содержание»/«Детали»,
|
||||
и правка задела бы их (см. «Опционально», если позже захотим уплотнить и их).
|
||||
- **Шрифт тела:** `sm` (14px) — не мельче.
|
||||
- **Иерархия:** имя `xs` (12px, `fw=500`), тело `sm` (14px), время `xs` (12px).
|
||||
- **Нижнее поле ввода и размер аватаров:** оставляем как есть.
|
||||
@@ -1,301 +0,0 @@
|
||||
# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё»
|
||||
|
||||
Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран
|
||||
**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом**
|
||||
(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От
|
||||
клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»).
|
||||
|
||||
## Суть
|
||||
|
||||
В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются
|
||||
только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов**
|
||||
сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть
|
||||
всё дерево» нет.
|
||||
|
||||
Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки
|
||||
текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная
|
||||
операция над видом — прав на запись не требует, доступна любому, кто видит спейс.
|
||||
|
||||
## Почему так (выбор архитектуры)
|
||||
|
||||
Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки
|
||||
`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages`
|
||||
отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого
|
||||
API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты,
|
||||
долгий индикатор, защитный потолок). Это и был отвергнутый вариант.
|
||||
|
||||
**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые
|
||||
кирпичи для рекурсивной выборки поддерева с учётом прав (используются в
|
||||
`movePageToSpace`):
|
||||
- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })`
|
||||
([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) —
|
||||
рекурсивный CTE: страница + все потомки одним запросом.
|
||||
- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)`
|
||||
([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) —
|
||||
то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один
|
||||
запрос, не тянет лишнее).
|
||||
- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)`
|
||||
([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136))
|
||||
— точечная фильтрация дерева по правам с сохранением целостности (для
|
||||
per-page permissions сверх restricted-спейсов).
|
||||
- `pageRepo.withHasChildren(eb)`
|
||||
([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) —
|
||||
вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и
|
||||
вывести на клиенте — у узла есть дети, если в ответе есть страница с
|
||||
`parentPageId === id`).
|
||||
|
||||
Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на
|
||||
тысячах страниц; права считаются на сервере (единый источник правды); на клиенте
|
||||
нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на
|
||||
бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа.
|
||||
|
||||
## Где сейчас живёт код (точные места)
|
||||
|
||||
### Клиент — фича `apps/client/src/features/page/tree/`
|
||||
- **Состояние раскрытия** —
|
||||
[open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts):
|
||||
`openTreeNodesAtom`, тип `OpenMap = Record<string, boolean>` (id → раскрыт ли),
|
||||
**персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`.
|
||||
⚠ **Карта общая для всех спейсов воркспейса.**
|
||||
- **Данные дерева** —
|
||||
[tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts):
|
||||
`treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере
|
||||
фильтруется по `spaceId`.
|
||||
- **Модель узла** —
|
||||
[types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode`
|
||||
(`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`,
|
||||
`parentPageId`, `canEdit`, `slugId`).
|
||||
- **Обёртка/тоггл/загрузка** —
|
||||
[space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx):
|
||||
`filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр.
|
||||
164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок).
|
||||
- **Модель-операции** —
|
||||
[tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts):
|
||||
`find`, `appendChildren`, `visible`, `siblingsOf`.
|
||||
- **HTTP-загрузка** —
|
||||
[page-query.ts](apps/client/src/features/page/queries/page-query.ts) +
|
||||
[page-service.ts](apps/client/src/features/page/services/page-service.ts):
|
||||
`getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**),
|
||||
`fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` /
|
||||
`mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)).
|
||||
- **Шапка дерева (куда вешать команды)** —
|
||||
[space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117):
|
||||
`SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/
|
||||
`Menu.Divider`) + кнопка «+» (Create page).
|
||||
|
||||
### Сервер — фича `apps/server/src/core/page/`
|
||||
- **Эндпоинт сайдбара** —
|
||||
[page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540)
|
||||
`POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`),
|
||||
CASL-скоуп на спейс, отдаёт **один уровень**.
|
||||
- **Сервис** —
|
||||
[page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304)
|
||||
`getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`:
|
||||
выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав** —
|
||||
если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`)
|
||||
→ `canEdit = spaceCanEdit`; иначе per-page фильтр через
|
||||
`filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по
|
||||
`getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в
|
||||
рекурсивном режиме.**
|
||||
|
||||
## Решение
|
||||
|
||||
### Серверная часть — «отдать всё поддерево» одним запросом
|
||||
|
||||
Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью):
|
||||
- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или**
|
||||
- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса;
|
||||
`{ pageId }` → всё поддерево страницы).
|
||||
|
||||
Контракт ответа: **плоский список элементов в точно том же shape, что и текущий
|
||||
`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`,
|
||||
`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские
|
||||
`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по
|
||||
`position` (collate "C"), как сейчас.
|
||||
|
||||
Сервисный метод (эскиз), переиспользует существующие кирпичи:
|
||||
```ts
|
||||
// Whole subtree (pageId) or whole space tree (spaceId only) in a single query,
|
||||
// permission-filtered, returned as a flat list matching the sidebar item shape.
|
||||
async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) {
|
||||
const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
|
||||
|
||||
// Seed: a single page subtree, or all root pages of the space.
|
||||
// - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL)
|
||||
// - open space -> plain recursive descendants
|
||||
// For the whole-space case add a space-rooted recursive CTE (seed:
|
||||
// parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring
|
||||
// getPageAndDescendants/...ExcludingRestricted.
|
||||
let pages = hasRestrictions
|
||||
? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false })
|
||||
: await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false });
|
||||
|
||||
// Fine-grained per-page permissions on top of restricted pruning.
|
||||
if (hasRestrictions) {
|
||||
pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId);
|
||||
}
|
||||
|
||||
// Derive hasChildren from the returned set; stamp canEdit (per-page when
|
||||
// restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages().
|
||||
return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ });
|
||||
}
|
||||
```
|
||||
Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые
|
||||
тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса»
|
||||
— CTE, засеянный корнями спейса вместо одного `parentPageId`).
|
||||
|
||||
**Важно про права:** обязательно сохранить **обе ветки** фильтрации из
|
||||
`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`,
|
||||
иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет
|
||||
доступа. Это критичная грань — на ревью проверить отдельно.
|
||||
|
||||
### Клиентская часть — упрощённый `expandAll`
|
||||
|
||||
Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны.
|
||||
|
||||
`page-service.ts` — новый вызов:
|
||||
```ts
|
||||
// Fetch the whole space tree (all roots + descendants) in one shot.
|
||||
export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise<IPage[]> {
|
||||
const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true }
|
||||
return req.data.items;
|
||||
}
|
||||
```
|
||||
|
||||
`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить
|
||||
`useImperativeHandle`:
|
||||
```ts
|
||||
export type SpaceTreeApi = {
|
||||
expandAll: () => Promise<void>;
|
||||
collapseAll: () => void;
|
||||
isExpanding: boolean;
|
||||
};
|
||||
|
||||
const expandAll = useCallback(async () => {
|
||||
const startSpaceId = spaceIdRef.current;
|
||||
setIsExpanding(true);
|
||||
try {
|
||||
// One request: the entire space tree, permission-filtered server-side.
|
||||
const items = await getSpaceTree({ spaceId: startSpaceId });
|
||||
if (spaceIdRef.current !== startSpaceId) return; // space switched — abort
|
||||
|
||||
const fullTree = buildTreeWithChildren(items);
|
||||
setData((prev) => {
|
||||
// Replace current-space nodes with the full tree; keep other spaces intact.
|
||||
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
|
||||
return [...others, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)];
|
||||
});
|
||||
|
||||
// Open every branch node of the current space.
|
||||
const branchIds = collectBranchIds(fullTree); // nodes with children
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of branchIds) next[id] = true;
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
// Never swallow: log full error + show the real reason (project convention).
|
||||
console.error("[tree] expandAll failed", err);
|
||||
notifications.show({ color: "red",
|
||||
message: t("Couldn't expand the tree: {{reason}}", { reason: err?.response?.data?.message ?? err?.message ?? String(err) }) });
|
||||
} finally {
|
||||
setIsExpanding(false);
|
||||
}
|
||||
}, [/* setData, setOpenTreeNodes, t */]);
|
||||
```
|
||||
|
||||
`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая):
|
||||
```ts
|
||||
const collapseAll = useCallback(() => {
|
||||
// The open-map is shared across spaces; clearing it wholesale would drop
|
||||
// other spaces' expanded state. Collapse only current-space ids.
|
||||
const ids = new Set<string>();
|
||||
const walk = (nodes: SpaceTreeNode[]) => {
|
||||
for (const n of nodes) { ids.add(n.id); if (n.children?.length) walk(n.children); }
|
||||
};
|
||||
walk(filteredData);
|
||||
setOpenTreeNodes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = false;
|
||||
return next;
|
||||
});
|
||||
}, [filteredData, setOpenTreeNodes]);
|
||||
```
|
||||
|
||||
`space-sidebar.tsx` — `const treeRef = useRef<SpaceTreeApi | null>(null)`, передать
|
||||
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
|
||||
`canManage`-гейта** — это операция над видом, не над данными.
|
||||
|
||||
## UX-развилка по размещению
|
||||
|
||||
В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты:
|
||||
- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` /
|
||||
`IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик.
|
||||
- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но
|
||||
состояние менее очевидно.
|
||||
- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` +
|
||||
`Menu.Divider`) → шапка не растёт, но в два клика и менее заметно.
|
||||
|
||||
> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо
|
||||
> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`:
|
||||
> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/
|
||||
> `disabled` (`isExpanding`).
|
||||
|
||||
## Тонкие моменты / edge cases
|
||||
|
||||
- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки
|
||||
фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из
|
||||
`getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые
|
||||
поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью —
|
||||
тест: пользователь без доступа к ветке не должен видеть её через «развернуть
|
||||
всё».
|
||||
- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не**
|
||||
тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение
|
||||
при очень больших спейсах — отдавать всё или ограничить + честно сообщить
|
||||
(конвенция: не молчать про усечение).
|
||||
- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и
|
||||
`expandAll`, и `collapseAll` работают **только по узлам текущего спейса**.
|
||||
- **Гонки при смене спейса.** Запрос асинхронный; сверяться с
|
||||
`spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн
|
||||
уже есть в эффектах `space-tree.tsx`).
|
||||
- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив
|
||||
узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы
|
||||
других спейсов.
|
||||
- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и**
|
||||
уведомление с реальной причиной (`err.response?.data?.message`/`err.message`),
|
||||
не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»).
|
||||
- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не
|
||||
было повторных кликов/ощущения зависания.
|
||||
- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых
|
||||
страниц ключи «висят». Не критично; уборка карты — отдельная задача.
|
||||
- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`.
|
||||
- **Шорткат `*`** (развернуть сиблингов,
|
||||
[doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не
|
||||
трогаем — дополняем его.
|
||||
- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк
|
||||
рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить,
|
||||
что позиция/скролл не прыгают.
|
||||
|
||||
## Тесты / проверка
|
||||
|
||||
- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод).
|
||||
Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их
|
||||
поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный
|
||||
`hasChildren`, порядок по `position`, `content` не тянется.
|
||||
- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`.
|
||||
- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним
|
||||
запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не**
|
||||
теряет состояние другого спейса (переключиться туда-обратно); перезагрузка —
|
||||
состояние сохраняется (localStorage); смена спейса в середине загрузки —
|
||||
корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно
|
||||
конкретное уведомление, ошибка залогирована.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против
|
||||
отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape
|
||||
текущего сайдбара.)
|
||||
2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3).
|
||||
Рекомендация — (3) или (1).
|
||||
3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
|
||||
(число узлов) и как сообщать про усечение.
|
||||
Reference in New Issue
Block a user