feat(wiki): convert-to-wiki UX, living-wiki naming, surface agent edits

Unit 7 of F-Wiki (D20/D23/D24). Source row action 'Convert to wiki' (owner/editor only) -> confirm naming the cost (lossy/one-way/skipped) -> poll task status -> after-summary; blank source enables inline. Agent wiki writes (create/str_replace/insert/delete/rename) render as a prominent top-level card in the conversation (outside the collapsed tool-calls accordion) with the page path; reads stay normal. 'Living wiki'/agent-editable naming + explainer; 'New wiki' blank-source fast path retained.
This commit is contained in:
Alex committed 2026-06-22 21:05:35 +01:00
1 parent 919fe10ab9
commit 80533f9383
14 files changed
+820 -10

No files matched your search

+2
View File
@@ -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)}`,
+4
View File
@@ -107,6 +107,10 @@ const userService = {
token: string | null,
): Promise<Response> =>
apiClient.post(endpoints.USER.CREATE_WIKI, data, token),
convertToWiki: (sourceId: string, token: string | null): Promise<Response> =>
apiClient.post(endpoints.USER.CONVERT_TO_WIKI(sourceId), {}, token),
getTaskStatus: (taskId: string, token: string | null): Promise<Response> =>
apiClient.get(endpoints.USER.TASK_STATUS(taskId), token),
getWikiPages: (sourceId: string, token: string | null): Promise<Response> =>
throttledApiClient.get(endpoints.USER.WIKI_PAGES(sourceId), token),
getWikiPage: (
+19
View File
@@ -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<WikiViewerProps> = ({
</span>
</div>
<div className="bg-muted/60 text-muted-foreground dark:bg-accent/40 mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<BookOpen
size={16}
strokeWidth={1.75}
className="mt-0.5 shrink-0"
aria-hidden="true"
/>
<p>
<span className="text-foreground font-medium">
{t('settings.sources.wiki.livingTitle')}
</span>{' '}
{t('settings.sources.wiki.livingExplainer')}
{canEdit && (
<> {t('settings.sources.wiki.livingExplainerEditable')}</>
)}
</p>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<div className="border-border md:w-64 md:shrink-0 md:border-r md:pr-4">
{loadingPages ? (
@@ -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 (
<div
className={`flex items-center gap-2.5 rounded-2xl border px-4 py-2.5 text-sm ${
isError
? 'border-destructive/40 bg-destructive/5'
: 'border-primary/30 bg-primary/5 dark:bg-primary/10'
}`}
>
<span aria-hidden="true" className="text-base leading-none">
✏️
</span>
<span className="text-foreground font-medium">
{t(`conversation.wikiWrite.${actionKey}`, {
defaultValue: t('conversation.wikiWrite.edited'),
})}
</span>
{path && (
<code className="dark:bg-card min-w-0 truncate rounded-md bg-black/10 px-1.5 py-0.5 font-mono text-xs">
{path}
</code>
)}
{isError && (
<span className="text-destructive text-xs">
{t('conversation.wikiWrite.failed')}
</span>
)}
</div>
);
}
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({
</div>
)}
{/* Wiki write cards — always visible (D24: visibility is the control) */}
{wikiWriteCalls.length > 0 && (
<div className="fade-in mt-4 mr-5 ml-3 grid w-[90vw] grid-cols-1 gap-2 md:w-[70vw] lg:w-full">
{wikiWriteCalls.map((toolCall, index) => (
<WikiWriteToolCallCard
key={`wiki-write-${toolCall.call_id ?? index}`}
toolCall={toolCall}
/>
))}
</div>
)}
{/* Regular tool calls accordion */}
{resolvedCalls.length > 0 && (
<>
@@ -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(
<I18nextProvider i18n={testI18n}>
<WikiWriteToolCallCard toolCall={toolCall} />
</I18nextProvider>,
);
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('<code');
});
});
describe('ToolCalls placement', () => {
const renderToolCalls = (toolCalls: ToolCallsType[]): string =>
renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
<ToolCalls toolCalls={toolCalls} />
</I18nextProvider>,
);
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);
});
});
@@ -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>): 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');
});
});
+26
View File
@@ -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_/, '');
}
+37 -3
View File
@@ -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",
@@ -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<Phase>('confirm');
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<ConvertSummary | null>(null);
const pollTimer = useRef<number | null>(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 (
<Modal
open={modalState === 'ACTIVE'}
onOpenChange={(o) => !o && closeModal()}
hideTitle
title={t('settings.sources.wiki.convert.title')}
size="md"
mobileVariant="sheet"
className="max-w-[480px]"
isPerformingTask={phase === 'converting'}
>
<div className="flex flex-col gap-5 px-1 py-1">
<h2 className="text-foreground text-xl font-semibold">
{t('settings.sources.wiki.convert.title')}
</h2>
{phase === 'confirm' && (
<>
<p className="text-muted-foreground text-sm">
{document?.name
? t('settings.sources.wiki.convert.intro', {
name: document.name,
})
: t('settings.sources.wiki.convert.introGeneric')}
</p>
<ul className="text-muted-foreground list-disc space-y-1.5 pl-5 text-sm">
<li>{t('settings.sources.wiki.convert.costReparse')}</li>
<li>{t('settings.sources.wiki.convert.costSkipped')}</li>
<li>{t('settings.sources.wiki.convert.costIrreversible')}</li>
</ul>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={closeModal}
className="w-full rounded-3xl px-6 sm:w-auto"
>
{t('cancel')}
</Button>
<Button
type="button"
onClick={handleConvert}
className="w-full rounded-3xl px-6 sm:w-auto"
>
{t('settings.sources.wiki.convert.confirm')}
</Button>
</div>
</>
)}
{phase === 'converting' && (
<div className="flex flex-col items-center gap-3 py-6">
<Spinner size="medium" />
<p className="text-muted-foreground text-sm">
{t('settings.sources.wiki.convert.inProgress')}
</p>
</div>
)}
{phase === 'summary' && summary && (
<>
<div className="rounded-xl bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/30 dark:text-green-200">
{[
t('settings.sources.wiki.convert.summaryPages', {
count: summary.pagesCreated,
}),
t('settings.sources.wiki.convert.summarySkipped', {
count: summary.skipped.length,
}),
].join(' · ')}
</div>
{summary.skipped.length > 0 && (
<div className="flex flex-col gap-1.5">
<p className="text-foreground text-sm font-medium">
{t('settings.sources.wiki.convert.skippedHeading')}
</p>
<ul className="text-muted-foreground max-h-40 list-disc space-y-1 overflow-auto pl-5 text-xs">
{summary.skipped.map((s) => (
<li key={s.file} title={s.reason}>
{s.file}
{s.reason ? ` — ${s.reason}` : ''}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={closeModal}
className="w-full rounded-3xl px-6 sm:w-auto"
>
{t('settings.sources.wiki.convert.done')}
</Button>
</div>
</>
)}
{phase === 'error' && (
<>
<div className="rounded-xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/40 dark:text-red-300">
{error}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={closeModal}
className="w-full rounded-3xl px-6 sm:w-auto"
>
{t('cancel')}
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
+45 -1
View File
@@ -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<ActiveState>('INACTIVE');
const [documentToConvert, setDocumentToConvert] = useState<Doc | null>(null);
const [convertModalState, setConvertModalState] =
useState<ActiveState>('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}
/>
<ConvertToWikiModal
modalState={convertModalState}
setModalState={(state) => {
setConvertModalState(state);
if (state === 'INACTIVE') {
setDocumentToConvert(null);
}
}}
document={documentToConvert}
onConverted={() => refreshDocs(undefined, currentPage, rowsPerPage)}
/>
</div>
);
}
@@ -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' });
});
});
+89
View File
@@ -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<Response>;
getTaskStatus: (taskId: string, token: string | null) => Promise<Response>;
}
export async function startWikiConversion(
service: Pick<ConvertService, 'convertToWiki'>,
sourceId: string,
token: string | null,
): Promise<ConvertStart> {
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<string, unknown>;
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<string, unknown>)?.message as string | undefined);
return { status: 'failed', message };
}
return { status: 'pending' };
}
export async function pollTaskOnce(
service: Pick<ConvertService, 'getTaskStatus'>,
taskId: string,
token: string | null,
): Promise<ConvertPoll> {
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);
}
+2 -2
View File
@@ -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', () => {
+2 -2
View File
@@ -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',