diff --git a/.env.example b/.env.example index 29fae1ddd..2cb945295 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,12 @@ SSO_USER_LIST_PLATFORM_ID= SSO_USER_LIST_SECRET= SSO_ARCH_TOKEN= +# Platform-to-platform shared secret for the iself agent API-key provisioning +# endpoint (POST /api/iself/api-keys/public/create). The caller must send this +# value in the `x-iself-secret` request header. Leave empty to disable the +# endpoint (it returns 503). +ISELF_API_KEY_SECRET= + # LLM Wiki model/provider/API key/base URL settings are configured from # Settings > AI > Models / the ai_model_configs database table only. Environment # variables below control runtime limits, not model selection. @@ -112,5 +118,4 @@ KNOWLEDGE_SPACE_SLICE_MAX_MS=300000 KNOWLEDGE_SPACE_HEARTBEAT_MS=30000 KNOWLEDGE_SPACE_LEASE_TTL_MS=180000 KNOWLEDGE_PAGE_DEADLINE_MS=900000 -KNOWLEDGE_AGGREGATE_DEADLINE_MS=300000 KNOWLEDGE_IMAGE_JOB_DEADLINE_MS=180000 diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 4dc78378f..06e9f626a 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Alle Unterseiten der aktuellen Seite auflisten", "Attachments": "Anhänge", "All spaces": "Alle Bereiche", + "All permissions": "Alle Berechtigungen", + "Filter by permission": "Nach Berechtigung filtern", "Unknown": "Unbekannt", "Find a space": "Einen Bereich finden", "Search in all your spaces": "In all Ihren Bereichen suchen", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b33c111a6..f773bd91b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -79,6 +79,12 @@ "Failed to publish page": "Failed to publish page", "Page publish started": "Page publish started", "Publish now": "Publish now", + "Compiled": "Compiled", + "Compiling": "Compiling", + "Compilation failed": "Compilation failed", + "Not compiled": "Not compiled", + "Needs recompilation": "Needs recompilation", + "Publish available in {{time}}": "Publish available in {{time}}", "Read": "Read", "Edit group": "Edit group", "Email": "Email", @@ -126,6 +132,15 @@ "Invited members will be granted access to spaces the groups can access": "Invited members will be granted access to spaces the groups can access", "Join the workspace": "Join the workspace", "Language": "Language", + "Member details": "Member details", + "View member details": "View member details", + "Last login": "Last login", + "Timezone": "Timezone", + "No groups": "This member does not belong to any groups.", + "External": "External", + "Retry": "Retry", + "Failed to load member details": "Failed to load member details", + "Workspace member not found": "Workspace member not found", "Light": "Light", "Link copied": "Link copied", "Login": "Login", @@ -156,6 +171,10 @@ "Page history": "Page history", "Select version": "Select version", "Highlight changes": "Highlight changes", + "Analyzing changes...": "Analyzing changes...", + "Loading previous version...": "Loading previous version...", + "Unable to analyze changes. The document is still available.": "Unable to analyze changes. The document is still available.", + "This version is too large for detailed change highlighting.": "This version is too large for detailed change highlighting.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Pages": "Pages", "pages": "pages", @@ -169,6 +188,12 @@ "Print PDF": "Print PDF", "Profile": "Profile", "Recently updated": "Recently updated", + "Recently viewed": "Recently viewed", + "Last 30 days": "Last 30 days", + "No recently viewed pages": "No recently viewed pages", + "Failed to load recently viewed pages": "Failed to load recently viewed pages", + "Pages you open will appear here for 30 days.": "Pages you open will appear here for 30 days.", + "Earlier": "Earlier", "Remove": "Remove", "Remove from favorites": "Remove from favorites", "Remove group member": "Remove group member", @@ -644,6 +669,8 @@ "Reset": "Reset", "Attachments": "Attachments", "All spaces": "All spaces", + "All permissions": "All permissions", + "Filter by permission": "Filter by permission", "Unknown": "Unknown", "Find a space": "Find a space", "Search in all your spaces": "Search in all your spaces", diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index b77d518e0..3a6ec8394 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Listar todas las subpáginas de la página actual", "Attachments": "Archivos adjuntos", "All spaces": "Todos los espacios", + "All permissions": "Todos los permisos", + "Filter by permission": "Filtrar por permiso", "Unknown": "Desconocido", "Find a space": "Buscar un espacio", "Search in all your spaces": "Buscar en todos tus espacios", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 1f3b0062b..5494193a9 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Lister toutes les sous-pages de la page actuelle", "Attachments": "Pièces jointes", "All spaces": "Tous les espaces", + "All permissions": "Toutes les autorisations", + "Filter by permission": "Filtrer par autorisation", "Unknown": "Inconnu", "Find a space": "Trouver un espace", "Search in all your spaces": "Rechercher dans tous vos espaces", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 2bd25a383..83b1496df 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Elenca tutte le sottopagine della pagina corrente", "Attachments": "Allegati", "All spaces": "Tutti gli spazi", + "All permissions": "Tutti i permessi", + "Filter by permission": "Filtra per permesso", "Unknown": "Sconosciuto", "Find a space": "Trova uno spazio", "Search in all your spaces": "Cerca in tutti i tuoi spazi", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index a020e3bfe..fccd1dfd4 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "現在のページのすべてのサブページを一覧表示", "Attachments": "添付ファイル", "All spaces": "すべてのスペース", + "All permissions": "すべての権限", + "Filter by permission": "権限で絞り込む", "Unknown": "不明", "Find a space": "スペースを探す", "Search in all your spaces": "すべてのスペースを検索", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index c4aa88a5e..6e288df54 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "현재 페이지의 모든 하위 페이지 나열", "Attachments": "첨부파일", "All spaces": "모든 스페이스", + "All permissions": "모든 권한", + "Filter by permission": "권한으로 필터링", "Unknown": "알 수 없음", "Find a space": "스페이스 찾기", "Search in all your spaces": "모든 스페이스에서 검색", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 754d2b4fa..bde839ceb 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Toon alle subpagina's van de huidige pagina", "Attachments": "Bijlagen", "All spaces": "Alle ruimtes", + "All permissions": "Alle rechten", + "Filter by permission": "Filteren op recht", "Unknown": "Onbekend", "Find a space": "Zoek een ruimte", "Search in all your spaces": "Zoek in al je ruimtes", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 85731f407..619a2814a 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Listar todas as subpáginas da página atual", "Attachments": "Anexos", "All spaces": "Todos os espaços", + "All permissions": "Todas as permissões", + "Filter by permission": "Filtrar por permissão", "Unknown": "Desconhecido", "Find a space": "Encontrar um espaço", "Search in all your spaces": "Pesquisar em todos os seus espaços", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index ce833585c..f486e2d2f 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Показать все подстраницы текущей страницы", "Attachments": "Вложения", "All spaces": "Все пространства", + "All permissions": "Все разрешения", + "Filter by permission": "Фильтр по разрешению", "Unknown": "Неизвестно", "Find a space": "Найти пространство", "Search in all your spaces": "Искать во всех ваших пространствах", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 0d36818b2..5f8440662 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -604,6 +604,8 @@ "List all subpages of the current page": "Показати всі підсторінки поточної сторінки", "Attachments": "Вкладення", "All spaces": "Усі простори", + "All permissions": "Усі дозволи", + "Filter by permission": "Фільтрувати за дозволом", "Unknown": "Невідомо", "Find a space": "Знайти простір", "Search in all your spaces": "Шукати в усіх ваших просторах", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 4440275ce..e423c896b 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -79,6 +79,12 @@ "Failed to publish page": "页面发布失败", "Page publish started": "已开始发布页面", "Publish now": "立即发布", + "Compiled": "已完成编译", + "Compiling": "正在编译", + "Compilation failed": "最近一次编译失败", + "Not compiled": "尚未编译", + "Needs recompilation": "需要重新编译", + "Publish available in {{time}}": "{{time}}后可再次发布", "Read": "读取", "Edit group": "编辑群组", "Email": "电子邮箱", @@ -126,6 +132,15 @@ "Invited members will be granted access to spaces the groups can access": "被邀请的成员将被授予访问群组可以访问的空间的权限", "Join the workspace": "加入工作空间", "Language": "语言", + "Member details": "成员详情", + "View member details": "查看成员详情", + "Last login": "最近登录", + "Timezone": "时区", + "No groups": "该成员尚未加入任何群组。", + "External": "外部群组", + "Retry": "重试", + "Failed to load member details": "成员详情加载失败", + "Workspace member not found": "未找到该成员", "Light": "浅色", "Link copied": "链接已复制", "Login": "登录", @@ -155,6 +170,10 @@ "Page history": "页面历史", "Select version": "选择版本", "Highlight changes": "突出显示更改", + "Analyzing changes...": "正在分析变更...", + "Loading previous version...": "正在加载上一版本...", + "Unable to analyze changes. The document is still available.": "无法分析变更,文档仍可正常查看。", + "This version is too large for detailed change highlighting.": "该版本内容过大,无法显示详细变更高亮。", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Pages": "页面", "pages": "个页面", @@ -168,6 +187,12 @@ "Print PDF": "打印 PDF", "Profile": "个人资料", "Recently updated": "最近更新", + "Recently viewed": "最近访问", + "Last 30 days": "近 30 天", + "No recently viewed pages": "暂无最近访问", + "Failed to load recently viewed pages": "最近访问加载失败", + "Pages you open will appear here for 30 days.": "打开过的文件会在这里保留 30 天。", + "Earlier": "更早", "Remove": "移除", "Remove from favorites": "从收藏中移除", "Remove group member": "移除群组成员", @@ -632,6 +657,8 @@ "Add headings to generate a table of contents.": "添加标题以生成目录。", "Attachments": "附件", "All spaces": "所有空间", + "All permissions": "所有权限", + "Filter by permission": "按权限筛选", "Unknown": "未知", "Find a space": "查找空间", "Search in all your spaces": "在您的所有空间中搜索", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 03032d0ac..35db3eead 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -46,10 +46,10 @@ import FavoritesPage from "@/pages/favorites/favorites-page"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import KnowledgeAdminPage from "@/ee/llm-wiki/pages/knowledge-admin"; import KnowledgeGraphPage from "@/ee/llm-wiki/pages/knowledge-graph"; -import ReviewPage from "@/ee/llm-wiki/pages/review"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; import LabelPage from "@/pages/label/label-page"; import { ThemeFavicon } from "@/components/common/brand-logo.tsx"; +import ImageViewer from "@/components/ui/image-viewer.tsx"; export default function App() { const { t } = useTranslation(); @@ -59,6 +59,7 @@ export default function App() { return ( <> + } /> } /> @@ -109,7 +110,6 @@ export default function App() { path={"/s/:spaceSlug/graph"} element={} /> - } /> } /> } /> diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 2a83debf9..e43243d84 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -45,7 +45,7 @@ export default function ExportModal({ }); } if (type === "space") { - await exportSpace({ spaceId: id, format, includeAttachments }); + await exportSpace({ spaceId: id, format }); } notifications.show({ message: t("Export successful"), @@ -121,24 +121,6 @@ export default function ExportModal({ )} - {type === "space" && ( - <> - - - -
- {t("Include attachments")} -
- - setIncludeAttachments(event.currentTarget.checked) - } - checked={includeAttachments} - /> -
- - )} - + + + + ); diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx index 1c1cd7e18..09b230a67 100644 --- a/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx +++ b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.tsx @@ -8,7 +8,11 @@ import { IconArrowUp } from "@tabler/icons-react"; import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts"; import { AiAction } from "@/ee/ai/types/ai.types.ts"; -import { CommandItem, commandItems, CommandSet } from "./command-items.ts"; +import { + CommandItem, + CommandSet, + getVisibleCommandItems, +} from "./command-items.ts"; import { CommandSelector } from "./command-selector.tsx"; import { ResultPreview } from "./result-preview.tsx"; import classes from "./ai-menu.module.css"; @@ -16,18 +20,25 @@ import { marked } from "marked"; import { DOMSerializer } from "@tiptap/pm/model"; import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext"; import { useLocation } from "react-router-dom"; +import { isFocusWithinAiMenu } from "./ai-menu.utils"; +import { notifications } from "@mantine/notifications"; interface EditorAiMenuProps { editor: Editor | null; + readOnly?: boolean; } -const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { +const EditorAiMenu = ({ + editor, + readOnly = false, +}: EditorAiMenuProps): JSX.Element | null => { const aiGenerateStreamMutation = useAiGenerateStreamMutation(); const location = useLocation(); const isSmBreakpoint = useMediaQuery("(max-width: 48em)"); const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const containerRef = useRef(null); const inputRef = useRef(null); + const streamAbortControllerRef = useRef(null); const [prompt, setPrompt] = useState(""); const [output, setOutput] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -43,11 +54,10 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { left: 0, width: 0, }); - const currentItems = useMemo(() => { - return commandItems[activeCommandSet].filter((item) => { - return item.name.toLowerCase().includes(prompt.toLowerCase()); - }); - }, [prompt, output, activeCommandSet]); + const currentItems = useMemo( + () => getVisibleCommandItems(activeCommandSet, prompt, readOnly), + [prompt, activeCommandSet, readOnly], + ); const updateMenuPlacement = useCallback(() => { if (!editor || !showAiMenu) return; @@ -83,8 +93,12 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { }); }, [editor, showAiMenu, isSmBreakpoint]); const resetMenu = useCallback(() => { + streamAbortControllerRef.current?.abort(); + streamAbortControllerRef.current = null; setPrompt(""); setOutput(""); + setIsLoading(false); + setSelectedIndex(-1); setActiveCommandSet("main"); setLastAction(null); aiGenerateStreamMutation.reset(); @@ -124,16 +138,22 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { action: command.action, prompt: command.prompt, content, + onController: (controller) => { + streamAbortControllerRef.current = controller; + }, onChunk: (chunk) => { setOutput((output) => output + chunk.content); }, onComplete: () => { + streamAbortControllerRef.current = null; setPrompt(""); setIsLoading(false); setActiveCommandSet("result"); }, - onError: () => { + onError: (error) => { + streamAbortControllerRef.current = null; setIsLoading(false); + notifications.show({ message: error.error, color: "red" }); resetMenu(); }, }); @@ -174,8 +194,8 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { // then decode HTML entities via DOMParser since TipTap would otherwise // treat the tagless string as plain text and insert entities literally. const content = isSingleParagraph - ? new DOMParser().parseFromString(html.slice(3, -4), "text/html") - .body.innerHTML + ? new DOMParser().parseFromString(html.slice(3, -4), "text/html").body + .innerHTML : html; chain.insertContent(content).run(); @@ -248,21 +268,32 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { useEffect(() => { if (!editor) return; - const handleClose = () => setShowAiMenu(false); + const handleFocus = () => setShowAiMenu(false); + const handleBlur = ({ event }: { event: FocusEvent }) => { + const nextTarget = event.relatedTarget; + + // Opening the AI menu moves focus from ProseMirror to the prompt input. + // Keep the menu open when that focus transition is internal to the menu. + if (isFocusWithinAiMenu(nextTarget, containerRef.current)) { + return; + } + + setShowAiMenu(false); + }; const observer = new ResizeObserver(() => { debouncedUpdateMenuPlacement(); }); updateMenuPlacement(); - editor.on("focus", handleClose); - editor.on("blur", handleClose); + editor.on("focus", handleFocus); + editor.on("blur", handleBlur); window.addEventListener("resize", debouncedUpdateMenuPlacement); window.addEventListener("scroll", debouncedUpdateMenuPlacement, true); observer.observe(editor.view.dom); return () => { - editor.off("focus", handleClose); - editor.off("blur", handleClose); + editor.off("focus", handleFocus); + editor.off("blur", handleBlur); window.removeEventListener("resize", debouncedUpdateMenuPlacement); window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true); observer.disconnect(); @@ -275,8 +306,14 @@ const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => { useEffect(() => { if (showAiMenu) { resetMenu(); + } else { + streamAbortControllerRef.current?.abort(); + streamAbortControllerRef.current = null; } }, [showAiMenu, resetMenu]); + useEffect(() => { + return () => streamAbortControllerRef.current?.abort(); + }, []); useEffect(() => { // Focus input when menu opens or command set changes requestAnimationFrame(() => { diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.utils.test.ts b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.utils.test.ts new file mode 100644 index 000000000..d2fddec67 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.utils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isFocusWithinAiMenu } from "./ai-menu.utils"; + +describe("isFocusWithinAiMenu", () => { + it("keeps the AI menu open when its prompt receives focus", () => { + const container = document.createElement("div"); + const input = document.createElement("input"); + container.appendChild(input); + + expect(isFocusWithinAiMenu(input, container)).toBe(true); + }); + + it("allows the AI menu to close when focus moves elsewhere", () => { + const container = document.createElement("div"); + const outsideButton = document.createElement("button"); + + expect(isFocusWithinAiMenu(outsideButton, container)).toBe(false); + expect(isFocusWithinAiMenu(null, container)).toBe(false); + }); +}); diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.utils.ts b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.utils.ts new file mode 100644 index 000000000..7d05b9007 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/ai-menu.utils.ts @@ -0,0 +1,5 @@ +export const isFocusWithinAiMenu = ( + relatedTarget: EventTarget | null, + container: HTMLElement | null, +) => + relatedTarget instanceof Node && container?.contains(relatedTarget) === true; diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/command-items.test.ts b/apps/client/src/ee/ai/components/editor/ai-menu/command-items.test.ts new file mode 100644 index 000000000..a71c3ac85 --- /dev/null +++ b/apps/client/src/ee/ai/components/editor/ai-menu/command-items.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { getVisibleCommandItems } from "./command-items"; + +describe("getVisibleCommandItems", () => { + it("does not expose document-writing result actions in read mode", () => { + const itemIds = getVisibleCommandItems("result", "", true).map( + (item) => item.id, + ); + + expect(itemIds).not.toContain("result-replace"); + expect(itemIds).not.toContain("result-insert-below"); + expect(itemIds).toEqual([ + "result-copy", + "result-discard", + "result-try-again", + ]); + }); + + it("keeps document-writing result actions in edit mode", () => { + const itemIds = getVisibleCommandItems("result", "", false).map( + (item) => item.id, + ); + + expect(itemIds).toContain("result-replace"); + expect(itemIds).toContain("result-insert-below"); + }); +}); diff --git a/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts b/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts index 71eaa9cb6..266741377 100644 --- a/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts +++ b/apps/client/src/ee/ai/components/editor/ai-menu/command-items.ts @@ -215,5 +215,23 @@ const commandItems: Record = { result: resultItems, }; +const getVisibleCommandItems = ( + commandSet: CommandSet, + prompt: string, + readOnly: boolean, +) => { + const items = commandItems[commandSet].filter((item) => + item.name.toLowerCase().includes(prompt.toLowerCase()), + ); + + if (!readOnly || commandSet !== "result") { + return items; + } + + return items.filter( + (item) => item.id !== "result-replace" && item.id !== "result-insert-below", + ); +}; + export type { CommandItem, CommandSet }; -export { commandItems }; +export { commandItems, getVisibleCommandItems }; diff --git a/apps/client/src/ee/ai/queries/ai-query.ts b/apps/client/src/ee/ai/queries/ai-query.ts index 076de9c77..4c7527275 100644 --- a/apps/client/src/ee/ai/queries/ai-query.ts +++ b/apps/client/src/ee/ai/queries/ai-query.ts @@ -27,6 +27,7 @@ export function useAiGenerateMutation(): UseMutationResult< } interface StreamCallbacks { + onController?: (controller: AbortController) => void; onChunk: (chunk: AiStreamChunk) => void; onError?: (error: AiStreamError) => void; onComplete?: () => void; @@ -38,7 +39,7 @@ export function useAiGenerateStreamMutation(): UseMutationResult< AiGenerateDto & StreamCallbacks > { return useMutation({ - mutationFn: ({ onChunk, onError, onComplete, ...data }) => - generateAiContentStream(data, onChunk, onError, onComplete), + mutationFn: ({ onController, onChunk, onError, onComplete, ...data }) => + generateAiContentStream(data, onChunk, onError, onComplete, onController), }); } diff --git a/apps/client/src/ee/ai/services/ai-model-config-service.ts b/apps/client/src/ee/ai/services/ai-model-config-service.ts index 067148202..b79dc8dd3 100644 --- a/apps/client/src/ee/ai/services/ai-model-config-service.ts +++ b/apps/client/src/ee/ai/services/ai-model-config-service.ts @@ -2,6 +2,7 @@ import api from "@/lib/api-client"; import type { AiModelConfigFeature, AiModelConfigView, + TestAiModelConfigResult, UpdateAiModelConfigInput, } from "@/ee/ai/types/ai-model-config.types"; @@ -22,3 +23,14 @@ export async function updateAiModelConfig( ); return req.data; } + +export async function testAiModelConfig( + feature: AiModelConfigFeature, + input: UpdateAiModelConfigInput, +): Promise { + const req = await api.post( + `/llm-wiki/admin/model-configs/${feature}/test`, + input, + ); + return req.data; +} diff --git a/apps/client/src/ee/ai/services/ai-service.ts b/apps/client/src/ee/ai/services/ai-service.ts index 88557ff10..8f778ff86 100644 --- a/apps/client/src/ee/ai/services/ai-service.ts +++ b/apps/client/src/ee/ai/services/ai-service.ts @@ -18,8 +18,10 @@ export async function generateAiContentStream( onChunk: (chunk: AiStreamChunk) => void, onError?: (error: AiStreamError) => void, onComplete?: () => void, + onController?: (controller: AbortController) => void, ): Promise { const abortController = new AbortController(); + onController?.(abortController); try { const response = await fetch("/api/ai/generate/stream", { method: "POST", @@ -44,6 +46,8 @@ export async function generateAiContentStream( const processStream = async () => { let buffer = ""; + let completed = false; + let failed = false; try { while (true) { const { done, value } = await reader.read(); @@ -58,13 +62,16 @@ export async function generateAiContentStream( if (line.startsWith("data: ")) { const data = line.slice(6); if (data === "[DONE]") { + completed = true; onComplete?.(); return; } try { const parsed = JSON.parse(data); if (parsed.error) { + failed = true; onError?.(parsed); + return; } else { onChunk(parsed); } @@ -74,6 +81,10 @@ export async function generateAiContentStream( } } } + + if (!completed && !failed && !abortController.signal.aborted) { + onError?.({ error: "AI stream ended unexpectedly." }); + } } catch (error) { if (error.name !== "AbortError") { onError?.({ error: error.message }); diff --git a/apps/client/src/ee/ai/types/ai-model-config.types.ts b/apps/client/src/ee/ai/types/ai-model-config.types.ts index 907154f54..27bc32472 100644 --- a/apps/client/src/ee/ai/types/ai-model-config.types.ts +++ b/apps/client/src/ee/ai/types/ai-model-config.types.ts @@ -30,3 +30,10 @@ export interface UpdateAiModelConfigInput { apiKey?: string; parameters?: AiModelConfigParameters; } + +export interface TestAiModelConfigResult { + ok: boolean; + code?: string; + message?: string; + latencyMs?: number; +} diff --git a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx index e787c0d48..a7855806e 100644 --- a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx +++ b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx @@ -31,7 +31,9 @@ export function ApiKeyCreatedModal({ @@ -43,7 +45,7 @@ export function ApiKeyCreatedModal({ > {t( "Make sure to copy your {{credential}} now. You won't be able to see it again!", - { credential: t("API key") }, + { credential: apiKey.keyType === "agent" ? t("agent credential") : t("API key") }, )} diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx index ce42c50aa..aa18337ea 100644 --- a/apps/client/src/ee/api-key/components/api-key-table.tsx +++ b/apps/client/src/ee/api-key/components/api-key-table.tsx @@ -1,5 +1,5 @@ import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; -import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; +import { IconDots, IconEdit, IconRefresh, IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { IApiKey } from "@/ee/api-key"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; @@ -11,18 +11,26 @@ interface ApiKeyTableProps { apiKeys: IApiKey[]; isLoading?: boolean; showUserColumn?: boolean; + userColumnLabel?: string; + keyNameLabel?: string; + isAgentTable?: boolean; showSpacesColumn?: boolean; onUpdate?: (apiKey: IApiKey) => void; onRevoke?: (apiKey: IApiKey) => void; + onRotate?: (apiKey: IApiKey) => void; } export function ApiKeyTable({ apiKeys, isLoading, showUserColumn = false, + userColumnLabel, + keyNameLabel, + isAgentTable = false, showSpacesColumn = false, onUpdate, onRevoke, + onRotate, }: ApiKeyTableProps) { const { t } = useTranslation(); const locale = useDateFnsLocale(); @@ -38,16 +46,16 @@ export function ApiKeyTable({ }; return ( - + - {t("Name")} - {showUserColumn && {t("User")}} + {keyNameLabel ?? t("Name")} + {showUserColumn && {userColumnLabel ?? t("User")}} {showSpacesColumn && {t("Spaces")}} - {t("Last used")} - {t("Expires")} - {t("Created")} + {t("Last used")} + {t("Expires")} + {t("Created")} @@ -56,29 +64,35 @@ export function ApiKeyTable({ {apiKeys && apiKeys.length > 0 ? ( apiKeys.map((apiKey: IApiKey, index: number) => ( - + {apiKey.name} - {showUserColumn && apiKey.creator && ( - - - + {showUserColumn && (apiKey.agentUser || apiKey.creator) && ( + + {isAgentTable ? ( - {apiKey.creator.name} + {apiKey.agentUser?.email || "-"} - + ) : ( + + + + {apiKey.creator?.name || "-"} + + + )} )} {showSpacesColumn && ( - + {(apiKey.spaces ?? []) .map((space) => space.name ?? space.id) @@ -134,9 +148,7 @@ export function ApiKeyTable({ leftSection={} onClick={() => onUpdate(apiKey)} > - {apiKey.keyType === "public_retrieval" - ? t("Edit") - : t("Rename")} + {apiKey.keyType === "agent" ? t("Edit Agent") : t("Edit")} )} {onRevoke && ( @@ -145,7 +157,15 @@ export function ApiKeyTable({ color="red" onClick={() => onRevoke(apiKey)} > - {t("Revoke")} + {apiKey.keyType === "agent" ? t("Delete Agent") : t("Revoke")} + + )} + {isAgentTable && onRotate && ( + } + onClick={() => onRotate(apiKey)} + > + {t("Rotate key")} )} diff --git a/apps/client/src/ee/api-key/components/create-public-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-public-api-key-modal.tsx index 9c3579aeb..724ddf6b2 100644 --- a/apps/client/src/ee/api-key/components/create-public-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/create-public-api-key-modal.tsx @@ -4,8 +4,6 @@ import { Button, Group, Stack, - Select, - MultiSelect, } from "@mantine/core"; import { useForm } from "@mantine/form"; import { zod4Resolver } from "mantine-form-zod-resolver"; @@ -13,10 +11,8 @@ import { z } from "zod/v4"; import { useTranslation } from "react-i18next"; import { useCreatePublicApiKeyMutation, - useGetPublicApiKeySpacesQuery, } from "@/ee/api-key/queries/api-key-query"; import { IApiKey } from "@/ee/api-key"; -import { useState } from "react"; interface CreatePublicApiKeyModalProps { opened: boolean; @@ -26,8 +22,6 @@ interface CreatePublicApiKeyModalProps { const schema = z.object({ name: z.string().min(1), - spaceIds: z.array(z.string()).min(1), - expiresAt: z.string().optional(), }); type FormValues = z.infer; @@ -38,25 +32,15 @@ export function CreatePublicApiKeyModal({ onSuccess, }: CreatePublicApiKeyModalProps) { const { t } = useTranslation(); - const [expiration, setExpiration] = useState("30"); - const { data: spaces, isLoading: spacesLoading } = - useGetPublicApiKeySpacesQuery(); const mutation = useCreatePublicApiKeyMutation(); const form = useForm({ validate: zod4Resolver(schema), - initialValues: { name: "", spaceIds: [], expiresAt: "" }, + initialValues: { name: "" }, }); - const expirationDate = () => { - if (expiration === "never") return undefined; - const date = new Date(); - date.setDate(date.getDate() + Number(expiration)); - return date.toISOString(); - }; const close = () => { form.reset(); - setExpiration("30"); onClose(); }; @@ -64,7 +48,7 @@ export function CreatePublicApiKeyModal({ @@ -72,8 +56,6 @@ export function CreatePublicApiKeyModal({ onSubmit={form.onSubmit(async (values) => { const created = await mutation.mutateAsync({ name: values.name, - spaceIds: values.spaceIds, - expiresAt: expirationDate(), }); onSuccess(created); close(); @@ -86,30 +68,6 @@ export function CreatePublicApiKeyModal({ required {...form.getInputProps("name")} /> - ({ - value: space.id, - label: space.name, - }))} - searchable - required - disabled={spacesLoading} - {...form.getInputProps("spaceIds")} - /> -