Merge remote-tracking branch 'gitea/develop' into feat/html-embed-admin
# Conflicts: # apps/server/src/core/workspace/services/workspace.service.ts
This commit is contained in:
@@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null);
|
||||
// Whether the floating AI chat window is open. Non-persistent (resets per session).
|
||||
export const aiChatWindowOpenAtom = atom<boolean>(false);
|
||||
|
||||
/**
|
||||
* The agent role selected for the NEXT new chat. `null` = "Universal assistant"
|
||||
* (no role). Consulted ONLY when creating a chat (its first message): the server
|
||||
* persists it to ai_chats.role_id and the role is immutable afterwards. Reset to
|
||||
* null when starting a new chat. It does NOT affect already-created chats.
|
||||
*/
|
||||
// Cast default for the same jotai overload reason as activeAiChatIdAtom above.
|
||||
export const selectedAiRoleIdAtom = atom(null as string | null);
|
||||
|
||||
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
|
||||
// ChatThread — so it survives the thread remount that happens when a brand-new
|
||||
// chat adopts its freshly created id after the first turn finishes. If it lived
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||
import { Group, Loader, Select, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsDiagonal,
|
||||
IconCheck,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
@@ -102,6 +104,8 @@ export default function AiChatWindow() {
|
||||
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
|
||||
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
|
||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||
// The role chosen for the next new chat (null = universal assistant).
|
||||
const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom);
|
||||
|
||||
// History section starts collapsed (matches the former panel's behavior).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -123,6 +127,16 @@ export default function AiChatWindow() {
|
||||
const adoptNewChat = useRef(false);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
// The new-chat picker only offers ENABLED roles. The list endpoint returns
|
||||
// all live roles (so the admin settings section can manage disabled ones), so
|
||||
// we filter to `enabled` here, client-side, for the composer picker only.
|
||||
const enabledRoles = useMemo(
|
||||
() => (roles ?? []).filter((r) => r.enabled === true),
|
||||
[roles],
|
||||
);
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
@@ -144,7 +158,9 @@ export default function AiChatWindow() {
|
||||
setActiveChatId(null);
|
||||
setHistoryOpen(false);
|
||||
setDraft("");
|
||||
}, [setActiveChatId, setDraft]);
|
||||
// Default the picker back to "Universal assistant" for the fresh chat.
|
||||
setSelectedRoleId(null);
|
||||
}, [setActiveChatId, setDraft, setSelectedRoleId]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
(chatId: string): void => {
|
||||
@@ -343,6 +359,15 @@ export default function AiChatWindow() {
|
||||
/>
|
||||
<span className={classes.title}>{t("AI chat")}</span>
|
||||
|
||||
{/* Role badge for the active chat (emoji + name). Shown only when the
|
||||
chat is bound to a role that still exists. */}
|
||||
{activeChat?.roleName && (
|
||||
<span className={classes.badge} title={t("Agent role")}>
|
||||
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
|
||||
{activeChat.roleName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{contextTokens > 0 && (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
@@ -400,7 +425,16 @@ export default function AiChatWindow() {
|
||||
>
|
||||
<div
|
||||
className={classes.historyHeader}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={historyOpen}
|
||||
onClick={() => setHistoryOpen((o) => !o)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setHistoryOpen((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
@@ -432,6 +466,29 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role picker — only for a NEW chat (before it is created). Once the
|
||||
chat exists, its role is fixed and shown as a header badge instead.
|
||||
Defaults to "Universal assistant" (no role). */}
|
||||
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
|
||||
<div style={{ padding: "4px 8px 0" }}>
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Agent role")}
|
||||
value={selectedRoleId ?? ""}
|
||||
onChange={(value) => setSelectedRoleId(value || null)}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
data={[
|
||||
{ value: "", label: t("Universal assistant") },
|
||||
...enabledRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* body: active chat thread */}
|
||||
<div className={classes.body}>
|
||||
{waitingForHistory ? (
|
||||
@@ -444,6 +501,8 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
onTurnFinished={onTurnFinished}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,10 @@ interface ChatThreadProps {
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
roleId?: string | null;
|
||||
/** Called when a turn finishes; the parent refreshes the chat list and, for
|
||||
* a new chat, adopts the freshly created chat id. */
|
||||
onTurnFinished: () => void;
|
||||
@@ -61,6 +65,7 @@ export default function ChatThread({
|
||||
chatId,
|
||||
initialRows,
|
||||
openPage,
|
||||
roleId,
|
||||
onTurnFinished,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -84,6 +89,12 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
const roleIdRef = useRef<string | null>(roleId ?? null);
|
||||
roleIdRef.current = roleId ?? null;
|
||||
|
||||
// Stable `useChat` store key for the lifetime of THIS mount.
|
||||
//
|
||||
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
|
||||
@@ -119,6 +130,9 @@ export default function ChatThread({
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
@@ -134,6 +148,13 @@ export default function ChatThread({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
onFinish: () => onTurnFinished(),
|
||||
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand
|
||||
// new chat that fails on its first turn would never invalidate the chat list
|
||||
// nor adopt the server-created chat id (the server still creates the row and
|
||||
// saves the error message). Run the same post-turn path on error so the
|
||||
// failed chat appears in history immediately instead of after a manual
|
||||
// refresh. The error itself is still surfaced via `error` below.
|
||||
onError: () => onTurnFinished(),
|
||||
});
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
@@ -115,11 +115,28 @@ export default function ConversationList({
|
||||
classes.conversationItem,
|
||||
isActive && classes.conversationItemActive,
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Activate on Enter/Space like a native button; the inner menu
|
||||
// button stops propagation so its own keys never reach this row.
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(chat.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.roleName && (
|
||||
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}>
|
||||
{chat.roleEmoji || "🤖"}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{chat.title || t("Untitled chat")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
@@ -12,11 +12,6 @@ interface MessageItemProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single UIMessage by iterating its `parts`:
|
||||
* - `text` parts -> sanitized markdown.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -11,11 +12,6 @@ interface MessageListProps {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
// Distance (px) from the bottom within which the viewport still counts as
|
||||
// "pinned" — absorbs sub-pixel rounding and small content jitter.
|
||||
const BOTTOM_THRESHOLD = 40;
|
||||
|
||||
@@ -8,18 +8,26 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
createAiRole,
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChats,
|
||||
getAiRoles,
|
||||
renameAiChat,
|
||||
updateAiRole,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
@@ -114,3 +122,79 @@ export function useDeleteAiChatMutation() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the workspace's agent roles. Available to any workspace member (used by
|
||||
* the chat-creation role picker and the admin management section). `enabled`
|
||||
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
||||
*/
|
||||
export function useAiRolesQuery(enabled: boolean = true) {
|
||||
return useQuery<IAiRole[], Error>({
|
||||
queryKey: AI_ROLES_RQ_KEY,
|
||||
queryFn: () => getAiRoles(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
||||
mutationFn: (data) => createAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Created successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
||||
mutationFn: (data) => updateAiRole(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
// The role badge denormalized onto the chat list may have changed.
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAiRoleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<{ success: true }, Error, string>({
|
||||
mutationFn: (id) => deleteAiRole(id),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Deleted successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
@@ -46,3 +49,33 @@ export async function renameAiChat(data: {
|
||||
export async function deleteAiChat(chatId: string): Promise<void> {
|
||||
await api.post("/ai-chat/delete", { chatId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
|
||||
* member (for the chat-creation picker); create/update/delete are admin-only
|
||||
* (the server enforces this). Same `{ data }` unwrap convention as above.
|
||||
*/
|
||||
|
||||
/** List the workspace's agent roles. */
|
||||
export async function getAiRoles(): Promise<IAiRole[]> {
|
||||
const req = await api.post<IAiRole[]>("/ai-chat/roles");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Create a role (admin). */
|
||||
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Update a role (admin). */
|
||||
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
|
||||
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/** Soft-delete a role (admin). */
|
||||
export async function deleteAiRole(id: string): Promise<{ success: true }> {
|
||||
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,63 @@ export interface IAiChat {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
// The agent role bound to this chat, if any (immutable after creation).
|
||||
roleId?: string | null;
|
||||
// Denormalized via a JOIN in the chat list response (the bound role's badge).
|
||||
// Null when the chat has no role or the role was soft-deleted.
|
||||
roleName?: string | null;
|
||||
roleEmoji?: string | null;
|
||||
}
|
||||
|
||||
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */
|
||||
export type AiRoleDriver = "openai" | "gemini" | "ollama";
|
||||
|
||||
/** Optional per-role model override (mirrors `model_config`). */
|
||||
export interface IAiRoleModelConfig {
|
||||
driver?: AiRoleDriver;
|
||||
chatModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent role (mirrors the server role views). A role replaces the agent's
|
||||
* persona (instructions) and may optionally override the model. The safety
|
||||
* framework is always still applied server-side.
|
||||
*
|
||||
* The list endpoint returns the FULL view to admins and a reduced picker view to
|
||||
* ordinary members, so the admin-only fields (`instructions`, `modelConfig`,
|
||||
* `createdAt`, `updatedAt`) are optional here — present only for admins.
|
||||
*/
|
||||
export interface IAiRole {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Admin create payload for a role. */
|
||||
export interface IAiRoleCreate {
|
||||
name: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
export interface IAiRoleUpdate {
|
||||
id: string;
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
*
|
||||
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
|
||||
* its `state` is one of input-streaming / input-available / output-available /
|
||||
* output-error (we only surface running / done / error). The server tools are:
|
||||
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
|
||||
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
|
||||
* output-error (we only surface running / done / error). The full toolset the
|
||||
* server exposes lives in `ai-chat-tools.service.ts` (the agent now exposes the
|
||||
* complete Docmost toolset); friendly action-log labels exist ONLY for the
|
||||
* tools listed in `toolLabelKey` below — every other tool falls through to the
|
||||
* generic "Ran tool {{name}}" label.
|
||||
*/
|
||||
|
||||
/** A tool UI part as it arrives from `useChat` / persisted history. */
|
||||
@@ -38,6 +40,11 @@ export interface ToolCitation {
|
||||
href: string;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
export function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
|
||||
export function getToolName(part: ToolUiPart): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +360,16 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
queryKey,
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Idempotency guard: the server now self-echoes addTreeNode back to the
|
||||
// author, so this writer can run twice for one create (mutation onSuccess
|
||||
// + socket echo). Skip the append if the page is already in the cache to
|
||||
// avoid a duplicate node / duplicate React key.
|
||||
const exists = old.pages.some((page) =>
|
||||
page.items.some((item) => item.id === newPage.id),
|
||||
);
|
||||
if (exists) return old;
|
||||
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
@@ -41,12 +40,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (sourceId: string, op: DropOp) => {
|
||||
const before = store.get(treeDataAtom);
|
||||
const { tree: after, result } = treeModel.move(before, sourceId, op);
|
||||
const { tree: after } = treeModel.move(before, sourceId, op);
|
||||
if (after === before) return;
|
||||
|
||||
const payload = dropOpToMovePayload(before, sourceId, op);
|
||||
@@ -112,22 +110,12 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
pageData,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "moveTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
id: sourceId,
|
||||
parentId: payload.parentPageId,
|
||||
oldParentId,
|
||||
index: result.index,
|
||||
position: payload.position,
|
||||
pageData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
// Realtime broadcast is now server-authoritative: the server emits
|
||||
// `moveTreeNode` to the space room on PAGE_MOVED. The old client relay
|
||||
// (emit + setTimeout(50)) was removed; the optimistic local update above
|
||||
// stays for instant feedback to the author.
|
||||
},
|
||||
[setData, store, movePageMutation, spaceId, emit, t],
|
||||
[setData, store, movePageMutation, spaceId, t],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
@@ -166,20 +154,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
lastIndex = parent?.children?.length ?? 0;
|
||||
}
|
||||
|
||||
setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex));
|
||||
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index: lastIndex,
|
||||
data: newNode,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
// Idempotent by id: the tree is server-authoritative and the server's
|
||||
// `addTreeNode` broadcast (now ~ms over same-origin) can win the race and
|
||||
// insert this node before this optimistic update runs. Inserting again
|
||||
// un-guarded would duplicate the row in the author's sidebar. Mirror the
|
||||
// `addTreeNode` socket guard: skip when the node already exists. The
|
||||
// optimistic node's id IS the real created page id (createdPage.id), so
|
||||
// the ids match exactly regardless of which path runs first.
|
||||
setData((prev) => {
|
||||
if (treeModel.find(prev, newNode.id)) return prev;
|
||||
return treeModel.insert(prev, parentId, newNode, lastIndex);
|
||||
});
|
||||
|
||||
// Realtime broadcast is now server-authoritative: the server emits
|
||||
// `addTreeNode` to the space room on PAGE_CREATED. The old client relay
|
||||
// (emit + setTimeout(50)) was removed; the optimistic insert above stays
|
||||
// for instant feedback to the author (the server event is idempotent and
|
||||
// a no-op for the author whose node already exists).
|
||||
const pageUrl = buildPageUrl(
|
||||
spaceSlug,
|
||||
createdPage.slugId,
|
||||
@@ -187,7 +178,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
);
|
||||
navigate(pageUrl);
|
||||
},
|
||||
[spaceId, createPageMutation, setData, store, emit, navigate, spaceSlug],
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -238,19 +229,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
navigate(getSpaceUrl(spaceSlug));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!node) return;
|
||||
emit({
|
||||
operation: "deleteTreeNode",
|
||||
spaceId,
|
||||
payload: { node },
|
||||
});
|
||||
}, 50);
|
||||
// Realtime broadcast is now server-authoritative: the server emits
|
||||
// `deleteTreeNode` to the space room on PAGE_SOFT_DELETED. The old
|
||||
// client relay (emit + setTimeout(50)) was removed; the optimistic
|
||||
// removal above stays for instant feedback to the author.
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page:", error);
|
||||
}
|
||||
},
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug, emit, spaceId],
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
||||
|
||||
@@ -128,6 +128,260 @@ describe('treeModel.insert', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.insertByPosition', () => {
|
||||
// Server-authoritative broadcasts ship the node's fractional `position`; the
|
||||
// receiver inserts among already-loaded siblings ordered by `position`.
|
||||
type P = TreeNode<{ name: string; position?: string }>;
|
||||
|
||||
const roots: P[] = [
|
||||
{ id: 'a', name: 'A', position: 'a0' },
|
||||
{ id: 'b', name: 'B', position: 'a2' },
|
||||
{ id: 'c', name: 'C', position: 'a4' },
|
||||
];
|
||||
|
||||
it('inserts a root node in position order (middle)', () => {
|
||||
const node: P = { id: 'x', name: 'X', position: 'a3' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
|
||||
});
|
||||
|
||||
it('inserts a root node at the front when its position sorts first', () => {
|
||||
const node: P = { id: 'x', name: 'X', position: 'a-' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['x', 'a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('appends a root node when its position sorts last', () => {
|
||||
const node: P = { id: 'x', name: 'X', position: 'a9' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
|
||||
});
|
||||
|
||||
it('produces the same order regardless of which siblings are loaded', () => {
|
||||
// Client 1 loaded all siblings; client 2 only loaded a subset. The inserted
|
||||
// node lands in a consistent relative position for both.
|
||||
const full: P[] = roots;
|
||||
const partial: P[] = [roots[0], roots[2]]; // a, c (b not loaded)
|
||||
const node: P = { id: 'x', name: 'X', position: 'a3' };
|
||||
|
||||
expect(
|
||||
treeModel.insertByPosition(full, null, node).map((n) => n.id),
|
||||
).toEqual(['a', 'b', 'x', 'c']);
|
||||
expect(
|
||||
treeModel.insertByPosition(partial, null, node).map((n) => n.id),
|
||||
).toEqual(['a', 'x', 'c']);
|
||||
});
|
||||
|
||||
it('inserts a child in position order under the parent', () => {
|
||||
const tree: P[] = [
|
||||
{
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'p1', name: 'P1', position: 'a0' },
|
||||
{ id: 'p2', name: 'P2', position: 'a2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const node: P = { id: 'p15', name: 'P1.5', position: 'a1' };
|
||||
const t = treeModel.insertByPosition(tree, 'p', node);
|
||||
expect(treeModel.find(t, 'p')?.children?.map((n) => n.id)).toEqual([
|
||||
'p1', 'p15', 'p2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends when the new node has no position', () => {
|
||||
const node: P = { id: 'x', name: 'X' };
|
||||
const t = treeModel.insertByPosition(roots, null, node);
|
||||
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
|
||||
});
|
||||
});
|
||||
|
||||
// addTreeNode idempotency: the receiver early-returns when the node id already
|
||||
// exists, so re-delivery (or the author's optimistic node) is never duplicated.
|
||||
// This guards the find-then-skip contract insertByPosition relies on.
|
||||
describe('addTreeNode idempotency (find-then-skip)', () => {
|
||||
type P = TreeNode<{ name: string; position?: string }>;
|
||||
|
||||
const applyAddTreeNode = (tree: P[], node: P): P[] => {
|
||||
if (treeModel.find(tree, node.id)) return tree;
|
||||
return treeModel.insertByPosition(tree, null, node);
|
||||
};
|
||||
|
||||
it('does not insert a duplicate when the id already exists', () => {
|
||||
const tree: P[] = [{ id: 'a', name: 'A', position: 'a0' }];
|
||||
const node: P = { id: 'a', name: 'A again', position: 'a5' };
|
||||
const t1 = applyAddTreeNode(tree, node);
|
||||
expect(t1).toBe(tree);
|
||||
expect(t1.map((n) => n.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('inserts once, then is a no-op on repeat delivery', () => {
|
||||
let tree: P[] = [{ id: 'a', name: 'A', position: 'a0' }];
|
||||
const node: P = { id: 'x', name: 'X', position: 'a5' };
|
||||
tree = applyAddTreeNode(tree, node);
|
||||
expect(tree.map((n) => n.id)).toEqual(['a', 'x']);
|
||||
const again = applyAddTreeNode(tree, node);
|
||||
expect(again).toBe(tree);
|
||||
expect(again.filter((n) => n.id === 'x')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// handleCreate optimistic-insert idempotency: the author's optimistic insert is
|
||||
// now guarded by `treeModel.find` (same contract as the addTreeNode socket
|
||||
// handler) because the server's broadcast can win the race and insert the node
|
||||
// first. Whichever runs first inserts; the second is a no-op. Exactly one row.
|
||||
describe('handleCreate optimistic-insert idempotency (find-then-skip)', () => {
|
||||
// Mirrors the guarded optimistic insert in use-tree-mutation handleCreate.
|
||||
const applyOptimisticInsert = (
|
||||
tree: N[],
|
||||
parentId: string | null,
|
||||
node: N,
|
||||
index: number,
|
||||
): N[] => {
|
||||
if (treeModel.find(tree, node.id)) return tree;
|
||||
return treeModel.insert(tree, parentId, node, index);
|
||||
};
|
||||
|
||||
// Mirrors the addTreeNode socket handler guard.
|
||||
const applyAddTreeNode = (tree: N[], parentId: string | null, node: N): N[] => {
|
||||
if (treeModel.find(tree, node.id)) return tree;
|
||||
return treeModel.insert(tree, parentId, node);
|
||||
};
|
||||
|
||||
const created: N = { id: 'new', name: '' };
|
||||
|
||||
it('optimistic insert is a no-op when server addTreeNode already inserted it', () => {
|
||||
// Reverse-of-reverse race: server wins.
|
||||
const afterServer = applyAddTreeNode(fixture, null, created);
|
||||
expect(afterServer.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
const afterOptimistic = applyOptimisticInsert(
|
||||
afterServer,
|
||||
null,
|
||||
created,
|
||||
afterServer.length,
|
||||
);
|
||||
expect(afterOptimistic).toBe(afterServer); // skipped
|
||||
expect(afterOptimistic.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('server addTreeNode is a no-op when optimistic insert already ran (optimistic-first)', () => {
|
||||
const afterOptimistic = applyOptimisticInsert(fixture, null, created, fixture.length);
|
||||
expect(afterOptimistic.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
const afterServer = applyAddTreeNode(afterOptimistic, null, created);
|
||||
expect(afterServer).toBe(afterOptimistic); // skipped
|
||||
expect(afterServer.filter((n) => n.id === 'new')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('inserts exactly once when only the optimistic path runs', () => {
|
||||
const t = applyOptimisticInsert(fixture, 'a', { id: 'a3', name: '' }, 2);
|
||||
expect(treeModel.find(t, 'a')?.children?.filter((n) => n.id === 'a3')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// moveTreeNode socket-handler semantics: the receiver must place the moved node
|
||||
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
|
||||
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
|
||||
// use-tree-socket.ts so the contract is unit-tested without rendering the hook.
|
||||
describe('moveTreeNode handler (place by position + apply pageData)', () => {
|
||||
type P = TreeNode<{
|
||||
name: string;
|
||||
position?: string;
|
||||
icon?: string;
|
||||
hasChildren?: boolean;
|
||||
parentPageId?: string | null;
|
||||
}>;
|
||||
|
||||
const applyMoveTreeNode = (
|
||||
tree: P[],
|
||||
payload: {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
position: string;
|
||||
pageData?: { title?: string | null; icon?: string | null; hasChildren?: boolean };
|
||||
},
|
||||
): P[] => {
|
||||
if (!treeModel.find(tree, payload.id)) return tree;
|
||||
const placed = treeModel.placeByPosition(tree, payload.id, {
|
||||
parentId: payload.parentId,
|
||||
position: payload.position,
|
||||
});
|
||||
if (placed === tree) return treeModel.remove(tree, payload.id);
|
||||
const patch: Partial<P> = {
|
||||
position: payload.position,
|
||||
parentPageId: payload.parentId,
|
||||
} as Partial<P>;
|
||||
const pd = payload.pageData;
|
||||
if (pd) {
|
||||
if (pd.title !== undefined) (patch as { name?: string }).name = pd.title ?? '';
|
||||
if (pd.icon !== undefined) (patch as { icon?: string }).icon = pd.icon ?? undefined;
|
||||
if (pd.hasChildren !== undefined)
|
||||
(patch as { hasChildren?: boolean }).hasChildren = pd.hasChildren;
|
||||
}
|
||||
return treeModel.update(placed, payload.id, patch);
|
||||
};
|
||||
|
||||
const tree: P[] = [
|
||||
{
|
||||
id: 'dst',
|
||||
name: 'DST',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'c1', name: 'C1', position: 'a1' },
|
||||
{ id: 'c2', name: 'C2', position: 'a3' },
|
||||
{ id: 'c3', name: 'C3', position: 'a5' },
|
||||
],
|
||||
},
|
||||
{ id: 'src', name: 'SRC', position: 'a9' },
|
||||
];
|
||||
|
||||
it('lands the moved node in the correct MIDDLE slot, not at index 0', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'src', 'c3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands the moved node at the END when position sorts last', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'dst',
|
||||
position: 'a8',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'c3', 'src',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies pageData (title/icon/hasChildren) to the moved node', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
pageData: { title: 'Renamed', icon: '🔥', hasChildren: true },
|
||||
});
|
||||
const moved = treeModel.find(t, 'src');
|
||||
expect(moved?.name).toBe('Renamed');
|
||||
expect(moved?.icon).toBe('🔥');
|
||||
expect(moved?.hasChildren).toBe(true);
|
||||
expect(moved?.position).toBe('a4');
|
||||
});
|
||||
|
||||
it('falls back to removing the node when the destination parent is not loaded', () => {
|
||||
const t = applyMoveTreeNode(tree, {
|
||||
id: 'src',
|
||||
parentId: 'not-loaded',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'src')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.remove', () => {
|
||||
it('removes a leaf', () => {
|
||||
const t = treeModel.remove(fixture, 'a2');
|
||||
@@ -240,6 +494,118 @@ describe('treeModel.place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.placeByPosition', () => {
|
||||
// Server-authoritative `moveTreeNode` ships the moved node's fractional
|
||||
// `position`; the receiver must sort it into the correct slot among the new
|
||||
// siblings — NOT drop it at index 0.
|
||||
type P = TreeNode<{ name: string; position?: string }>;
|
||||
|
||||
const tree: P[] = [
|
||||
{
|
||||
id: 'dst',
|
||||
name: 'DST',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'c1', name: 'C1', position: 'a1' },
|
||||
{ id: 'c2', name: 'C2', position: 'a3' },
|
||||
{ id: 'c3', name: 'C3', position: 'a5' },
|
||||
],
|
||||
},
|
||||
{ id: 'src', name: 'SRC', position: 'a9' },
|
||||
];
|
||||
|
||||
it('places the moved node in the MIDDLE of new siblings by position', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'src', 'c3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the moved node at the END when its position sorts last', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a8',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'c1', 'c2', 'c3', 'src',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the moved node at the FRONT only when its position sorts first', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a0',
|
||||
});
|
||||
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
|
||||
'src', 'c1', 'c2', 'c3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stamps the authoritative position onto the moved node', () => {
|
||||
const t = treeModel.placeByPosition(tree, 'src', {
|
||||
parentId: 'dst',
|
||||
position: 'a4',
|
||||
});
|
||||
expect(treeModel.find(t, 'src')?.position).toBe('a4');
|
||||
});
|
||||
|
||||
it('reorders within the same parent by position (not to index 0)', () => {
|
||||
const same: P[] = [
|
||||
{
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
position: 'a0',
|
||||
children: [
|
||||
{ id: 'x', name: 'X', position: 'a1' },
|
||||
{ id: 'y', name: 'Y', position: 'a2' },
|
||||
{ id: 'z', name: 'Z', position: 'a3' },
|
||||
],
|
||||
},
|
||||
];
|
||||
// Move x to between y and z.
|
||||
const t = treeModel.placeByPosition(same, 'x', {
|
||||
parentId: 'p',
|
||||
position: 'a25',
|
||||
});
|
||||
expect(treeModel.find(t, 'p')?.children?.map((n) => n.id)).toEqual([
|
||||
'y', 'x', 'z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns same array reference for unknown source', () => {
|
||||
expect(
|
||||
treeModel.placeByPosition(tree, 'ghost', { parentId: 'dst', position: 'a4' }),
|
||||
).toBe(tree);
|
||||
});
|
||||
|
||||
it('returns same array reference when destination parent is not loaded', () => {
|
||||
expect(
|
||||
treeModel.placeByPosition(tree, 'src', { parentId: 'ghost', position: 'a4' }),
|
||||
).toBe(tree);
|
||||
});
|
||||
|
||||
it('moves a node to root by position', () => {
|
||||
const roots: P[] = [
|
||||
{ id: 'r1', name: 'R1', position: 'a1' },
|
||||
{ id: 'r2', name: 'R2', position: 'a5' },
|
||||
{
|
||||
id: 'rp',
|
||||
name: 'RP',
|
||||
position: 'a7',
|
||||
children: [{ id: 'child', name: 'CHILD', position: 'a1' }],
|
||||
},
|
||||
];
|
||||
const t = treeModel.placeByPosition(roots, 'child', {
|
||||
parentId: null,
|
||||
position: 'a3',
|
||||
});
|
||||
expect(t.map((n) => n.id)).toEqual(['r1', 'child', 'r2', 'rp']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('treeModel.move', () => {
|
||||
it('reorder-before within same parent: moves source to target index', () => {
|
||||
const { tree: t, result } = treeModel.move(fixture, 'a2', {
|
||||
|
||||
@@ -98,6 +98,35 @@ export const treeModel = {
|
||||
return touched ? out : tree;
|
||||
},
|
||||
|
||||
// Position-aware insert for server-authoritative broadcasts. The server does
|
||||
// not know each receiver's local index (clients have different loaded sets and
|
||||
// the root list is paginated), so it sends the node's fractional `position`.
|
||||
// We insert among the already-loaded siblings ordered by `position` so the
|
||||
// order is consistent across clients regardless of which nodes they loaded.
|
||||
// Falls back to appending when `position` is missing.
|
||||
insertByPosition<T extends { position?: string }>(
|
||||
tree: TreeNode<T>[],
|
||||
parentId: string | null,
|
||||
node: TreeNode<T>,
|
||||
): TreeNode<T>[] {
|
||||
const index = (siblings: TreeNode<T>[]): number => {
|
||||
const pos = node.position;
|
||||
if (pos == null) return siblings.length;
|
||||
// First sibling whose position sorts after the new node's position.
|
||||
const at = siblings.findIndex(
|
||||
(s) => s.position != null && s.position > pos,
|
||||
);
|
||||
return at === -1 ? siblings.length : at;
|
||||
};
|
||||
|
||||
if (parentId === null) {
|
||||
return treeModel.insert(tree, null, node, index(tree));
|
||||
}
|
||||
const parent = treeModel.find(tree, parentId);
|
||||
const kids = (parent?.children as TreeNode<T>[] | undefined) ?? [];
|
||||
return treeModel.insert(tree, parentId, node, index(kids));
|
||||
},
|
||||
|
||||
remove<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] {
|
||||
let touched = false;
|
||||
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] => {
|
||||
@@ -186,6 +215,30 @@ export const treeModel = {
|
||||
return treeModel.insert(removed, to.parentId, source, to.index);
|
||||
},
|
||||
|
||||
// Position-aware move for server-authoritative `moveTreeNode` broadcasts. Like
|
||||
// `place`, but instead of an absolute index (which the sender computed against
|
||||
// its own loaded set), it inserts the moved node among the destination's
|
||||
// already-loaded siblings ordered by the node's fractional `position`. This
|
||||
// keeps the visible order correct for every receiver — `place(..., index: 0)`
|
||||
// would wrongly drop the node at the TOP of its new sibling list.
|
||||
// Returns the same array reference (like `place`) when the source is missing
|
||||
// or the destination parent isn't loaded on this client, so callers can detect
|
||||
// that and fall back to removing the node.
|
||||
placeByPosition<T extends { position?: string }>(
|
||||
tree: TreeNode<T>[],
|
||||
sourceId: string,
|
||||
to: { parentId: string | null; position?: string },
|
||||
): TreeNode<T>[] {
|
||||
const source = treeModel.find(tree, sourceId);
|
||||
if (!source) return tree;
|
||||
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
|
||||
const removed = treeModel.remove(tree, sourceId);
|
||||
// Reuse the same position-ordered insertion as `insertByPosition` by
|
||||
// stamping the authoritative position onto the moved node first.
|
||||
const positioned = { ...source, position: to.position } as TreeNode<T>;
|
||||
return treeModel.insertByPosition(removed, to.parentId, positioned);
|
||||
},
|
||||
|
||||
move<T extends object>(
|
||||
tree: TreeNode<T>[],
|
||||
sourceId: string,
|
||||
|
||||
40
apps/client/src/features/page/tree/utils/utils.test.ts
Normal file
40
apps/client/src/features/page/tree/utils/utils.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildTree } from "./utils";
|
||||
import type { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
function page(id: string, position: string): IPage {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
title: id.toUpperCase(),
|
||||
icon: "",
|
||||
position,
|
||||
hasChildren: false,
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
} as IPage;
|
||||
}
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("builds one node per unique page", () => {
|
||||
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
|
||||
expect(tree.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("dedups a duplicate id so the tree has no duplicate node", () => {
|
||||
// A realtime cache write could append a page twice; buildTree must not emit
|
||||
// two references to the same node (which would crash the sidebar render with
|
||||
// a duplicate React key).
|
||||
const tree = buildTree([
|
||||
page("a", "a1"),
|
||||
page("b", "a2"),
|
||||
page("a", "a1"), // duplicate id
|
||||
]);
|
||||
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree.map((n) => n.id).sort()).toEqual(["a", "b"]);
|
||||
// No id appears more than once.
|
||||
const ids = tree.map((n) => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,14 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||
};
|
||||
});
|
||||
|
||||
// Defense-in-depth: a duplicate id in `pages` would push two references to the
|
||||
// same node, producing a duplicate React key that crashes the sidebar render.
|
||||
// Track ids we've already pushed and skip repeats so a stray duplicate from a
|
||||
// realtime cache write can never break the tree.
|
||||
const seen = new Set<string>();
|
||||
pages.forEach((page) => {
|
||||
if (seen.has(page.id)) return;
|
||||
seen.add(page.id);
|
||||
tree.push(pageMap[page.id]);
|
||||
});
|
||||
|
||||
@@ -216,3 +223,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;
|
||||
}
|
||||
|
||||
233
apps/client/src/features/share/components/share-ai-widget.tsx
Normal file
233
apps/client/src/features/share/components/share-ai-widget.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { generateId } from "ai";
|
||||
import {
|
||||
ActionIcon,
|
||||
Affix,
|
||||
Alert,
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowUp,
|
||||
IconSparkles,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChat, type UIMessage } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
|
||||
interface ShareAiWidgetProps {
|
||||
/** The share id (or key) the assistant is scoped to. */
|
||||
shareId: string;
|
||||
/** The page the reader currently has open (context for "this page"). */
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/** Concatenate the visible text parts of a UIMessage. */
|
||||
function messageText(message: UIMessage): string {
|
||||
return (message.parts ?? [])
|
||||
.filter(
|
||||
(p): p is { type: "text"; text: string } =>
|
||||
p?.type === "text" && typeof (p as { text?: string }).text === "string",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
|
||||
*
|
||||
* A stripped version of the authenticated chat: text input only, no chat list,
|
||||
* no history, no persistence, no voice input. The transcript lives only in
|
||||
* memory (this component's `useChat` store) and is sent with `credentials:
|
||||
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
|
||||
* nothing.
|
||||
*/
|
||||
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
// Stable per-mount store key (see ai-chat ChatThread for the rationale on why
|
||||
// useChat needs a stable, non-undefined id to avoid re-creating its store).
|
||||
const storeIdRef = useRef<string>(`share-ai-${generateId()}`);
|
||||
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport<UIMessage>({
|
||||
api: "/api/shares/ai/stream",
|
||||
// Anonymous endpoint: never send cookies/credentials.
|
||||
credentials: "omit",
|
||||
prepareSendMessagesRequest: ({ messages, body }) => ({
|
||||
body: {
|
||||
...body,
|
||||
shareId,
|
||||
pageId,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[shareId, pageId],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error } = useChat({
|
||||
id: storeIdRef.current,
|
||||
transport,
|
||||
});
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
setInput("");
|
||||
void sendMessage({ text });
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
// Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right
|
||||
// "Powered by Gitmost" branding button (share-branding.tsx) without
|
||||
// overlapping it.
|
||||
<Affix position={{ bottom: 80, right: 20 }}>
|
||||
<Tooltip label={t("Ask AI")} position="left">
|
||||
<ActionIcon
|
||||
size="xl"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
aria-label={t("Ask AI")}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<IconSparkles size={22} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Affix position={{ bottom: 80, right: 20 }}>
|
||||
<Paper
|
||||
shadow="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
width: 360,
|
||||
maxWidth: "calc(100vw - 40px)",
|
||||
height: 480,
|
||||
maxHeight: "calc(100vh - 100px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
p="xs"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
{t("Ask AI")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Close")}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} p="sm" scrollbarSize={6} type="scroll">
|
||||
{messages.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="lg">
|
||||
{t("Ask a question about this documentation.")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{messages.map((message) => (
|
||||
<Box
|
||||
key={message.id}
|
||||
style={{
|
||||
alignSelf:
|
||||
message.role === "user" ? "flex-end" : "flex-start",
|
||||
maxWidth: "85%",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="md"
|
||||
bg={
|
||||
message.role === "user"
|
||||
? "var(--mantine-color-blue-light)"
|
||||
: "var(--mantine-color-default-hover)"
|
||||
}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{messageText(message) ||
|
||||
(isStreaming ? t("Thinking…") : "")}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
mt="sm"
|
||||
title={t("Something went wrong")}
|
||||
>
|
||||
{t("The assistant is unavailable right now. Please try again.")}
|
||||
</Alert>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<Group
|
||||
gap="xs"
|
||||
p="xs"
|
||||
align="flex-end"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Textarea
|
||||
style={{ flex: 1 }}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
placeholder={t("Ask a question…")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
aria-label={isStreaming ? t("Stop") : t("Send")}
|
||||
onClick={isStreaming ? () => stop() : handleSend}
|
||||
disabled={!isStreaming && input.trim().length === 0}
|
||||
>
|
||||
{isStreaming ? <IconX size={18} /> : <IconArrowUp size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import { Affix, Button } from "@mantine/core";
|
||||
|
||||
export default function ShareBranding() {
|
||||
return (
|
||||
// Pinned to the bottom-RIGHT corner. The AI assistant FAB
|
||||
// (share-ai-widget.tsx) is stacked ABOVE this with a higher `bottom`
|
||||
// offset, so the two Affix elements never overlap.
|
||||
<Affix position={{ bottom: 20, right: 20 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href="https://docmost.com?ref=public-share"
|
||||
href="https://github.com/vvzvlad/gitmost?ref=public-share"
|
||||
>
|
||||
Powered by Docmost
|
||||
Powered by Gitmost
|
||||
</Button>
|
||||
</Affix>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface ISharedPage extends IShare {
|
||||
sharedPage: { id: string; slugId: string; title: string; icon: string };
|
||||
};
|
||||
features?: string[];
|
||||
// Whether the anonymous public-share AI assistant is enabled for the
|
||||
// workspace (server-resolved). Gates the "Ask AI" widget.
|
||||
aiAssistant?: boolean;
|
||||
}
|
||||
|
||||
export interface IShareForPage extends IShare {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -54,13 +54,17 @@ export const useTreeSocket = () => {
|
||||
break;
|
||||
case "addTreeNode":
|
||||
setTreeData((prev) => {
|
||||
// Idempotent: the author already inserted the node optimistically,
|
||||
// and a node may be re-delivered — never insert a duplicate id.
|
||||
if (treeModel.find(prev, event.payload.data.id)) return prev;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
let next = treeModel.insert(
|
||||
// Insert by `position` among already-loaded siblings (not the
|
||||
// sender's absolute index) so order is consistent across clients
|
||||
// with different loaded sets.
|
||||
let next = treeModel.insertByPosition(
|
||||
prev,
|
||||
newParentId,
|
||||
event.payload.data,
|
||||
event.payload.index,
|
||||
);
|
||||
// Mirror the emitter: flip new parent's hasChildren to true so
|
||||
// the chevron renders on the receiver.
|
||||
@@ -80,22 +84,50 @@ export const useTreeSocket = () => {
|
||||
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
|
||||
const newParentId = event.payload.parentId as string | null;
|
||||
|
||||
const placed = treeModel.place(prev, event.payload.id, {
|
||||
// Place the node by its fractional `position` among the new
|
||||
// siblings — NOT by the sender's absolute `index` (the sender
|
||||
// computed that against its own loaded set, which differs from
|
||||
// this receiver's). Using the position keeps the visible order
|
||||
// correct on every client; placing at `index: 0` would wrongly
|
||||
// drop reordered/moved nodes at the top of their new sibling list.
|
||||
const placed = treeModel.placeByPosition(prev, event.payload.id, {
|
||||
parentId: newParentId,
|
||||
index: event.payload.index,
|
||||
position: event.payload.position,
|
||||
});
|
||||
// `place` silently returns the same reference if the destination
|
||||
// parent isn't loaded on this client. Falling back to removing the
|
||||
// source keeps the UI consistent (the source will reappear when
|
||||
// the user expands the new parent and lazy-load fetches it).
|
||||
// `placeByPosition` silently returns the same reference if the
|
||||
// destination parent isn't loaded on this client. Falling back to
|
||||
// removing the source keeps the UI consistent (the source will
|
||||
// reappear when the user expands the new parent and lazy-load
|
||||
// fetches it).
|
||||
if (placed === prev) {
|
||||
return treeModel.remove(prev, event.payload.id);
|
||||
}
|
||||
|
||||
let next = treeModel.update(placed, event.payload.id, {
|
||||
// Apply the authoritative node fields the move payload carries
|
||||
// (`pageData`) so receivers don't keep a stale title/icon/chevron
|
||||
// on the moved node. `placeByPosition` already set `position`.
|
||||
const pageData = event.payload.pageData as
|
||||
| {
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const patch: Partial<SpaceTreeNode> = {
|
||||
position: event.payload.position,
|
||||
parentPageId: newParentId,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
// Honest type: a root move has a null parent, so this is
|
||||
// `string | null`, not always `string`.
|
||||
parentPageId: newParentId as string | null,
|
||||
};
|
||||
if (pageData) {
|
||||
// The tree node stores the title as `name`.
|
||||
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
|
||||
if (pageData.icon !== undefined)
|
||||
patch.icon = pageData.icon ?? undefined;
|
||||
if (pageData.hasChildren !== undefined)
|
||||
patch.hasChildren = pageData.hasChildren;
|
||||
}
|
||||
let next = treeModel.update(placed, event.payload.id, patch);
|
||||
|
||||
// Mirror the emitter's hasChildren bookkeeping so both clients
|
||||
// converge to the same chevron state.
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useCreateAiRoleMutation,
|
||||
useUpdateAiRoleMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import {
|
||||
IAiRole,
|
||||
IAiRoleCreate,
|
||||
IAiRoleUpdate,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// Supported drivers for the optional model override (mirrors server AI_DRIVERS).
|
||||
// "" => use the workspace default driver/model.
|
||||
const DRIVER_OPTIONS = [
|
||||
{ value: "", label: "Workspace default" },
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
];
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
emoji: z.string(),
|
||||
description: z.string(),
|
||||
instructions: z.string().min(1),
|
||||
// "" => no driver override (use the workspace driver).
|
||||
driver: z.enum(["", "openai", "gemini", "ollama"]),
|
||||
chatModel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface AiAgentRoleFormProps {
|
||||
// When provided, edits an existing role; otherwise creates one.
|
||||
role?: IAiRole;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AiAgentRoleForm({
|
||||
role,
|
||||
onClose,
|
||||
}: AiAgentRoleFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = Boolean(role);
|
||||
|
||||
const createMutation = useCreateAiRoleMutation();
|
||||
const updateMutation = useUpdateAiRoleMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: role?.name ?? "",
|
||||
emoji: role?.emoji ?? "",
|
||||
description: role?.description ?? "",
|
||||
instructions: role?.instructions ?? "",
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-hydrate when the target role changes (reusing the modal).
|
||||
useEffect(() => {
|
||||
form.setValues({
|
||||
name: role?.name ?? "",
|
||||
emoji: role?.emoji ?? "",
|
||||
description: role?.description ?? "",
|
||||
instructions: role?.instructions ?? "",
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
});
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [role?.id]);
|
||||
|
||||
// Build the model override payload: null when neither a driver nor a model id
|
||||
// is set (use the workspace default).
|
||||
function resolveModelConfig(values: FormValues) {
|
||||
const driver = values.driver || undefined;
|
||||
const chatModel = values.chatModel.trim() || undefined;
|
||||
if (!driver && !chatModel) return null;
|
||||
return { driver, chatModel };
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const modelConfig = resolveModelConfig(values);
|
||||
|
||||
if (isEdit && role) {
|
||||
const payload: IAiRoleUpdate = {
|
||||
id: role.id,
|
||||
name: values.name,
|
||||
emoji: values.emoji,
|
||||
description: values.description,
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
};
|
||||
await updateMutation.mutateAsync(payload);
|
||||
} else {
|
||||
const payload: IAiRoleCreate = {
|
||||
name: values.name,
|
||||
emoji: values.emoji || undefined,
|
||||
description: values.description || undefined,
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
};
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("Role name")}
|
||||
placeholder={t("e.g. Proofreader")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Emoji")}
|
||||
description={t("Optional. Shown as the chat badge.")}
|
||||
maxLength={8}
|
||||
{...form.getInputProps("emoji")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Description")}
|
||||
description={t("Optional. A short note about what this role does.")}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t("Instructions")}
|
||||
description={t(
|
||||
"The built-in safety framework is always added automatically.",
|
||||
)}
|
||||
autosize
|
||||
minRows={4}
|
||||
maxRows={14}
|
||||
{...form.getInputProps("instructions")}
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label={t("Model provider override")}
|
||||
description={t("Optional. Defaults to the workspace provider.")}
|
||||
data={DRIVER_OPTIONS}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
{...form.getInputProps("driver")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("Model override")}
|
||||
description={t("Optional. Defaults to the workspace model.")}
|
||||
placeholder={t("e.g. gpt-4o-mini")}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
{t(
|
||||
"If you choose a different provider, it must already be configured in AI settings.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
checked={form.values.enabled}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue("enabled", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button type="button" variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(form.values)}
|
||||
disabled={isSaving || !form.isValid()}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
useAiRolesQuery,
|
||||
useDeleteAiRoleMutation,
|
||||
useUpdateAiRoleMutation,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
|
||||
|
||||
/**
|
||||
* Admin section: list / add / edit / delete reusable agent roles. A role
|
||||
* replaces the agent's persona (instructions) and may optionally override the
|
||||
* model; the safety framework is always still applied. The add/edit form lives
|
||||
* in `AiAgentRoleForm`, opened in a modal.
|
||||
*/
|
||||
export default function AiAgentRoles() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const { data: roles, isLoading } = useAiRolesQuery(isAdmin);
|
||||
const updateMutation = useUpdateAiRoleMutation();
|
||||
const deleteMutation = useDeleteAiRoleMutation();
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
// The role being edited; undefined => the modal is in "create" mode.
|
||||
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Only workspace admins can manage AI provider settings.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditing(undefined);
|
||||
open();
|
||||
}
|
||||
|
||||
function openEdit(role: IAiRole) {
|
||||
setEditing(role);
|
||||
open();
|
||||
}
|
||||
|
||||
function confirmDelete(role: IAiRole) {
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete role"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete this role?")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteMutation.mutate(role.id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
bg="green.6"
|
||||
style={{ borderRadius: "50%", flex: "none" }}
|
||||
/>
|
||||
<Text fw={600}>{t("Agent roles")}</Text>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add role")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{!isLoading && (!roles || roles.length === 0) && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{t("No roles configured")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="xs" mt="sm">
|
||||
{roles?.map((role) => (
|
||||
<Group key={role.id} justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{role.emoji ? `${role.emoji} ` : ""}
|
||||
{role.name}
|
||||
</Text>
|
||||
{role.modelConfig?.chatModel && (
|
||||
<Badge size="xs" variant="light">
|
||||
{role.modelConfig.chatModel}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{role.description && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{role.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={role.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: role.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(role)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(role)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={editing ? t("Edit role") : t("Add role")}
|
||||
size="lg"
|
||||
>
|
||||
{/* Remount the form per target so its internal state re-hydrates. */}
|
||||
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,21 @@ interface AiMcpServerFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Build the form's field values from a (possibly undefined) server. Used both
|
||||
// for the initial mount and for re-hydration when the modal is reused for a
|
||||
// different server, so the two stay in sync. authHeader is always empty: it is
|
||||
// a write-only secret buffer never echoed back from the server.
|
||||
function buildInitialValues(server?: IAiMcpServer): FormValues {
|
||||
return {
|
||||
name: server?.name ?? "",
|
||||
transport: server?.transport ?? "http",
|
||||
url: server?.url ?? "",
|
||||
authHeader: "",
|
||||
toolAllowlist: server?.toolAllowlist ?? [],
|
||||
enabled: server?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
|
||||
const TAVILY_PRESET = {
|
||||
name: "Tavily",
|
||||
@@ -72,26 +87,12 @@ export default function AiMcpServerForm({
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: server?.name ?? "",
|
||||
transport: server?.transport ?? "http",
|
||||
url: server?.url ?? "",
|
||||
authHeader: "",
|
||||
toolAllowlist: server?.toolAllowlist ?? [],
|
||||
enabled: server?.enabled ?? true,
|
||||
},
|
||||
initialValues: buildInitialValues(server),
|
||||
});
|
||||
|
||||
// Re-hydrate when the target server changes (e.g. reusing the modal).
|
||||
useEffect(() => {
|
||||
form.setValues({
|
||||
name: server?.name ?? "",
|
||||
transport: server?.transport ?? "http",
|
||||
url: server?.url ?? "",
|
||||
authHeader: "",
|
||||
toolAllowlist: server?.toolAllowlist ?? [],
|
||||
enabled: server?.enabled ?? true,
|
||||
});
|
||||
form.setValues(buildInitialValues(server));
|
||||
form.resetDirty();
|
||||
setHasHeaders(server?.hasHeaders ?? false);
|
||||
setHeadersCleared(false);
|
||||
|
||||
@@ -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";
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
IAiSettingsUpdate,
|
||||
SttApiStyle,
|
||||
} from "@/features/workspace/services/ai-settings-service.ts";
|
||||
import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import AiMcpServers from "./ai-mcp-servers.tsx";
|
||||
|
||||
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
|
||||
@@ -44,6 +47,11 @@ import AiMcpServers from "./ai-mcp-servers.tsx";
|
||||
// (empty means "leave unchanged" unless explicitly cleared).
|
||||
const formSchema = z.object({
|
||||
chatModel: z.string(),
|
||||
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
||||
publicShareChatModel: z.string(),
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona.
|
||||
publicShareAssistantRoleId: z.string(),
|
||||
embeddingModel: z.string(),
|
||||
baseUrl: z.string(),
|
||||
// Embedding-specific base URL. Empty means "use the chat base URL".
|
||||
@@ -60,8 +68,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 +86,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +150,10 @@ export default function AiProviderSettings() {
|
||||
const embedTest = useTestAiConnectionMutation();
|
||||
const sttTest = useTestAiConnectionMutation();
|
||||
|
||||
// Agent roles drive the public-share assistant identity picker. Admin-gated
|
||||
// (the component returns early for non-admins), same as the AI settings query.
|
||||
const { data: roles } = useAiRolesQuery(isAdmin);
|
||||
|
||||
// Workspace-level feature toggles live in the card headers.
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [chatEnabled, setChatEnabled] = useState<boolean>(
|
||||
@@ -114,9 +165,17 @@ export default function AiProviderSettings() {
|
||||
const [dictationEnabled, setDictationEnabled] = useState<boolean>(
|
||||
workspace?.settings?.ai?.dictation ?? false,
|
||||
);
|
||||
const [publicShareAssistantEnabled, setPublicShareAssistantEnabled] =
|
||||
useState<boolean>(
|
||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
||||
);
|
||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||
const [
|
||||
publicShareAssistantToggleLoading,
|
||||
setPublicShareAssistantToggleLoading,
|
||||
] = useState(false);
|
||||
|
||||
// Whether a key is currently stored server-side (drives the placeholder).
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
@@ -136,6 +195,8 @@ export default function AiProviderSettings() {
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
chatModel: "",
|
||||
publicShareChatModel: "",
|
||||
publicShareAssistantRoleId: "",
|
||||
embeddingModel: "",
|
||||
baseUrl: "",
|
||||
embeddingBaseUrl: "",
|
||||
@@ -155,6 +216,8 @@ export default function AiProviderSettings() {
|
||||
if (!settings) return;
|
||||
form.setValues({
|
||||
chatModel: settings.chatModel ?? "",
|
||||
publicShareChatModel: settings.publicShareChatModel ?? "",
|
||||
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
||||
embeddingModel: settings.embeddingModel ?? "",
|
||||
baseUrl: settings.baseUrl ?? "",
|
||||
embeddingBaseUrl: settings.embeddingBaseUrl ?? "",
|
||||
@@ -181,6 +244,12 @@ export default function AiProviderSettings() {
|
||||
// Everything is OpenAI-compatible.
|
||||
driver: "openai",
|
||||
chatModel: values.chatModel,
|
||||
// Cheap model id for the anonymous public-share assistant; empty falls
|
||||
// back to chatModel server-side.
|
||||
publicShareChatModel: values.publicShareChatModel,
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona server-side.
|
||||
publicShareAssistantRoleId: values.publicShareAssistantRoleId,
|
||||
embeddingModel: values.embeddingModel,
|
||||
// The embedding base URL is optional; empty falls back to the chat base
|
||||
// URL server-side.
|
||||
@@ -344,6 +413,37 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic toggle for the anonymous public-share AI assistant
|
||||
// (settings.ai.publicShareAssistant). When off, the public endpoint 404s.
|
||||
async function handleTogglePublicShareAssistant(value: boolean) {
|
||||
setPublicShareAssistantToggleLoading(true);
|
||||
const previous = publicShareAssistantEnabled;
|
||||
setPublicShareAssistantEnabled(value);
|
||||
try {
|
||||
const updated = await updateWorkspace({
|
||||
aiPublicShareAssistant: value,
|
||||
});
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
ai: { ...updated.settings?.ai, publicShareAssistant: value },
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
setPublicShareAssistantEnabled(previous);
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setPublicShareAssistantToggleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Admins only — match the previous behavior.
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
@@ -353,21 +453,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(
|
||||
@@ -383,6 +485,34 @@ export default function AiProviderSettings() {
|
||||
|
||||
const monoFont = "ui-monospace, Menlo, monospace";
|
||||
|
||||
// Public-share assistant identity options: a leading "built-in persona" entry
|
||||
// (empty value, the server default) plus every enabled agent role. If the saved
|
||||
// role was since disabled it is filtered out of the enabled list, so surface it
|
||||
// explicitly (labeled "disabled") instead of letting the Select render a blank
|
||||
// field for a still-stored id.
|
||||
const selectedRoleId = form.values.publicShareAssistantRoleId;
|
||||
const enabledRoles = (roles ?? []).filter((r: IAiRole) => r.enabled);
|
||||
const selectedDisabledRole =
|
||||
selectedRoleId.length > 0 &&
|
||||
!enabledRoles.some((r: IAiRole) => r.id === selectedRoleId)
|
||||
? (roles ?? []).find((r: IAiRole) => r.id === selectedRoleId)
|
||||
: undefined;
|
||||
const roleOptions = [
|
||||
{ value: "", label: t("Built-in assistant persona") },
|
||||
...enabledRoles.map((r: IAiRole) => ({
|
||||
value: r.id,
|
||||
label: r.emoji ? `${r.emoji} ${r.name}` : r.name,
|
||||
})),
|
||||
...(selectedDisabledRole
|
||||
? [
|
||||
{
|
||||
value: selectedDisabledRole.id,
|
||||
label: `${selectedDisabledRole.emoji ? `${selectedDisabledRole.emoji} ` : ""}${selectedDisabledRole.name} (${t("disabled")})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
{/* Section header */}
|
||||
@@ -404,7 +534,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 +560,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
|
||||
@@ -455,6 +600,50 @@ export default function AiProviderSettings() {
|
||||
{t("Resolves to {{url}}", { url: chatResolved })}
|
||||
</Text>
|
||||
|
||||
{/* Anonymous public-share assistant: a single master toggle + an
|
||||
optional cheaper model id. Reuses this card's driver/URL/key. */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||
<Text fw={600} size="sm">
|
||||
{t("Public share assistant")}
|
||||
</Text>
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
labelPosition="left"
|
||||
checked={publicShareAssistantEnabled}
|
||||
disabled={publicShareAssistantToggleLoading}
|
||||
onChange={(e) =>
|
||||
handleTogglePublicShareAssistant(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4} mb="xs">
|
||||
{t(
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||
)}
|
||||
</Text>
|
||||
<TextInput
|
||||
label={t("Public assistant model")}
|
||||
placeholder={t("Defaults to the chat model")}
|
||||
disabled={isLoading || !publicShareAssistantEnabled}
|
||||
{...form.getInputProps("publicShareChatModel")}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"Optional cheaper model id for the public assistant. Empty uses the chat model above.",
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
mt="sm"
|
||||
label={t("Assistant identity")}
|
||||
description={t(
|
||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||
)}
|
||||
data={roleOptions}
|
||||
allowDeselect={false}
|
||||
disabled={isLoading || !publicShareAssistantEnabled}
|
||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
||||
/>
|
||||
|
||||
<Group mt="md" align="center">
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -514,7 +703,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 +724,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 +829,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 +852,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
|
||||
|
||||
@@ -16,6 +16,11 @@ export type SttApiStyle = "multipart" | "json";
|
||||
export interface IAiSettings {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona.
|
||||
publicShareAssistantRoleId?: string;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
embeddingBaseUrl?: string;
|
||||
@@ -42,6 +47,10 @@ export interface IAiSettings {
|
||||
export interface IAiSettingsUpdate {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
publicShareChatModel?: string;
|
||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||
// built-in locked persona.
|
||||
publicShareAssistantRoleId?: string;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
embeddingBaseUrl?: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface IWorkspace {
|
||||
mcpEnabled?: boolean;
|
||||
aiChat?: boolean;
|
||||
aiDictation?: boolean;
|
||||
aiPublicShareAssistant?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
@@ -53,6 +54,7 @@ export interface IWorkspaceAiSettings {
|
||||
mcp?: boolean;
|
||||
chat?: boolean;
|
||||
dictation?: boolean;
|
||||
publicShareAssistant?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.tsx";
|
||||
import AiProviderSettings from "@/features/workspace/components/settings/components/ai-provider-settings.tsx";
|
||||
import AiAgentRoles from "@/features/workspace/components/settings/components/ai-agent-roles.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -20,6 +21,13 @@ export default function AiSettings() {
|
||||
<SettingsTitle title={t("AI")} />
|
||||
{isAdmin && <AiProviderSettings />}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<AiAgentRoles />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<McpSettings />
|
||||
|
||||
@@ -8,6 +8,7 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import ShareBranding from "@/features/share/components/share-branding.tsx";
|
||||
import ShareAiWidget from "@/features/share/components/share-ai-widget.tsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
sharedPageFullWidthAtom,
|
||||
@@ -74,6 +75,12 @@ export default function SharedPage() {
|
||||
</Container>
|
||||
|
||||
{data && !shareId && !(data.features?.length > 0) && <ShareBranding />}
|
||||
|
||||
{/* Anonymous "Ask AI" widget — only when the workspace enables the
|
||||
public-share assistant (server-resolved flag on /shares/page-info). */}
|
||||
{data?.aiAssistant && data.share?.id && data.page?.id && (
|
||||
<ShareAiWidget shareId={data.share.id} pageId={data.page.id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user