diff --git a/.env.example b/.env.example index fbd32428..b04078e3 100644 --- a/.env.example +++ b/.env.example @@ -69,15 +69,50 @@ DEBUG_DB=false # Log http requests LOG_HTTP=false -# MCP server (community): service account the embedded MCP uses to talk to this Docmost instance +# MCP server (community): the embedded /mcp endpoint authenticates PER USER. +# An MCP client authenticates with one of: +# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own +# Docmost login/password. The server validates the credentials and the MCP +# session then acts under that user's permissions (edits attributed to them). +# - Bearer access JWT: `Authorization: Bearer ` (the user's +# `authToken` cookie value). Validated as an ACCESS token. +# +# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR +# Bearer credentials and these are set, the MCP session falls back to this +# shared service account (back-compat; useful for CI/scripts). Leave BLANK to +# require per-user credentials. MCP_DOCMOST_EMAIL= MCP_DOCMOST_PASSWORD= # MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api -# Optional bearer token to protect the /mcp endpoint. If unset, /mcp relies on -# the workspace MCP toggle and network isolation (do not expose the port publicly). +# Optional shared guard for the /mcp endpoint. When set, every /mcp request must +# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now +# carries the per-user credentials). When unset, /mcp relies on the per-user +# credentials above plus the workspace MCP toggle and network isolation (do not +# expose the port publicly). # MCP_TOKEN= # MCP_SESSION_IDLE_MS=1800000 # Per-embedding-call timeout in milliseconds for the RAG indexer. # A slow/hung embeddings endpoint fails after this and the batch continues. # AI_EMBEDDING_TIMEOUT_MS=120000 + +# --- Anonymous public-share AI assistant --- +# Opt-in per workspace (AI settings -> "public share assistant"; off by default). +# When enabled, anonymous visitors of a published share can ask an AI about that +# share at POST /api/shares/ai/stream. The assistant is read-only and hard-scoped +# to the single share tree, but every call spends real tokens on the workspace +# owner's configured AI provider. +# +# DEPLOYMENT REQUIREMENT: the per-IP rate limit on this endpoint is only +# effective behind a trusted reverse proxy that OVERWRITES (not appends) +# X-Forwarded-For with the real client IP. The app runs with trustProxy, so +# without such a proxy an attacker can rotate X-Forwarded-For to evade the +# per-IP limit. Put this endpoint (and the app) behind a proxy you control that +# sets X-Forwarded-For to the real client IP. +# +# Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent, +# keyed by the server-resolved workspace id) bounds the owner's bill even if the +# per-IP limit is fully evaded. It is a COST backstop, not an access control, +# and FAILS OPEN if Redis is unavailable. Override the hourly cap below +# (default: 300 calls per workspace per rolling hour). +# SHARE_AI_WORKSPACE_MAX_PER_HOUR=300 diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 736040b7..5959983e 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -3,7 +3,7 @@ name: Develop on: push: branches: - - main + - develop workflow_dispatch: concurrency: diff --git a/apps/client/package.json b/apps/client/package.json index 00a25bbe..0433c97f 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.91.0", + "version": "0.93.0", "scripts": { "dev": "vite", "build": "tsc && vite build", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..c04fc72d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -529,6 +529,7 @@ "Add 2FA method": "Add 2FA method", "Backup codes": "Backup codes", "Disable": "Disable", + "disabled": "disabled", "Invalid verification code": "Invalid verification code", "New backup codes have been generated": "New backup codes have been generated", "Failed to regenerate backup codes": "Failed to regenerate backup codes", @@ -977,6 +978,9 @@ "Page menu": "Page menu", "Expand": "Expand", "Collapse": "Collapse", + "Expand all": "Expand all", + "Collapse all": "Collapse all", + "Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}", "Comment menu": "Comment menu", "Group menu": "Group menu", "Show hidden breadcrumbs": "Show hidden breadcrumbs", @@ -1122,6 +1126,19 @@ "Page menu for {{name}}": "Page menu for {{name}}", "Create subpage of {{name}}": "Create subpage of {{name}}", "AI chat": "AI chat", + "Ask a question about this documentation.": "Ask a question about this documentation.", + "Ask a question…": "Ask a question…", + "Thinking…": "Thinking…", + "The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.", + "Public share assistant": "Public share assistant", + "Enabled": "Enabled", + "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.", + "Public assistant model": "Public assistant model", + "Defaults to the chat model": "Defaults to the chat model", + "Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Optional cheaper model id for the public assistant. Empty uses the chat model above.", + "Assistant identity": "Assistant identity", + "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.", + "Built-in assistant persona": "Built-in assistant persona", "Minimize": "Minimize", "Current context size": "Current context size", "AI agent": "AI agent", @@ -1162,6 +1179,10 @@ "Voice dictation is not available yet.": "Voice dictation is not available yet.", "Test endpoint": "Test endpoint", "Save endpoints": "Save endpoints", + "Configured and enabled": "Configured and enabled", + "Configured but disabled": "Configured but disabled", + "Enabled but not configured": "Enabled but not configured", + "Not configured": "Not configured", "External tools": "External tools", "Gitmost as MCP client": "Gitmost as MCP client", "Servers the agent calls out to.": "Servers the agent calls out to.", @@ -1195,5 +1216,26 @@ "Request format": "Request format", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", - "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)" + "OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, base64 audio)", + "Agent role": "Agent role", + "Universal assistant": "Universal assistant", + "Add role": "Add role", + "Edit role": "Edit role", + "Role name": "Role name", + "e.g. Proofreader": "e.g. Proofreader", + "Optional. Shown as the chat badge.": "Optional. Shown as the chat badge.", + "Optional. A short note about what this role does.": "Optional. A short note about what this role does.", + "Instructions": "Instructions", + "The built-in safety framework is always added automatically.": "The built-in safety framework is always added automatically.", + "Model provider override": "Model provider override", + "Optional. Defaults to the workspace provider.": "Optional. Defaults to the workspace provider.", + "Model override": "Model override", + "Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.", + "e.g. gpt-4o-mini": "e.g. gpt-4o-mini", + "If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.", + "Agent roles": "Agent roles", + "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.", + "No roles configured": "No roles configured", + "Delete role": "Delete role", + "Are you sure you want to delete this role?": "Are you sure you want to delete this role?" } diff --git a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts index b3707cb9..027a8c50 100644 --- a/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts +++ b/apps/client/src/features/ai-chat/atoms/ai-chat-atom.ts @@ -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(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 diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 122f80ff..1b9012c5 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -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() { /> {t("AI chat")} + {/* Role badge for the active chat (emoji + name). Shown only when the + chat is bound to a role that still exists. */} + {activeChat?.roleName && ( + + {activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""} + {activeChat.roleName} + + )} +
{contextTokens > 0 && ( @@ -400,7 +425,16 @@ export default function AiChatWindow() { >
setHistoryOpen((o) => !o)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setHistoryOpen((o) => !o); + } + }} > + {/* 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 && ( +
+