diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 61db92cc..141584ff 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -57,6 +57,8 @@ const endpoints = { REINGEST_SOURCE: '/api/sources/reingest', SOURCE_CONFIG: (id: string) => `/api/sources/${id}/config`, CREATE_WIKI: '/api/sources/wiki', + CONVERT_TO_WIKI: (id: string) => `/api/sources/${id}/wiki/convert`, + TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`, WIKI_PAGES: (id: string) => `/api/sources/${id}/wiki/pages`, WIKI_PAGE: (id: string, path: string) => `/api/sources/${id}/wiki/page?path=${encodeURIComponent(path)}`, diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 98ef4d74..f2ec6dd6 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -107,6 +107,10 @@ const userService = { token: string | null, ): Promise => apiClient.post(endpoints.USER.CREATE_WIKI, data, token), + convertToWiki: (sourceId: string, token: string | null): Promise => + apiClient.post(endpoints.USER.CONVERT_TO_WIKI(sourceId), {}, token), + getTaskStatus: (taskId: string, token: string | null): Promise => + apiClient.get(endpoints.USER.TASK_STATUS(taskId), token), getWikiPages: (sourceId: string, token: string | null): Promise => throttledApiClient.get(endpoints.USER.WIKI_PAGES(sourceId), token), getWikiPage: ( diff --git a/frontend/src/components/WikiViewer.tsx b/frontend/src/components/WikiViewer.tsx index 64f1e63e..e5e7ca6d 100644 --- a/frontend/src/components/WikiViewer.tsx +++ b/frontend/src/components/WikiViewer.tsx @@ -1,3 +1,4 @@ +import { BookOpen } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -221,6 +222,24 @@ const WikiViewer: React.FC = ({ +
+
+
{loadingPages ? ( diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index 90ffb3f2..32d69f89 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -49,6 +49,11 @@ import classes from './ConversationBubble.module.css'; import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels'; import ResearchProgress from './ResearchProgress'; import { ToolCallsType } from './types'; +import { + isWikiWriteCall, + wikiWriteActionKey, + wikiWritePath, +} from './wikiToolCall'; const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; @@ -1064,7 +1069,47 @@ function ToolCallApprovalBar({ ); } -function ToolCalls({ +export function WikiWriteToolCallCard({ + toolCall, +}: { + toolCall: ToolCallsType; +}) { + const { t } = useTranslation(); + const path = wikiWritePath(toolCall); + const actionKey = wikiWriteActionKey(toolCall.action_name); + const isError = toolCall.status === 'error'; + + return ( +
+ + + {t(`conversation.wikiWrite.${actionKey}`, { + defaultValue: t('conversation.wikiWrite.edited'), + })} + + {path && ( + + {path} + + )} + {isError && ( + + {t('conversation.wikiWrite.failed')} + + )} +
+ ); +} + +export function ToolCalls({ toolCalls, onToolAction, agentId, @@ -1082,8 +1127,11 @@ function ToolCalls({ const awaitingCalls = toolCalls.filter( (tc) => tc.status === 'awaiting_approval', ); + const wikiWriteCalls = toolCalls.filter( + (tc) => tc.status !== 'awaiting_approval' && isWikiWriteCall(tc), + ); const resolvedCalls = toolCalls.filter( - (tc) => tc.status !== 'awaiting_approval', + (tc) => tc.status !== 'awaiting_approval' && !isWikiWriteCall(tc), ); return ( @@ -1101,6 +1149,18 @@ function ToolCalls({
)} + {/* Wiki write cards — always visible (D24: visibility is the control) */} + {wikiWriteCalls.length > 0 && ( +
+ {wikiWriteCalls.map((toolCall, index) => ( + + ))} +
+ )} + {/* Regular tool calls accordion */} {resolvedCalls.length > 0 && ( <> diff --git a/frontend/src/conversation/WikiWriteToolCallCard.test.tsx b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx new file mode 100644 index 00000000..576f550b --- /dev/null +++ b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx @@ -0,0 +1,102 @@ +import i18n from 'i18next'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import en from '../locale/en.json'; +import { ToolCalls, WikiWriteToolCallCard } from './ConversationBubble'; +import { ToolCallsType } from './types'; + +const testI18n = i18n.createInstance(); + +beforeAll(async () => { + await testI18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: en } }, + }); +}); + +const render = (toolCall: ToolCallsType): string => + renderToStaticMarkup( + + + , + ); + +describe('WikiWriteToolCallCard', () => { + it('renders the edited-wiki treatment with the page path', () => { + const html = render({ + tool_name: 'wiki', + action_name: 'wiki_str_replace', + call_id: 'c1', + arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' }, + status: 'completed', + }); + expect(html).toContain('✏️'); + expect(html).toContain('Edited wiki page'); + expect(html).toContain('/policy.md'); + }); + + it('uses the new path for a rename', () => { + const html = render({ + tool_name: 'wiki', + action_name: 'wiki_rename', + call_id: 'c2', + arguments: { old_path: '/a.md', new_path: '/b.md' }, + status: 'completed', + }); + expect(html).toContain('Renamed wiki page'); + expect(html).toContain('/b.md'); + }); + + it('still renders the action without a path when none is in the payload', () => { + const html = render({ + tool_name: 'wiki', + action_name: 'wiki_create', + call_id: 'c3', + arguments: {}, + status: 'completed', + }); + expect(html).toContain('Created wiki page'); + expect(html).not.toContain(' { + const renderToolCalls = (toolCalls: ToolCallsType[]): string => + renderToStaticMarkup( + + + , + ); + + const wikiWrite: ToolCallsType = { + tool_name: 'wiki', + action_name: 'wiki_str_replace', + call_id: 'w1', + arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' }, + status: 'completed', + }; + + const otherCall: ToolCallsType = { + tool_name: 'memory', + action_name: 'memory_search', + call_id: 'm1', + arguments: { query: 'secret-arg' }, + status: 'completed', + }; + + it('renders wiki write cards outside the collapsed accordion (default closed)', () => { + const html = renderToolCalls([wikiWrite, otherCall]); + expect(html).toContain('Edited wiki page'); + expect(html).toContain('/policy.md'); + expect(html).not.toContain('secret-arg'); + }); + + it('does not double-render wiki write cards inside the accordion', () => { + const html = renderToolCalls([wikiWrite]); + const occurrences = html.split('Edited wiki page').length - 1; + expect(occurrences).toBe(1); + }); +}); diff --git a/frontend/src/conversation/wikiToolCall.test.ts b/frontend/src/conversation/wikiToolCall.test.ts new file mode 100644 index 00000000..31773368 --- /dev/null +++ b/frontend/src/conversation/wikiToolCall.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { ToolCallsType } from './types'; +import { + isWikiWriteCall, + wikiWriteActionKey, + wikiWritePath, +} from './wikiToolCall'; + +const call = (overrides: Partial): ToolCallsType => ({ + tool_name: 'wiki', + action_name: 'wiki_str_replace', + call_id: 'c1', + arguments: {}, + ...overrides, +}); + +describe('isWikiWriteCall', () => { + it('flags wiki write actions', () => { + expect(isWikiWriteCall(call({ action_name: 'wiki_create' }))).toBe(true); + expect(isWikiWriteCall(call({ action_name: 'wiki_str_replace' }))).toBe( + true, + ); + expect(isWikiWriteCall(call({ action_name: 'wiki_insert' }))).toBe(true); + expect(isWikiWriteCall(call({ action_name: 'wiki_delete' }))).toBe(true); + expect(isWikiWriteCall(call({ action_name: 'wiki_rename' }))).toBe(true); + }); + + it('does not flag wiki read actions', () => { + expect(isWikiWriteCall(call({ action_name: 'wiki_view' }))).toBe(false); + }); + + it('does not flag non-wiki tools', () => { + expect( + isWikiWriteCall( + call({ tool_name: 'memory', action_name: 'wiki_create' }), + ), + ).toBe(false); + }); +}); + +describe('wikiWritePath', () => { + it('reads the path argument for str_replace', () => { + const tc = call({ + action_name: 'wiki_str_replace', + arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' }, + }); + expect(wikiWritePath(tc)).toBe('/policy.md'); + }); + + it('prefers new_path for a rename', () => { + const tc = call({ + action_name: 'wiki_rename', + arguments: { old_path: '/a.md', new_path: '/b.md' }, + }); + expect(wikiWritePath(tc)).toBe('/b.md'); + }); + + it('returns null when no path is present in the payload', () => { + expect(wikiWritePath(call({ arguments: {} }))).toBeNull(); + }); +}); + +describe('wikiWriteActionKey', () => { + it('strips the wiki_ prefix', () => { + expect(wikiWriteActionKey('wiki_str_replace')).toBe('str_replace'); + expect(wikiWriteActionKey('wiki_create')).toBe('create'); + }); +}); diff --git a/frontend/src/conversation/wikiToolCall.ts b/frontend/src/conversation/wikiToolCall.ts new file mode 100644 index 00000000..a6404da7 --- /dev/null +++ b/frontend/src/conversation/wikiToolCall.ts @@ -0,0 +1,26 @@ +import { ToolCallsType } from './types'; + +const WIKI_WRITE_ACTIONS = new Set([ + 'wiki_create', + 'wiki_str_replace', + 'wiki_insert', + 'wiki_delete', + 'wiki_rename', +]); + +export function isWikiWriteCall(toolCall: ToolCallsType): boolean { + return ( + toolCall.tool_name === 'wiki' && + WIKI_WRITE_ACTIONS.has(toolCall.action_name) + ); +} + +export function wikiWritePath(toolCall: ToolCallsType): string | null { + const args = toolCall.arguments || {}; + const path = args.new_path ?? args.path; + return typeof path === 'string' && path.length > 0 ? path : null; +} + +export function wikiWriteActionKey(actionName: string): string { + return actionName.replace(/^wiki_/, ''); +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 50ddcd43..bae7bbd8 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -115,6 +115,9 @@ "forbidden": "You do not have permission to edit this page.", "conflict": "This page changed since you opened it and has been reloaded. Your edits are below — reapply and save again.", "editPlaceholder": "Write the page in Markdown…", + "livingTitle": "Living wiki.", + "livingExplainer": "An agent-editable source: pages are read and rewritten as agents work, and it is exposed to agents through the agentic search tool by default.", + "livingExplainerEditable": "Any agent using this source can edit it.", "stamp": { "editedBy": "Edited by {{who}}", "agent": "agent", @@ -122,6 +125,28 @@ "you": "you", "unknown": "unknown", "version": "v{{version}}" + }, + "convert": { + "action": "Convert to wiki", + "title": "Convert to a living wiki", + "intro": "Convert \"{{name}}\" into a living wiki of editable pages that agents can read and rewrite.", + "introGeneric": "Convert this source into a living wiki of editable pages that agents can read and rewrite.", + "costReparse": "Files are re-parsed into editable pages. Formatting is simplified, especially for PDFs.", + "costSkipped": "Non-text files (images, binaries) are skipped.", + "costIrreversible": "This is not reversible — the wiki pages become the source of truth.", + "confirm": "Convert to wiki", + "inProgress": "Converting files into wiki pages…", + "summaryPages_one": "{{count}} page created", + "summaryPages_other": "{{count}} pages created", + "summarySkipped_one": "{{count}} file skipped", + "summarySkipped_other": "{{count}} files skipped", + "skippedHeading": "Skipped files", + "done": "Done", + "errors": { + "generic": "Conversion failed. Please try again.", + "forbidden": "You don't have permission to convert this source.", + "conflict": "This source is still ingesting. Wait for it to finish before converting." + } } }, "editConfig": "Source settings", @@ -731,8 +756,8 @@ "heading": "Upload from Confluence" }, "wiki": { - "label": "Create Wiki", - "heading": "Create an LLM-editable wiki" + "label": "New wiki", + "heading": "Create a living wiki" } }, "connectors": { @@ -979,7 +1004,16 @@ "remove": "Remove attachment" }, "retry": "Retry", - "reasoning": "Reasoning" + "reasoning": "Reasoning", + "wikiWrite": { + "edited": "Edited wiki", + "create": "Created wiki page", + "str_replace": "Edited wiki page", + "insert": "Edited wiki page", + "delete": "Deleted wiki page", + "rename": "Renamed wiki page", + "failed": "failed" + } }, "agents": { "title": "Agents", diff --git a/frontend/src/settings/ConvertToWikiModal.tsx b/frontend/src/settings/ConvertToWikiModal.tsx new file mode 100644 index 00000000..d024b8b2 --- /dev/null +++ b/frontend/src/settings/ConvertToWikiModal.tsx @@ -0,0 +1,245 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import Spinner from '../components/Spinner'; +import { Button } from '../components/ui/button'; +import { Modal } from '../components/ui/modal'; +import { ActiveState, Doc } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; + +import { + ConvertSummary, + pollTaskOnce, + startWikiConversion, +} from './wikiConvertUtils'; + +const POLL_INTERVAL_MS = 2000; + +type Phase = 'confirm' | 'converting' | 'summary' | 'error'; + +interface ConvertToWikiModalProps { + modalState: ActiveState; + setModalState: (state: ActiveState) => void; + document: Doc | null; + onConverted: () => void; +} + +export default function ConvertToWikiModal({ + modalState, + setModalState, + document, + onConverted, +}: ConvertToWikiModalProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + + const [phase, setPhase] = useState('confirm'); + const [error, setError] = useState(null); + const [summary, setSummary] = useState(null); + const pollTimer = useRef(null); + const isActiveRef = useRef(true); + + const clearPoll = () => { + if (pollTimer.current !== null) { + window.clearTimeout(pollTimer.current); + pollTimer.current = null; + } + }; + + const stopPolling = () => { + isActiveRef.current = false; + clearPoll(); + }; + + useEffect(() => { + if (modalState === 'ACTIVE') { + isActiveRef.current = true; + setPhase('confirm'); + setError(null); + setSummary(null); + } + return stopPolling; + }, [modalState, document]); + + const closeModal = () => { + stopPolling(); + setModalState('INACTIVE'); + }; + + const fail = (message?: string) => { + setError(message ?? t('settings.sources.wiki.convert.errors.generic')); + setPhase('error'); + }; + + const poll = (taskId: string) => { + pollTaskOnce(userService, taskId, token) + .then((result) => { + if (!isActiveRef.current) return; + if (result.status === 'pending') { + pollTimer.current = window.setTimeout( + () => poll(taskId), + POLL_INTERVAL_MS, + ); + return; + } + if (result.status === 'done') { + setSummary(result.summary); + setPhase('summary'); + onConverted(); + return; + } + fail(result.message); + }) + .catch(() => { + if (!isActiveRef.current) return; + fail(); + }); + }; + + const handleConvert = async () => { + if (!document?.id) return; + setPhase('converting'); + setError(null); + const start = await startWikiConversion(userService, document.id, token); + if (start.status === 'enabled') { + setSummary({ pagesCreated: 0, skipped: [] }); + setPhase('summary'); + onConverted(); + return; + } + if (start.status === 'task') { + poll(start.taskId); + return; + } + if (start.status === 'forbidden') { + fail(t('settings.sources.wiki.convert.errors.forbidden')); + return; + } + if (start.status === 'conflict') { + fail(start.message ?? t('settings.sources.wiki.convert.errors.conflict')); + return; + } + fail(start.message); + }; + + return ( + !o && closeModal()} + hideTitle + title={t('settings.sources.wiki.convert.title')} + size="md" + mobileVariant="sheet" + className="max-w-[480px]" + isPerformingTask={phase === 'converting'} + > +
+

+ {t('settings.sources.wiki.convert.title')} +

+ + {phase === 'confirm' && ( + <> +

+ {document?.name + ? t('settings.sources.wiki.convert.intro', { + name: document.name, + }) + : t('settings.sources.wiki.convert.introGeneric')} +

+
    +
  • {t('settings.sources.wiki.convert.costReparse')}
  • +
  • {t('settings.sources.wiki.convert.costSkipped')}
  • +
  • {t('settings.sources.wiki.convert.costIrreversible')}
  • +
+
+ + +
+ + )} + + {phase === 'converting' && ( +
+ +

+ {t('settings.sources.wiki.convert.inProgress')} +

+
+ )} + + {phase === 'summary' && summary && ( + <> +
+ {[ + t('settings.sources.wiki.convert.summaryPages', { + count: summary.pagesCreated, + }), + t('settings.sources.wiki.convert.summarySkipped', { + count: summary.skipped.length, + }), + ].join(' · ')} +
+ {summary.skipped.length > 0 && ( +
+

+ {t('settings.sources.wiki.convert.skippedHeading')} +

+
    + {summary.skipped.map((s) => ( +
  • + {s.file} + {s.reason ? ` — ${s.reason}` : ''} +
  • + ))} +
+
+ )} +
+ +
+ + )} + + {phase === 'error' && ( + <> +
+ {error} +
+
+ +
+ + )} +
+
+ ); +} diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index 2a9df22e..7d8a8e23 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -1,4 +1,9 @@ -import { Search as SearchIcon, SlidersHorizontal, Users } from 'lucide-react'; +import { + BookOpen, + Search as SearchIcon, + SlidersHorizontal, + Users, +} from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -46,6 +51,7 @@ import FileTree from '../components/FileTree'; import ConnectorTree from '../components/ConnectorTree'; import Chunks from '../components/Chunks'; import WikiViewer from '../components/WikiViewer'; +import ConvertToWikiModal from './ConvertToWikiModal'; import SourceConfigModal from './SourceConfigModal'; type SourceMenuOption = { @@ -111,6 +117,9 @@ export default function Sources({ ); const [configModalState, setConfigModalState] = useState('INACTIVE'); + const [documentToConvert, setDocumentToConvert] = useState(null); + const [convertModalState, setConvertModalState] = + useState('INACTIVE'); const [syncMenuState, setSyncMenuState] = useState<{ isOpen: boolean; docId: string | null; @@ -329,6 +338,9 @@ export default function Sources({ document: Doc, ): SourceMenuOption[] => { const isWiki = document.type === 'wiki'; + // 'team' viewers cannot write; convert is owner/editor only. + const canEdit = + document.ownership !== 'team' || document.team_access === 'editor'; const actions: SourceMenuOption[] = [ { icon: EyeView, @@ -398,6 +410,26 @@ export default function Sources({ }); } + if ( + document.id && + !isWiki && + canEdit && + document.ingestStatus !== 'processing' && + document.ingestStatus !== 'failed' + ) { + actions.push({ + icon: BookOpen, + label: t('settings.sources.wiki.convert.action'), + onClick: () => { + setDocumentToConvert(document); + setConvertModalState('ACTIVE'); + }, + iconWidth: 16, + iconHeight: 16, + variant: 'default', + }); + } + // Sharing is an owner-only action: hide it for sources shared into the // user's workspace by a team. if (document.ownership !== 'team' && document.id) { @@ -764,6 +796,18 @@ export default function Sources({ document={documentToConfigure} onReingest={handleReingest} /> + + { + setConvertModalState(state); + if (state === 'INACTIVE') { + setDocumentToConvert(null); + } + }} + document={documentToConvert} + onConverted={() => refreshDocs(undefined, currentPage, rowsPerPage)} + />
); } diff --git a/frontend/src/settings/wikiConvertUtils.test.ts b/frontend/src/settings/wikiConvertUtils.test.ts new file mode 100644 index 00000000..41bb482a --- /dev/null +++ b/frontend/src/settings/wikiConvertUtils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + interpretTaskStatus, + pollTaskOnce, + startWikiConversion, +} from './wikiConvertUtils'; + +const jsonResponse = (status: number, body: unknown): Response => + ({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) as unknown as Response; + +describe('startWikiConversion', () => { + it('returns enabled for a blank source ({enabled:true}, no task)', async () => { + const convertToWiki = vi + .fn() + .mockResolvedValue(jsonResponse(200, { success: true, enabled: true })); + + const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok'); + + expect(convertToWiki).toHaveBeenCalledWith('src-1', 'tok'); + expect(result).toEqual({ status: 'enabled' }); + }); + + it('returns the task id for a fileful source', async () => { + const convertToWiki = vi + .fn() + .mockResolvedValue(jsonResponse(200, { success: true, task_id: 't-9' })); + + const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok'); + + expect(result).toEqual({ status: 'task', taskId: 't-9' }); + }); + + it('maps 403 to forbidden', async () => { + const convertToWiki = vi.fn().mockResolvedValue(jsonResponse(403, {})); + const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok'); + expect(result).toEqual({ status: 'forbidden' }); + }); + + it('maps 409 to conflict with the server message', async () => { + const convertToWiki = vi + .fn() + .mockResolvedValue(jsonResponse(409, { message: 'still ingesting' })); + const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok'); + expect(result).toEqual({ status: 'conflict', message: 'still ingesting' }); + }); + + it('maps a network throw to a generic error', async () => { + const convertToWiki = vi.fn().mockRejectedValue(new Error('offline')); + const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok'); + expect(result).toEqual({ status: 'error' }); + }); +}); + +describe('interpretTaskStatus', () => { + it('parses a SUCCESS summary (pages + skipped list)', () => { + const result = interpretTaskStatus('SUCCESS', { + status: 'converted', + pages_created: 12, + skipped: [{ file: 'a.png', reason: 'unsupported' }], + }); + expect(result).toEqual({ + status: 'done', + summary: { + pagesCreated: 12, + skipped: [{ file: 'a.png', reason: 'unsupported' }], + }, + }); + }); + + it('defaults missing summary fields', () => { + const result = interpretTaskStatus('SUCCESS', {}); + expect(result).toEqual({ + status: 'done', + summary: { pagesCreated: 0, skipped: [] }, + }); + }); + + it('maps FAILURE to failed', () => { + expect(interpretTaskStatus('FAILURE', 'boom')).toEqual({ + status: 'failed', + message: 'boom', + }); + }); + + it('treats other statuses as pending', () => { + expect(interpretTaskStatus('PENDING', null)).toEqual({ status: 'pending' }); + expect(interpretTaskStatus('STARTED', null)).toEqual({ status: 'pending' }); + }); +}); + +describe('pollTaskOnce', () => { + it('returns the parsed summary on success', async () => { + const getTaskStatus = vi.fn().mockResolvedValue( + jsonResponse(200, { + status: 'SUCCESS', + result: { pages_created: 3, skipped: [] }, + }), + ); + const result = await pollTaskOnce({ getTaskStatus }, 't-1', 'tok'); + expect(result).toEqual({ + status: 'done', + summary: { pagesCreated: 3, skipped: [] }, + }); + }); + + it('returns pending on a non-ok response', async () => { + const getTaskStatus = vi.fn().mockResolvedValue(jsonResponse(503, {})); + const result = await pollTaskOnce({ getTaskStatus }, 't-1', 'tok'); + expect(result).toEqual({ status: 'pending' }); + }); +}); diff --git a/frontend/src/settings/wikiConvertUtils.ts b/frontend/src/settings/wikiConvertUtils.ts new file mode 100644 index 00000000..8f258407 --- /dev/null +++ b/frontend/src/settings/wikiConvertUtils.ts @@ -0,0 +1,89 @@ +export interface SkippedFile { + file: string; + reason: string; +} + +export interface ConvertSummary { + pagesCreated: number; + skipped: SkippedFile[]; +} + +export type ConvertStart = + | { status: 'enabled' } + | { status: 'task'; taskId: string } + | { status: 'forbidden' } + | { status: 'conflict'; message?: string } + | { status: 'error'; message?: string }; + +export type ConvertPoll = + | { status: 'pending' } + | { status: 'done'; summary: ConvertSummary } + | { status: 'failed'; message?: string }; + +interface ConvertService { + convertToWiki: (sourceId: string, token: string | null) => Promise; + getTaskStatus: (taskId: string, token: string | null) => Promise; +} + +export async function startWikiConversion( + service: Pick, + sourceId: string, + token: string | null, +): Promise { + let response: Response; + try { + response = await service.convertToWiki(sourceId, token); + } catch { + return { status: 'error' }; + } + const data = await response.json().catch(() => ({})); + if (response.ok && data?.success) { + if (data.task_id) return { status: 'task', taskId: data.task_id }; + return { status: 'enabled' }; + } + if (response.status === 403) return { status: 'forbidden' }; + if (response.status === 409) + return { status: 'conflict', message: data?.message }; + return { status: 'error', message: data?.message }; +} + +function parseSummary(result: unknown): ConvertSummary { + const r = (result || {}) as Record; + const skipped = Array.isArray(r.skipped) ? (r.skipped as SkippedFile[]) : []; + return { + pagesCreated: Number(r.pages_created) || 0, + skipped, + }; +} + +export function interpretTaskStatus( + status: string | undefined, + result: unknown, +): ConvertPoll { + if (status === 'SUCCESS') + return { status: 'done', summary: parseSummary(result) }; + if (status === 'FAILURE') { + const message = + typeof result === 'string' + ? result + : ((result as Record)?.message as string | undefined); + return { status: 'failed', message }; + } + return { status: 'pending' }; +} + +export async function pollTaskOnce( + service: Pick, + taskId: string, + token: string | null, +): Promise { + let response: Response; + try { + response = await service.getTaskStatus(taskId, token); + } catch { + return { status: 'pending' }; + } + if (!response.ok) return { status: 'pending' }; + const data = await response.json().catch(() => ({})); + return interpretTaskStatus(data?.status, data?.result); +} diff --git a/frontend/src/upload/types/ingestor.test.ts b/frontend/src/upload/types/ingestor.test.ts index ccfab55e..7cad192e 100644 --- a/frontend/src/upload/types/ingestor.test.ts +++ b/frontend/src/upload/types/ingestor.test.ts @@ -7,10 +7,10 @@ import { } from './ingestor'; describe('wiki ingestor schema', () => { - it('registers a "wiki" ingestor with the Create Wiki label', () => { + it('registers a "wiki" ingestor with the New wiki label', () => { const schema = getIngestorSchema('wiki'); expect(schema).toBeDefined(); - expect(schema?.label).toBe('Create Wiki'); + expect(schema?.label).toBe('New wiki'); }); it('exposes an optional initial_content textarea field', () => { diff --git a/frontend/src/upload/types/ingestor.ts b/frontend/src/upload/types/ingestor.ts index 5cd59bfe..8801ecbb 100644 --- a/frontend/src/upload/types/ingestor.ts +++ b/frontend/src/upload/types/ingestor.ts @@ -240,9 +240,9 @@ export const IngestorFormSchemas: IngestorSchema[] = [ }, { key: 'wiki', - label: 'Create Wiki', + label: 'New wiki', icon: BookIcon, - heading: 'Create an LLM-editable wiki', + heading: 'Create a living wiki', fields: [ { name: 'initial_content',