mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 00:16:20 +00:00
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:
1 parent
919fe10ab9
commit
80533f9383
14 files changed
+820
-10
No files matched your search
@@ -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)}`,
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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_/, '');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user