PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / admin / sample-docs / SampleDocsFlow.js

SampleDocsFlow.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/admin/sample-docs/SampleDocsFlow.js

598 lines 27.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React from "react";
2 import { useState, useEffect, useRef } from "@wordpress/element";
3 import { __, _n, sprintf } from "@wordpress/i18n";
4 import { Sparkle, IconClose, IconCheck, IconArrowRight, IconAlert } from "./icons";
5 import {
6 DetectingView,
7 ProfileBody,
8 GeneratingBody,
9 useGenProgress,
10 PreviewGroups,
11 GenerateBtn,
12 articleTitle,
13 } from "./Pieces";
14 import { detectSite, generateDocs, generateOutline, generateArticle, insertDocs, undoDocs } from "./api";
15
16 // Keep the "analyzing" view up for at least this long so a fast detect() never
17 // just flashes the screen — the user should always see it run. (fbs)
18 const MIN_DETECT_MS = 2000;
19
20 // Category display palette (mirrors SampleDocs::PALETTE server-side).
21 const SD_PALETTE = ["#00B884", "#3B82F6", "#8B5CF6", "#F59E0B", "#0EA5E9", "#EF4444"];
22
23 // How many article-expansion calls to run at once. 2 keeps wall-clock reasonable
24 // without risking the user's host running out of PHP workers (each call = 1 worker).
25 const ARTICLE_CONCURRENCY = 2;
26
27 function titleFor(stage, isFaq) {
28 switch (stage) {
29 case "detecting":
30 case "profile":
31 return isFaq ? __("Generate sample FAQs", "betterdocs") : __("Generate sample docs", "betterdocs");
32 case "generating":
33 return isFaq ? __("Generating your FAQs", "betterdocs") : __("Generating your docs", "betterdocs");
34 case "preview":
35 return __("Preview & confirm", "betterdocs");
36 case "success":
37 return __("All done", "betterdocs");
38 case "error":
39 return __("Something went wrong", "betterdocs");
40 default:
41 return "";
42 }
43 }
44
45 /* apiFetch surfaces WP's raw critical-error page (HTML markup) as e.message on
46 an unhandled 500 — never render that in the modal. The generic critical-error
47 copy is useless to the user anyway, so map it to the actionable fallback;
48 otherwise strip any tags and keep the server's readable message. */
49 function errorMessage(e, fallback) {
50 if (!e?.message || typeof e.message !== "string" || e?.code === "internal_server_error") {
51 return fallback;
52 }
53 const text = e.message.replace(/<\/?[^>]+>/g, " ").replace(/\s+/g, " ").trim();
54 return text || fallback;
55 }
56
57 /* Expand one article, retrying once on failure. Resolves to the article body or
58 null (the caller drops nulls and assembles a partial KB). */
59 async function fetchArticleWithRetry(jobToken, index) {
60 for (let attempt = 0; attempt < 2; attempt++) {
61 try {
62 const res = await generateArticle({ job_token: jobToken, index });
63 if (res?.article?.content_html) return res.article;
64 } catch (e) {
65 // fall through to retry / give up
66 }
67 }
68 return null;
69 }
70
71 /* Merge the proxy's generated groups (with content_html) back onto the suggested
72 categories so we keep their icon/color for display. A general FAQ's groups are
73 designed by the AI, so there may be more of them than there were topic chips —
74 fall back to the palette by index rather than giving every extra group the same
75 color.
76
77 product_category / all_products MUST be preserved: the insert step sends this merged
78 array straight back to the server, which routes each product-FAQ group by those fields
79 (all_products → shown on every product page; product_category → attached to that
80 WooCommerce category). Dropping them here would silently un-assign every generated
81 product FAQ. */
82 function mergeGenerated(cats, generated) {
83 return (generated || []).map((g, i) => {
84 const base = cats[i] || {};
85 return {
86 id: base.id || "g" + i,
87 name: g.name || base.name,
88 icon: base.icon || "book",
89 color: base.color || SD_PALETTE[i % SD_PALETTE.length],
90 slug: g.slug,
91 description: g.description,
92 articles: g.articles || [],
93 product_category: g.product_category || "",
94 all_products: !!g.all_products,
95 };
96 });
97 }
98
99 export default function SampleDocsFlow({ contentType = "docs", onClose, onComplete }) {
100 const [stage, setStage] = useState("detecting");
101 const [profile, setProfile] = useState(null);
102 const [cats, setCats] = useState([]);
103 const [generated, setGenerated] = useState([]);
104 const [quota, setQuota] = useState(false);
105 const [error, setError] = useState(null);
106 const [summary, setSummary] = useState(null);
107 const [inserting, setInserting] = useState(false);
108 const [intent, setIntent] = useState("");
109
110 // Deep-docs orchestrator state: real per-article progress (no timed fake).
111 // genPhase: "outlining" → "writing" → "finalizing" → "done".
112 const [genPhase, setGenPhase] = useState("outlining");
113 const [genRows, setGenRows] = useState([]);
114 const [genPct, setGenPct] = useState(0);
115 // Number of articles the AI planned in the outline but that failed to expand
116 // (after a retry). Surfaced in the preview so a partial KB isn't shown as complete.
117 const [missingCount, setMissingCount] = useState(0);
118 const jobTokenRef = useRef("");
119
120 const genResult = useRef(null);
121 const [apiDone, setApiDone] = useState(false);
122
123 // Guards for async work: `mountedRef` blocks state updates after unmount;
124 // `cancelledRef` lets the user cancel an in-flight generation (QA-006, QA-010).
125 const mountedRef = useRef(true);
126 const cancelledRef = useRef(false);
127 useEffect(() => () => { mountedRef.current = false; }, []);
128
129 // Both "faq" and "product_faq" use the FAQ-flavored copy; only "docs" differs.
130 const isFaq = contentType !== "docs";
131 const draftedCount = generated.reduce((n, c) => n + (c.articles?.length || 0), 0);
132
133 /* ESC closes the modal. */
134 useEffect(() => {
135 const onKey = (e) => e.key === "Escape" && onClose();
136 document.addEventListener("keydown", onKey);
137 return () => document.removeEventListener("keydown", onKey);
138 }, [onClose]);
139
140 /* Kick off detection on mount. */
141 useEffect(() => {
142 let alive = true;
143 const startedAt = Date.now();
144 detectSite(contentType)
145 .then((res) => {
146 if (!alive) return;
147 const wait = Math.max(0, MIN_DETECT_MS - (Date.now() - startedAt));
148 setTimeout(() => {
149 if (!alive) return;
150 setProfile(res.profile);
151 setCats((res.categories || []).map((c) => ({ ...c })));
152 setStage("profile");
153 }, wait);
154 })
155 .catch((e) => {
156 if (!alive) return;
157 setError(errorMessage(e, __("Could not analyze your site.", "betterdocs")));
158 setStage("error");
159 });
160 return () => {
161 alive = false;
162 };
163 }, []);
164
165 const showPreview = () => {
166 setGenerated(genResult.current || []);
167 setStage("preview");
168 };
169
170 /* Entry point from the Generate button: docs use the deep outline→expand flow;
171 FAQ / product FAQ keep the single-call path. */
172 const startGenerate = () => {
173 if (isFaq) {
174 startGenerateClassic();
175 } else {
176 startGenerateDeep();
177 }
178 };
179
180 /* Single-call generation (FAQ/product_faq, and the docs fallback when the proxy
181 has no /outline endpoint yet). */
182 const startGenerateClassic = () => {
183 genResult.current = null;
184 cancelledRef.current = false;
185 setApiDone(false);
186 setGenPhase("writing"); // classic path uses the timed animation, not "outlining"
187 setStage("generating");
188
189 generateDocs({ content_type: contentType, profile, categories: cats, intent })
190 .then((res) => {
191 if (cancelledRef.current || !mountedRef.current) return;
192 genResult.current = mergeGenerated(cats, res.categories);
193 setApiDone(true);
194 })
195 .catch((e) => {
196 if (cancelledRef.current || !mountedRef.current) return;
197 if (e?.code === "quota_exceeded") {
198 setQuota(true);
199 setStage("profile");
200 } else {
201 setError(errorMessage(e, __("The AI service could not generate docs.", "betterdocs")));
202 setStage("error");
203 }
204 });
205 };
206
207 /* Deep KB (docs): design the IA, then expand every article with live progress. */
208 const startGenerateDeep = async () => {
209 genResult.current = null;
210 cancelledRef.current = false;
211 setApiDone(false);
212 setQuota(false);
213 setGenPct(0);
214 setGenRows([]);
215 setMissingCount(0);
216 setGenPhase("outlining");
217 setStage("generating");
218
219 let out;
220 try {
221 // Send the topics the user kept: removing a chip must skip that category and
222 // its docs. (The outline call used to send none, so pruning did nothing.)
223 out = await generateOutline({ profile, intent, categories: cats });
224 } catch (e) {
225 if (cancelledRef.current || !mountedRef.current) return;
226 if (e?.code === "quota_exceeded") {
227 setQuota(true);
228 setStage("profile");
229 return;
230 }
231 // Old proxy without /outline → fall back to the single-call docs path.
232 if (e?.data?.proxy_status === 404) {
233 startGenerateClassic();
234 return;
235 }
236 setError(errorMessage(e, __("The AI service could not design your knowledge base.", "betterdocs")));
237 setStage("error");
238 return;
239 }
240 if (cancelledRef.current || !mountedRef.current) return;
241
242 const outlineCats = (out?.outline?.categories || []).map((c, i) => ({
243 id: c.id || "oc" + i,
244 name: c.name,
245 slug: c.slug,
246 icon: c.icon || "book",
247 color: c.color || SD_PALETTE[i % SD_PALETTE.length],
248 description: c.description,
249 articles: Array.isArray(c.articles) ? c.articles : [],
250 }));
251
252 const total = out?.outline?.total_articles || outlineCats.reduce((n, c) => n + c.articles.length, 0);
253 if (!outlineCats.length || total === 0) {
254 setError(__("The AI service returned an empty knowledge base. Please try again.", "betterdocs"));
255 setStage("error");
256 return;
257 }
258
259 jobTokenRef.current = out.job_token;
260 setCats(outlineCats);
261 setGenRows(outlineCats.map((c) => ({ id: c.id, state: "pending", written: 0 })));
262 setGenPct(0);
263 setGenPhase("writing");
264
265 // Flat work list of { catId, index } across the whole KB.
266 const work = [];
267 outlineCats.forEach((c) => c.articles.forEach((a) => work.push({ catId: c.id, index: a.index })));
268
269 const results = {}; // index → expanded article body
270 let done = 0;
271 const refreshProgress = () => {
272 setGenPct(Math.round((done / total) * 100));
273 setGenRows(
274 outlineCats.map((c) => {
275 const written = c.articles.filter((a) => results[a.index]).length;
276 const state = written >= c.articles.length ? "done" : written > 0 ? "writing" : "pending";
277 return { id: c.id, state, written };
278 })
279 );
280 };
281
282 // Concurrency-limited workers sharing one queue (JS is single-threaded, so
283 // Array.shift() is race-free here).
284 const queue = work.slice();
285 const worker = async () => {
286 while (queue.length) {
287 if (cancelledRef.current || !mountedRef.current) return;
288 const item = queue.shift();
289 const article = await fetchArticleWithRetry(jobTokenRef.current, item.index);
290 if (cancelledRef.current || !mountedRef.current) return;
291 if (article) results[item.index] = article;
292 done++;
293 refreshProgress();
294 }
295 };
296 await Promise.all(Array.from({ length: ARTICLE_CONCURRENCY }, () => worker()));
297 if (cancelledRef.current || !mountedRef.current) return;
298
299 // Assemble the generated KB, dropping any article that failed after a retry.
300 const generatedCats = outlineCats
301 .map((c) => ({
302 id: c.id,
303 name: c.name,
304 slug: c.slug,
305 icon: c.icon,
306 color: c.color,
307 description: c.description,
308 articles: c.articles
309 .map((a) => results[a.index])
310 .filter(Boolean)
311 .map((b) => ({
312 title: b.title,
313 slug: b.slug,
314 type: b.type,
315 content_html: b.content_html,
316 excerpt: b.excerpt,
317 })),
318 }))
319 .filter((c) => c.articles.length > 0);
320
321 if (!generatedCats.length) {
322 setError(__("The AI service could not write any articles. Please try again.", "betterdocs"));
323 setStage("error");
324 return;
325 }
326
327 // Some articles the outline planned may have failed to expand (and were
328 // dropped above). Record how many so the preview can flag a partial KB
329 // instead of presenting it as complete.
330 const generatedCount = generatedCats.reduce((n, c) => n + c.articles.length, 0);
331 setMissingCount(Math.max(0, total - generatedCount));
332
333 genResult.current = generatedCats;
334 setGenPct(100);
335 setGenPhase("done");
336 // Brief terminal state, then reveal the preview.
337 setTimeout(() => {
338 if (cancelledRef.current || !mountedRef.current) return;
339 showPreview();
340 }, 500);
341 };
342
343 /* Abort an in-flight generation and return to the profile screen (QA-010). */
344 const cancelGenerate = () => {
345 cancelledRef.current = true;
346 genResult.current = null;
347 setApiDone(false);
348 setGenPhase("outlining");
349 setStage("profile");
350 };
351
352 /* Navigate to the BetterDocs AI settings tab (where the OpenAI key lives). */
353 const goToAiSettings = () => {
354 const base = (typeof window !== "undefined" && window.betterdocs_admin?.admin_url) || "";
355 window.location.href = `${base}admin.php?page=betterdocs-settings&tab=tab-betterdocs-ai`;
356 };
357
358 const insert = () => {
359 setInserting(true);
360 insertDocs({ content_type: contentType, categories: generated })
361 .then((res) => {
362 setSummary(res);
363 setStage("success");
364 })
365 .catch((e) => {
366 setError(errorMessage(e, __("Could not insert the docs.", "betterdocs")));
367 setStage("error");
368 })
369 .finally(() => setInserting(false));
370 };
371
372 const removeSamples = () => {
373 undoDocs(contentType).finally(() => {
374 if (onComplete) onComplete({ removed: true });
375 else onClose();
376 });
377 };
378
379 const finish = () => {
380 if (onComplete) onComplete({ ...(summary || { inserted: true }), categories: generated });
381 else onClose();
382 };
383
384 return (
385 <div className="betterdocs-sd-overlay" onClick={onClose}>
386 <div className="betterdocs-sd-modal betterdocs-sd-pop" onClick={(e) => e.stopPropagation()}>
387 <div className="betterdocs-sd-modal-header">
388 <span className="betterdocs-sd-modal-spark"><Sparkle size={18} /></span>
389 <div className="betterdocs-sd-modal-heading">
390 <div className="betterdocs-sd-modal-title">{titleFor(stage, isFaq)}</div>
391 <div className="betterdocs-sd-modal-sub">{__("BetterDocs AI · hosted, no API key required", "betterdocs")}</div>
392 </div>
393 <button type="button" className="betterdocs-sd-modal-close" onClick={onClose}>
394 <IconClose size={17} />
395 </button>
396 </div>
397
398 <div className="betterdocs-sd-modal-body">
399 {stage === "detecting" && <DetectingView />}
400
401 {stage === "profile" && (
402 <ProfileBody
403 profile={profile}
404 cats={cats}
405 setCats={setCats}
406 quota={quota}
407 contentType={contentType}
408 intent={intent}
409 setIntent={setIntent}
410 />
411 )}
412
413 {stage === "generating" && (
414 isFaq ? (
415 <GenStage cats={cats} ready={apiDone} onDone={showPreview} contentType={contentType} />
416 ) : genPhase === "outlining" ? (
417 <DetectingView
418 title={__("Designing your knowledge base…", "betterdocs")}
419 sub={__("Planning the categories and articles from what your site is about.", "betterdocs")}
420 />
421 ) : (
422 <GeneratingBody
423 cats={cats}
424 rows={genRows}
425 pct={genPct}
426 finalizing={genPhase === "finalizing"}
427 done={genPhase === "done"}
428 contentType={contentType}
429 />
430 )
431 )}
432
433 {stage === "preview" && (
434 <div className="betterdocs-sd-fade">
435 <div className="betterdocs-sd-preview-intro">
436 <div className="betterdocs-sd-preview-intro-title">
437 {isFaq
438 ? __("Your starter FAQs are ready", "betterdocs")
439 : missingCount > 0
440 ? __("Your starter knowledge base is ready (partial)", "betterdocs")
441 : __("Your starter knowledge base is ready", "betterdocs")}
442 </div>
443 <div className="betterdocs-sd-preview-intro-sub">
444 {sprintf(
445 _n(
446 "Review the %d item, then add it as a draft to publish when you're ready.",
447 "Review the %d items, then add them as drafts — publish the ones you want when you're ready.",
448 draftedCount,
449 "betterdocs"
450 ),
451 draftedCount
452 )}
453 </div>
454 </div>
455 {missingCount > 0 && (
456 <div className="betterdocs-sd-preview-warning" role="alert">
457 <span className="betterdocs-sd-preview-warning-icon">!</span>
458 <span>
459 {sprintf(
460 _n(
461 "%d article couldn't be generated and was skipped. You can add what's here, or discard and try again for the full set.",
462 "%d articles couldn't be generated and were skipped. You can add what's here, or discard and try again for the full set.",
463 missingCount,
464 "betterdocs"
465 ),
466 missingCount
467 )}
468 </span>
469 </div>
470 )}
471 <PreviewGroups cats={generated} contentType={contentType} />
472 </div>
473 )}
474
475 {stage === "success" && (
476 <div className="betterdocs-sd-success betterdocs-sd-fade">
477 <span className="betterdocs-sd-success-check"><IconCheck size={26} sw={2.6} /></span>
478 <div className="betterdocs-sd-success-title">
479 {isFaq ? __("Sample FAQs added as drafts", "betterdocs") : __("Sample docs added as drafts", "betterdocs")}
480 </div>
481 <div className="betterdocs-sd-success-sub">
482 {summary
483 ? (isFaq
484 ? sprintf(__("%1$d FAQ groups · %2$d questions saved as drafts. Review and publish the ones you want to go live.", "betterdocs"), summary.categories, summary.articles)
485 : sprintf(__("%1$d categories · %2$d docs saved as drafts. Review and publish the ones you want to go live — nothing is public until you do.", "betterdocs"), summary.categories, summary.articles))
486 : __("Saved as drafts.", "betterdocs")}
487 </div>
488 </div>
489 )}
490
491 {stage === "error" && (
492 <div className="betterdocs-sd-error betterdocs-sd-fade">
493 <span className="betterdocs-sd-error-icon"><IconAlert size={22} /></span>
494 <div className="betterdocs-sd-error-title">{error}</div>
495 <div className="betterdocs-sd-error-sub">
496 {__("This is usually temporary. Please try again in a moment.", "betterdocs")}
497 </div>
498 </div>
499 )}
500 </div>
501
502 {stage !== "detecting" && (
503 <div className="betterdocs-sd-modal-footer">
504 {stage === "generating" && (
505 <button type="button" className="betterdocs-sd-btn-ghost" onClick={cancelGenerate}>
506 {__("Cancel", "betterdocs")}
507 </button>
508 )}
509
510 {stage === "profile" && (
511 <>
512 <button type="button" className="betterdocs-sd-btn-ghost" onClick={onClose}>
513 {__("Cancel", "betterdocs")}
514 </button>
515 {quota ? (
516 <button type="button" className="betterdocs-sd-btn-primary" onClick={goToAiSettings}>
517 {__("Add API key in Settings", "betterdocs")} <IconArrowRight size={16} />
518 </button>
519 ) : (
520 // A general FAQ needs no topic chips — with none, the AI designs it
521 // from the site profile alone. Only the deterministic product FAQ is
522 // driven by its categories.
523 <GenerateBtn onClick={startGenerate} disabled={contentType === "product_faq" && cats.length === 0}>
524 {isFaq ? __("Generate My FAQs", "betterdocs") : __("Generate My Knowledge Base", "betterdocs")}
525 </GenerateBtn>
526 )}
527 </>
528 )}
529
530 {stage === "preview" && (
531 <>
532 <button type="button" className="betterdocs-sd-btn-discard" onClick={onClose}>
533 {__("Discard", "betterdocs")}
534 </button>
535 <button type="button" className="betterdocs-sd-btn-primary" onClick={insert} disabled={inserting}>
536 <IconCheck size={16} sw={2.6} />{" "}
537 {inserting
538 ? __("Inserting…", "betterdocs")
539 : isFaq ? __("Insert all FAQs", "betterdocs") : __("Insert all docs", "betterdocs")}
540 </button>
541 </>
542 )}
543
544 {stage === "success" && (
545 <>
546 <button type="button" className="betterdocs-sd-btn-ghost" onClick={removeSamples}>
547 {isFaq ? __("Remove sample FAQs", "betterdocs") : __("Remove sample docs", "betterdocs")}
548 </button>
549 <button type="button" className="betterdocs-sd-btn-primary" onClick={finish}>
550 {isFaq ? __("View my FAQs", "betterdocs") : __("View my docs", "betterdocs")}
551 </button>
552 </>
553 )}
554
555 {stage === "error" && (
556 <>
557 <button type="button" className="betterdocs-sd-btn-ghost" onClick={onClose}>
558 {__("Close", "betterdocs")}
559 </button>
560 <button type="button" className="betterdocs-sd-btn-primary" onClick={() => { setError(null); setStage("detecting"); reDetect(mountedRef, setProfile, setCats, setStage, setError, contentType); }}>
561 {__("Try again", "betterdocs")}
562 </button>
563 </>
564 )}
565 </div>
566 )}
567 </div>
568 </div>
569 );
570 }
571
572 function GenStage({ cats, ready, onDone, contentType }) {
573 const { rows, pct, finalizing, done } = useGenProgress(cats, ready, onDone);
574 return <GeneratingBody cats={cats} rows={rows} pct={pct} finalizing={finalizing} done={done} contentType={contentType} />;
575 }
576
577 function reDetect(mountedRef, setProfile, setCats, setStage, setError, contentType) {
578 const startedAt = Date.now();
579 detectSite(contentType)
580 .then((res) => {
581 if (!mountedRef.current) return;
582 const wait = Math.max(0, MIN_DETECT_MS - (Date.now() - startedAt));
583 setTimeout(() => {
584 if (!mountedRef.current) return;
585 setProfile(res.profile);
586 setCats((res.categories || []).map((c) => ({ ...c })));
587 setStage("profile");
588 }, wait);
589 })
590 .catch((e) => {
591 if (!mountedRef.current) return;
592 setError(errorMessage(e, __("Could not analyze your site.", "betterdocs")));
593 setStage("error");
594 });
595 }
596
597 export { articleTitle };
598