| 1 |
// ===== write-with-ai/StudioGenerateModal.js — "Write Documentation with BetterDocs AI" ===== |
| 2 |
// Superset modal ported from the AI Studio mockup (studio-modal.jsx + the |
| 3 |
// OutlineEditor from ai-modal.jsx), wired to the real REST endpoint and real |
| 4 |
// Gutenberg insertion. Brand-voice chips are intentionally omitted (deferred). |
| 5 |
// The token meter markup is built but kept hidden for now. |
| 6 |
|
| 7 |
import { __, sprintf } from "@wordpress/i18n"; |
| 8 |
import { normalizeAIError } from "../../utils/aiErrorMessage"; |
| 9 |
import { useState, useEffect, useRef } from "@wordpress/element"; |
| 10 |
import { dispatch, select } from "@wordpress/data"; |
| 11 |
|
| 12 |
import { |
| 13 |
Modal, AIcon, FieldLabel, TextInput, TextArea, Pick, DropdownSelect, Toggle, Banner, |
| 14 |
PrimaryBtn, GhostBtn, TextBtn, |
| 15 |
IcList, IcSummary, IcChevDown, IcPlus, IcTrash, IcGrip, IcArrowR, IcRefresh, IcCheck, AIPill, AIMarkWhite, |
| 16 |
} from "../ai-shared/kit"; |
| 17 |
import { PreviewAccept, ERR_LABEL } from "../ai-shared/PreviewAccept"; |
| 18 |
import InstructionPicker from "../ai-shared/InstructionPicker"; |
| 19 |
import { callWriteWithAI, callWriteWithAIMultipart, getConfig, suggestTerms } from "./api"; |
| 20 |
import { cleanGeneratedHtml, extractFirstHeading, insertContentToEditor } from "./insert"; |
| 21 |
import SuggestTermsPreview from "./SuggestTermsPreview"; |
| 22 |
import GitBrowse from "./GitBrowse"; |
| 23 |
|
| 24 |
// Tone + length selectors. The empty/"any" values send no directive (backend default). |
| 25 |
const TONE_OPTIONS = [ |
| 26 |
{ value: "", label: __("Default", "betterdocs") }, |
| 27 |
{ value: "friendly", label: __("Friendly", "betterdocs") }, |
| 28 |
{ value: "technical", label: __("Technical", "betterdocs") }, |
| 29 |
{ value: "formal", label: __("Formal", "betterdocs") }, |
| 30 |
{ value: "casual", label: __("Casual", "betterdocs") }, |
| 31 |
]; |
| 32 |
|
| 33 |
const SIZE_OPTIONS = [ |
| 34 |
{ value: "any", label: __("Any", "betterdocs") }, |
| 35 |
{ value: "short", label: __("Short", "betterdocs") }, |
| 36 |
{ value: "medium", label: __("Medium", "betterdocs") }, |
| 37 |
{ value: "long", label: __("Long", "betterdocs") }, |
| 38 |
]; |
| 39 |
|
| 40 |
// ── Local icons (sliders for the settings accordion, sparkle for "Generate") ── |
| 41 |
const IcSliders = ({ size = 16 }) => ( |
| 42 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" |
| 43 |
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> |
| 44 |
<line x1="4" y1="7" x2="20" y2="7" /><line x1="4" y1="12" x2="20" y2="12" /><line x1="4" y1="17" x2="20" y2="17" /> |
| 45 |
<circle cx="9" cy="7" r="2.2" fill="white" /><circle cx="15" cy="12" r="2.2" fill="white" /><circle cx="9" cy="17" r="2.2" fill="white" /> |
| 46 |
</svg> |
| 47 |
); |
| 48 |
const IcSpark = ({ size = 16 }) => ( |
| 49 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="white" stroke="none"> |
| 50 |
<path d="M12 2l1.9 5.1L19 9l-5.1 1.9L12 16l-1.9-5.1L5 9l5.1-1.9z" /> |
| 51 |
<path d="M19 14l.9 2.4L22 17l-2.1.6L19 20l-.9-2.4L16 17l2.1-.6z" /> |
| 52 |
</svg> |
| 53 |
); |
| 54 |
// Pencil that inherits the tab's color (currentColor) — the kit's AIMark is fixed to the AI accent. |
| 55 |
const IcPencil = (p) => ( |
| 56 |
<AIcon {...p}> |
| 57 |
<path d="M12 20h9" /> |
| 58 |
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" /> |
| 59 |
</AIcon> |
| 60 |
); |
| 61 |
// Git branch glyph (inherits currentColor) for the "From Git" tab. |
| 62 |
const IcGit = (p) => ( |
| 63 |
<AIcon {...p}> |
| 64 |
<circle cx="6" cy="6" r="2.5" /> |
| 65 |
<circle cx="6" cy="18" r="2.5" /> |
| 66 |
<circle cx="18" cy="9" r="2.5" /> |
| 67 |
<path d="M6 8.5v7" /> |
| 68 |
<path d="M18 11.5a6 6 0 0 1-6 6H8.5" /> |
| 69 |
</AIcon> |
| 70 |
); |
| 71 |
// Paperclip glyph for the "From Attachment" tab (inherits currentColor). |
| 72 |
const IcPaperclip = (p) => ( |
| 73 |
<AIcon {...p}> |
| 74 |
<path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3 3 0 0 1 4.24 4.24l-9.2 9.19a1 1 0 0 1-1.41-1.41l8.49-8.49" /> |
| 75 |
</AIcon> |
| 76 |
); |
| 77 |
// Padlock + star — for the "Pro feature" upsell panel (inherit currentColor). |
| 78 |
const IcLock = (p) => ( |
| 79 |
<AIcon {...p}> |
| 80 |
<rect x="5" y="11" width="14" height="10" rx="2" /> |
| 81 |
<path d="M8 11V7a4 4 0 0 1 8 0v4" /> |
| 82 |
</AIcon> |
| 83 |
); |
| 84 |
const IcProStar = ({ size = 14 }) => ( |
| 85 |
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="none"> |
| 86 |
<path d="M12 2l2.6 6.3L21 9l-5 4.1L17.5 21 12 17.3 6.5 21 8 13.1 3 9l6.4-.7z" /> |
| 87 |
</svg> |
| 88 |
); |
| 89 |
|
| 90 |
// ── Underline tabs (Prompt | From source) ──────────────────────────────────── |
| 91 |
// Typography, colour and active border-bottom mirror the admin |
| 92 |
// `.betterdocs-metabox-tabs` (see react-src/admin/scss/unified-metabox.scss): |
| 93 |
// 14px / 1.5 / weight 500, inactive #d0d5dd, active + hover green (#00b884), |
| 94 |
// active gets a 2px green bottom border. |
| 95 |
const Segmented = ({ value, onChange, tabs }) => ( |
| 96 |
<div style={{ display: "inline-flex", gap: 12 }}> |
| 97 |
{tabs.map((t) => { |
| 98 |
const active = value === t.id; |
| 99 |
const idle = "var(--text-color-400, #d0d5dd)"; |
| 100 |
return ( |
| 101 |
<button key={t.id} type="button" onClick={() => onChange(t.id)} style={{ |
| 102 |
display: "inline-flex", alignItems: "center", gap: 7, |
| 103 |
padding: "4px 4px 13px", border: "none", background: "transparent", cursor: "pointer", |
| 104 |
borderBottom: `2px solid ${active ? "var(--green)" : "transparent"}`, marginBottom: -1, |
| 105 |
fontSize: 14, lineHeight: 1.5, fontWeight: 500, whiteSpace: "nowrap", |
| 106 |
color: active ? "var(--green)" : idle, |
| 107 |
transition: "color .2s ease, border-color .2s ease", |
| 108 |
}} |
| 109 |
onMouseEnter={(e) => { if (!active) e.currentTarget.style.color = "var(--green)"; }} |
| 110 |
onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = idle; }}> |
| 111 |
{t.icon}{t.label} |
| 112 |
</button> |
| 113 |
); |
| 114 |
})} |
| 115 |
</div> |
| 116 |
); |
| 117 |
|
| 118 |
// ── Segmented choice control (tone / length) ───────────────────────────────── |
| 119 |
// Segmented choice control (tone / length) — mirrors .betterdocs-faq-page-tabs: |
| 120 |
// a white bordered track with a soft shadow; the active segment gets the ash |
| 121 |
// (--text-color-50) fill + green (--base-color-700) label. |
| 122 |
const SegControl = ({ value, onChange, options }) => ( |
| 123 |
<div style={{ display: "flex", gap: 2, flex: 1, background: "var(--base-background, #fff)", border: "1px solid var(--text-color-200, #eaecf0)", padding: 4, borderRadius: 8 }}> |
| 124 |
{options.map((o) => { |
| 125 |
const active = value === o.value; |
| 126 |
return ( |
| 127 |
<button key={o.value || "default"} type="button" onClick={() => onChange(o.value)} style={{ |
| 128 |
flex: "1 1 0", minWidth: 0, padding: "7px 6px", borderRadius: 9, border: "none", cursor: "pointer", |
| 129 |
fontSize: 13, fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", |
| 130 |
background: active ? "var(--text-color-50, #f9fafb)" : "transparent", |
| 131 |
color: active ? "var(--green)" : "var(--muted)", |
| 132 |
transition: "background .12s, color .12s", |
| 133 |
}}> |
| 134 |
{o.label} |
| 135 |
</button> |
| 136 |
); |
| 137 |
})} |
| 138 |
</div> |
| 139 |
); |
| 140 |
|
| 141 |
// ── Quick prompt chip (From Git) — toggles a starter snippet into the prompt ── |
| 142 |
const QuickChip = ({ active, onClick, children }) => ( |
| 143 |
<button type="button" onClick={onClick} style={{ |
| 144 |
display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 11px", borderRadius: 999, cursor: "pointer", |
| 145 |
fontSize: 12.5, fontWeight: 600, whiteSpace: "nowrap", |
| 146 |
border: `1px solid ${active ? "var(--green)" : "var(--text-color-200, #eaecf0)"}`, |
| 147 |
background: active ? "rgba(0,184,132,.08)" : "var(--base-background, #fff)", |
| 148 |
color: active ? "var(--green)" : "var(--ink-2)", |
| 149 |
transition: "background .12s, border-color .12s, color .12s", |
| 150 |
}} |
| 151 |
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.borderColor = "var(--text-color-300, #dde2e4)"; e.currentTarget.style.background = "#f4f6f8"; } }} |
| 152 |
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.borderColor = "var(--text-color-200, #eaecf0)"; e.currentTarget.style.background = "var(--base-background, #fff)"; } }}> |
| 153 |
<span style={{ display: "flex" }}>{active ? <IcCheck size={13} sw={2.6} /> : <IcPlus size={13} sw={2.2} />}</span> |
| 154 |
{children} |
| 155 |
</button> |
| 156 |
); |
| 157 |
|
| 158 |
// ── One row inside the Advanced settings card ──────────────────────────────── |
| 159 |
const SettingRow = ({ label, children }) => ( |
| 160 |
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 0" }}> |
| 161 |
<span style={{ width: 62, flexShrink: 0, fontSize: 13, fontWeight: 700, color: "var(--ink-2)" }}>{label}</span> |
| 162 |
{children} |
| 163 |
</div> |
| 164 |
); |
| 165 |
const ToggleRow = ({ title, subtitle, checked, onChange }) => ( |
| 166 |
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, padding: "14px 0" }}> |
| 167 |
<div> |
| 168 |
<div style={{ fontSize: 13.5, fontWeight: 700, color: "var(--ink)" }}>{title}</div> |
| 169 |
<div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 2 }}>{subtitle}</div> |
| 170 |
</div> |
| 171 |
<Toggle checked={checked} onChange={onChange} /> |
| 172 |
</div> |
| 173 |
); |
| 174 |
|
| 175 |
// ── Read-only platform + model indicator ───────────────────────────────────── |
| 176 |
const ModelPill = ({ platform, model }) => { |
| 177 |
const label = [platform, model].filter(Boolean).join(" · "); |
| 178 |
if (!label) return null; |
| 179 |
return ( |
| 180 |
<span title={__("AI platform & model configured in BetterDocs → Settings → AI", "betterdocs")} style={{ |
| 181 |
display: "inline-flex", alignItems: "center", gap: 6, |
| 182 |
padding: "6px 11px", borderRadius: 999, background: "var(--text-color-50, #f9fafb)", |
| 183 |
border: "1px solid var(--border)", fontSize: 12, fontWeight: 700, color: "var(--ink-2)", whiteSpace: "nowrap", |
| 184 |
}}> |
| 185 |
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "var(--green)" }} /> |
| 186 |
{label} |
| 187 |
</span> |
| 188 |
); |
| 189 |
}; |
| 190 |
|
| 191 |
// ── Token meter — markup built but hidden for now (design-complete, inactive) ── |
| 192 |
const TokenMeter = ({ estimated = 0, max = 4000, hidden = true }) => { |
| 193 |
const pct = Math.min(100, Math.round((estimated / max) * 100)); |
| 194 |
return ( |
| 195 |
<div data-bd-token-meter style={{ display: hidden ? "none" : "flex", alignItems: "center", gap: 8 }}> |
| 196 |
<span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--muted-2)", whiteSpace: "nowrap" }}> |
| 197 |
{__("Tokens", "betterdocs")} |
| 198 |
</span> |
| 199 |
<span style={{ position: "relative", width: 90, height: 6, borderRadius: 999, background: "#eef0f3", overflow: "hidden" }}> |
| 200 |
<span style={{ position: "absolute", inset: 0, width: `${pct}%`, background: "var(--ai)", borderRadius: 999 }} /> |
| 201 |
</span> |
| 202 |
<span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--muted)", whiteSpace: "nowrap" }}> |
| 203 |
~{estimated}/{max} |
| 204 |
</span> |
| 205 |
</div> |
| 206 |
); |
| 207 |
}; |
| 208 |
|
| 209 |
// ── Editable outline (H2/H3, drag to reorder, inline edit, add/remove) ──────── |
| 210 |
const OutlineEditor = ({ items, setItems, onExpand, onRegenerate, onDiscard }) => { |
| 211 |
const [dragId, setDragId] = useState(null); |
| 212 |
const [overId, setOverId] = useState(null); |
| 213 |
|
| 214 |
const move = (from, to) => { |
| 215 |
setItems((list) => { |
| 216 |
const next = [...list]; |
| 217 |
const [m] = next.splice(from, 1); |
| 218 |
next.splice(to, 0, m); |
| 219 |
return next; |
| 220 |
}); |
| 221 |
}; |
| 222 |
const onDrop = (targetId) => { |
| 223 |
const from = items.findIndex((i) => i.id === dragId); |
| 224 |
const to = items.findIndex((i) => i.id === targetId); |
| 225 |
if (from > -1 && to > -1 && from !== to) move(from, to); |
| 226 |
setDragId(null); setOverId(null); |
| 227 |
}; |
| 228 |
|
| 229 |
return ( |
| 230 |
<div> |
| 231 |
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}> |
| 232 |
<AIPill>{__("AI outline", "betterdocs")}</AIPill> |
| 233 |
<span style={{ fontSize: 12, color: "var(--muted)", fontWeight: 600 }}> |
| 234 |
{sprintf(__("%d sections · drag to reorder", "betterdocs"), items.length)} |
| 235 |
</span> |
| 236 |
</div> |
| 237 |
|
| 238 |
<div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 8, maxHeight: 340, overflowY: "auto" }}> |
| 239 |
{items.map((it) => { |
| 240 |
const isH3 = it.level === "h3"; |
| 241 |
return ( |
| 242 |
<div key={it.id} |
| 243 |
draggable |
| 244 |
onDragStart={() => setDragId(it.id)} |
| 245 |
onDragOver={(e) => { e.preventDefault(); setOverId(it.id); }} |
| 246 |
onDrop={() => onDrop(it.id)} |
| 247 |
onDragEnd={() => { setDragId(null); setOverId(null); }} |
| 248 |
style={{ |
| 249 |
display: "flex", alignItems: "center", gap: 8, |
| 250 |
padding: "7px 8px", marginLeft: isH3 ? 26 : 0, borderRadius: 9, |
| 251 |
background: overId === it.id && dragId !== it.id ? "var(--text-color-100, #f0f0f0)" : dragId === it.id ? "#f4f6f8" : "transparent", |
| 252 |
opacity: dragId === it.id ? 0.5 : 1, transition: "background .12s", |
| 253 |
}}> |
| 254 |
<span style={{ color: "#b6bdc7", cursor: "grab", display: "flex", flexShrink: 0 }}><IcGrip size={14} /></span> |
| 255 |
<button type="button" |
| 256 |
onClick={() => setItems((list) => list.map((x) => x.id === it.id ? { ...x, level: x.level === "h2" ? "h3" : "h2" } : x))} |
| 257 |
title={__("Toggle heading level", "betterdocs")} |
| 258 |
style={{ |
| 259 |
flexShrink: 0, width: 32, height: 24, borderRadius: 6, cursor: "pointer", |
| 260 |
fontSize: 11, fontWeight: 800, letterSpacing: 0.3, |
| 261 |
background: isH3 ? "var(--text-color-50, #f9fafb)" : "var(--text-color-100, #f0f0f0)", |
| 262 |
color: isH3 ? "var(--muted)" : "var(--text-color-700, #344054)", |
| 263 |
border: `1px solid ${isH3 ? "var(--border-soft)" : "var(--text-color-300, #dde2e4)"}`, |
| 264 |
}}> |
| 265 |
{isH3 ? "H3" : "H2"} |
| 266 |
</button> |
| 267 |
<input type="text" |
| 268 |
value={it.text} |
| 269 |
onChange={(e) => setItems((list) => list.map((x) => x.id === it.id ? { ...x, text: e.target.value } : x))} |
| 270 |
style={{ |
| 271 |
flex: 1, border: "1px solid transparent", background: "transparent", |
| 272 |
borderRadius: 7, padding: "6px 8px", outline: "none", |
| 273 |
fontSize: isH3 ? 13.5 : 14, fontWeight: isH3 ? 500 : 600, color: "var(--ink)", |
| 274 |
transition: "background .12s, border-color .12s", |
| 275 |
}} |
| 276 |
onFocus={(e) => { e.target.style.background = "white"; e.target.style.borderColor = "var(--border)"; }} |
| 277 |
onBlur={(e) => { e.target.style.background = "transparent"; e.target.style.borderColor = "transparent"; }} /> |
| 278 |
<button type="button" onClick={() => setItems((list) => list.filter((x) => x.id !== it.id))} |
| 279 |
title={__("Remove", "betterdocs")} style={{ |
| 280 |
width: 28, height: 28, borderRadius: 7, color: "#b6bdc7", flexShrink: 0, border: "none", |
| 281 |
background: "transparent", cursor: "pointer", |
| 282 |
display: "flex", alignItems: "center", justifyContent: "center", |
| 283 |
}} |
| 284 |
onMouseEnter={(e) => { e.currentTarget.style.background = "#fef2f2"; e.currentTarget.style.color = "#dc2626"; }} |
| 285 |
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "#b6bdc7"; }}> |
| 286 |
<IcTrash size={14} /> |
| 287 |
</button> |
| 288 |
</div> |
| 289 |
); |
| 290 |
})} |
| 291 |
|
| 292 |
<button type="button" |
| 293 |
onClick={() => setItems((list) => [...list, { id: `new-${Date.now()}`, level: "h2", text: __("New section", "betterdocs") }])} |
| 294 |
style={{ |
| 295 |
display: "flex", alignItems: "center", gap: 8, width: "100%", border: "none", |
| 296 |
padding: "9px 10px", marginTop: 2, borderRadius: 9, background: "transparent", cursor: "pointer", |
| 297 |
color: "var(--muted)", fontSize: 13.5, fontWeight: 600, textAlign: "left", |
| 298 |
transition: "background .12s, color .12s", |
| 299 |
}} |
| 300 |
onMouseEnter={(e) => { e.currentTarget.style.background = "#f4f6f8"; e.currentTarget.style.color = "var(--ink-2)"; }} |
| 301 |
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "var(--muted)"; }}> |
| 302 |
<IcPlus size={15} sw={2.2} /> {__("Add section", "betterdocs")} |
| 303 |
</button> |
| 304 |
</div> |
| 305 |
|
| 306 |
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 18 }}> |
| 307 |
<TextBtn onClick={onDiscard} style={{ marginRight: "auto" }}>{__("Discard", "betterdocs")}</TextBtn> |
| 308 |
<GhostBtn icon={<IcRefresh size={15} />} onClick={onRegenerate}>{__("Regenerate outline", "betterdocs")}</GhostBtn> |
| 309 |
<PrimaryBtn icon={<IcArrowR size={16} sw={2.2} />} onClick={onExpand} disabled={items.length === 0}>{__("Expand into Doc", "betterdocs")}</PrimaryBtn> |
| 310 |
</div> |
| 311 |
</div> |
| 312 |
); |
| 313 |
}; |
| 314 |
|
| 315 |
const backBtn = { |
| 316 |
width: 34, height: 34, borderRadius: 9, flexShrink: 0, border: "none", cursor: "pointer", |
| 317 |
display: "flex", alignItems: "center", justifyContent: "center", |
| 318 |
color: "var(--muted)", background: "#f4f6f8", |
| 319 |
}; |
| 320 |
|
| 321 |
const SOURCE_TYPES = [ |
| 322 |
{ value: "", label: __("Auto-detect", "betterdocs") }, |
| 323 |
{ value: "transcript", label: __("Support transcript", "betterdocs") }, |
| 324 |
{ value: "forum", label: __("Forum thread", "betterdocs") }, |
| 325 |
{ value: "notes", label: __("Raw notes", "betterdocs") }, |
| 326 |
]; |
| 327 |
|
| 328 |
const SOURCE_PLACEHOLDERS = { |
| 329 |
"": __("Paste any existing content — a support chat, forum thread, or rough notes. BetterDocs AI will turn it into a structured doc.", "betterdocs"), |
| 330 |
transcript: __("Paste the full support conversation (chat or email). BetterDocs AI will turn it into a structured doc.", "betterdocs"), |
| 331 |
forum: __("Paste the forum thread, including replies. BetterDocs AI will turn it into a structured doc.", "betterdocs"), |
| 332 |
notes: __("Paste your rough notes or bullet points. BetterDocs AI will expand them into a structured doc.", "betterdocs"), |
| 333 |
}; |
| 334 |
|
| 335 |
const GIT_PLACEHOLDER = __("Paste a GitHub pull request or file URL — e.g. https://github.com/owner/repo/pull/123 or .../blob/main/docs/guide.md", "betterdocs"); |
| 336 |
const UPGRADE_URL = "https://betterdocs.co/upgrade"; |
| 337 |
|
| 338 |
// From Git input modes: paste a URL, or browse the connected account's repos. |
| 339 |
// ── Attachment kinds ───────────────────────────────────────────────────────── |
| 340 |
// |
| 341 |
// Recordings take a different route through the modal than every other |
| 342 |
// attachment: they are transcribed first, the author corrects the transcript, |
| 343 |
// and only then is the doc generated from that text. The extension list comes |
| 344 |
// from the server (REST\WriteWithAI::media_exts) so the two can never drift. |
| 345 |
const MEDIA_EXTS_FALLBACK = [ |
| 346 |
"mp3", "mpga", "m4a", "wav", "flac", "ogg", "oga", "mp4", "mpeg", "mpg", "webm", |
| 347 |
]; |
| 348 |
|
| 349 |
// Read lazily, not at module scope: the bundle can evaluate before |
| 350 |
// wp_localize_script has run on some screens, and a snapshot taken then would |
| 351 |
// freeze the fallback list even once the real config arrives. |
| 352 |
const mediaExts = () => getConfig().media_exts || MEDIA_EXTS_FALLBACK; |
| 353 |
|
| 354 |
const extOf = (name) => String(name || "").split(".").pop().toLowerCase(); |
| 355 |
|
| 356 |
const isRecording = (f) => !!f && mediaExts().indexOf(extOf(f.name)) !== -1; |
| 357 |
|
| 358 |
// Accept attribute for the file input, built from the same list. |
| 359 |
const acceptAttr = () => |
| 360 |
[".pdf", ".docx", ".txt", ".md", ".markdown", ".png", ".jpg", ".jpeg", ".webp"] |
| 361 |
.concat(mediaExts().map((e) => `.${e}`)) |
| 362 |
.join(","); |
| 363 |
|
| 364 |
// wp_localize_script stringifies top-level scalars, so every number here |
| 365 |
// arrives as "26214400" and every boolean as "1" or "". Coerce explicitly |
| 366 |
// rather than leaning on JS comparison rules — "0" is truthy, and a silently |
| 367 |
// truthy "can transcribe" flag would offer a button that always fails. |
| 368 |
const num = (value, fallback) => { |
| 369 |
const n = parseInt(value, 10); |
| 370 |
return isNaN(n) || n <= 0 ? fallback : n; |
| 371 |
}; |
| 372 |
|
| 373 |
const flag = (value) => !!value && "0" !== String(value); |
| 374 |
|
| 375 |
// The server cap, already clamped to what the host will accept — a site with |
| 376 |
// upload_max_filesize = 8M advertises 8 MB here, not our 25. |
| 377 |
const capFor = (f) => { |
| 378 |
const cfg = getConfig(); |
| 379 |
return isRecording(f) |
| 380 |
? num(cfg.max_media_bytes, 25165824) |
| 381 |
: num(cfg.max_upload_bytes, 5242880); |
| 382 |
}; |
| 383 |
|
| 384 |
const asMB = (bytes) => `${Math.round((bytes / 1048576) * 10) / 10} MB`; |
| 385 |
|
| 386 |
const GIT_MODES = [ |
| 387 |
{ value: "url", label: __("Paste URL", "betterdocs") }, |
| 388 |
{ value: "browse", label: __("Browse repository", "betterdocs") }, |
| 389 |
]; |
| 390 |
|
| 391 |
// Quick prompt starters for the From Git tab. One click appends the snippet to |
| 392 |
// the additional-prompt field (click again to remove) — they replace the old |
| 393 |
// framing dropdown, so the AI reads the intent straight from the prompt text. |
| 394 |
const QUICK_PROMPTS = [ |
| 395 |
{ id: "feature", label: __("Document this feature / PR", "betterdocs"), |
| 396 |
text: __("Explain what this feature does and how to use it in clear end-user documentation terms — focus on the user-facing behavior, not the code or implementation details.", "betterdocs") }, |
| 397 |
{ id: "howto", label: __("Turn into a how-to guide", "betterdocs"), |
| 398 |
text: __("Turn this into a concise, numbered step-by-step how-to guide.", "betterdocs") }, |
| 399 |
{ id: "whatsnew", label: __("Write a what's-new doc", "betterdocs"), |
| 400 |
text: __("Write a \"What's New\" announcement-style doc that highlights the changes and explains why they matter to users.", "betterdocs") }, |
| 401 |
{ id: "adapt", label: __("Adapt this doc to my site", "betterdocs"), |
| 402 |
text: __("Rewrite this as a fresh doc for my site, keeping the meaning but improving the clarity, structure, and tone.", "betterdocs") }, |
| 403 |
]; |
| 404 |
|
| 405 |
const withId = (outline) => |
| 406 |
(outline || []).map((o, i) => ({ id: `o-${i}`, level: o.level === "h3" ? "h3" : "h2", text: String(o.text || "") })); |
| 407 |
|
| 408 |
// Taxonomies offered in the preview-step term suggestions (order = display order). |
| 409 |
// Slugs double as the canonical post-attribute key (doc_category/doc_tag have no |
| 410 |
// rest_base), matching the native Docs AI Suite panel. |
| 411 |
const SUGGEST_TAXONOMIES = [ |
| 412 |
{ taxonomy: "doc_category", label: __("Categories", "betterdocs") }, |
| 413 |
{ taxonomy: "doc_tag", label: __("Tags", "betterdocs") }, |
| 414 |
]; |
| 415 |
|
| 416 |
// ── The modal ───────────────────────────────────────────────────────────────── |
| 417 |
const StudioGenerateModal = ({ open, onClose }) => { |
| 418 |
const config = getConfig(); |
| 419 |
const hasKey = !!config.has_key; |
| 420 |
const languageOptions = Array.isArray(config.language_options) ? config.language_options : []; |
| 421 |
const showLanguage = !!config.is_multilingual_active && languageOptions.length > 0; |
| 422 |
const instructionChoices = Array.isArray(config.instructions) ? config.instructions : []; |
| 423 |
|
| 424 |
// From Git gating: Pro must be active AND Git Sync enabled + authenticated. |
| 425 |
const isProActive = !!config.is_pro_active; |
| 426 |
const git = config.git || {}; |
| 427 |
const gitReady = isProActive && !!git.enabled && !!git.connected; |
| 428 |
// Site owners can hide the whole "From Git" tab from Settings → Git Sync |
| 429 |
// (default on). Only applies when Pro is active; without Pro the tab still |
| 430 |
// shows the upsell. |
| 431 |
const showGitTab = !isProActive || git.ai_enabled !== false; |
| 432 |
// Repository browsing is GitHub-only (GitLab connections are single-project); |
| 433 |
// GitLab users get the Paste-URL mode only. |
| 434 |
const gitProvider = git.provider || "github"; |
| 435 |
const canBrowse = gitProvider === "github"; |
| 436 |
// Falls back to the Git Sync settings anchor (not the AI settings anchor). |
| 437 |
const gitSettingsUrl = git.settings_url || "admin.php?page=betterdocs-settings#git-sync"; |
| 438 |
|
| 439 |
const [tab, setTab] = useState("prompt"); |
| 440 |
const [stage, setStage] = useState("form"); // form · loadingOutline · loadingDoc · outline · preview · previewError |
| 441 |
const [prompt, setPrompt] = useState(""); |
| 442 |
const [source, setSource] = useState(""); |
| 443 |
const [srcType, setSrcType] = useState(SOURCE_TYPES[0].value); |
| 444 |
const [gitUrl, setGitUrl] = useState(""); |
| 445 |
const [gitPrompt, setGitPrompt] = useState(""); // additional prompt for the From Git tab |
| 446 |
const [gitMode, setGitMode] = useState("url"); // url · browse |
| 447 |
const [file, setFile] = useState(null); // selected file for the From Attachment tab |
| 448 |
const [filePrompt, setFilePrompt] = useState(""); // optional prompt for the From Attachment tab |
| 449 |
// Recording flow: a transcript sits between the upload and generation so the |
| 450 |
// author can fix mis-heard product names before the doc is written. |
| 451 |
const [transcript, setTranscript] = useState(""); |
| 452 |
const [transcribing, setTranscribing] = useState(false); |
| 453 |
const [fileError, setFileError] = useState(""); |
| 454 |
const [transcriptClipped, setTranscriptClipped] = useState(false); |
| 455 |
// Keyboard focus on the (visually hidden) file input, drawn on its drop zone. |
| 456 |
const [pickerFocus, setPickerFocus] = useState(false); |
| 457 |
// Bumped whenever the file changes or is removed. A transcription answers |
| 458 |
// only if it is still the latest request — otherwise Remove (or a new pick) |
| 459 |
// during "Transcribing…" let the old file's transcript land on the new file. |
| 460 |
const transcribeReq = useRef(0); |
| 461 |
const [browseUrl, setBrowseUrl] = useState(""); // URL resolved by the browse picker |
| 462 |
const [lang, setLang] = useState(""); |
| 463 |
const [tone, setTone] = useState(""); |
| 464 |
const [docSize, setDocSize] = useState("any"); |
| 465 |
const [selectedInstructionIds, setSelectedInstructionIds] = useState([]); |
| 466 |
const [advOpen, setAdvOpen] = useState(false); |
| 467 |
const [existingTitle, setExistingTitle] = useState(""); |
| 468 |
const [hasContent, setHasContent] = useState(false); |
| 469 |
const [regenerateTitle, setRegenerateTitle] = useState(true); |
| 470 |
const [overwrite, setOverwrite] = useState(true); |
| 471 |
const [genTitle, setGenTitle] = useState(""); |
| 472 |
const [html, setHtml] = useState(""); |
| 473 |
const [outline, setOutline] = useState([]); |
| 474 |
const [error, setError] = useState(null); |
| 475 |
// Preview-step term suggestions — one independent on/off control per taxonomy. |
| 476 |
const [suggestTax, setSuggestTax] = useState({ doc_category: true, doc_tag: true }); |
| 477 |
const [suggestions, setSuggestions] = useState({}); // { [taxonomy]: [{name,term_id,is_new}] } |
| 478 |
const [suggestLoading, setSuggestLoading] = useState(false); |
| 479 |
const [selectedKeys, setSelectedKeys] = useState(() => new Set()); // `${taxonomy}::${name}` kept |
| 480 |
|
| 481 |
// Snapshot the editor's title + content state, and reset, each time it opens. |
| 482 |
useEffect(() => { |
| 483 |
if (!open) return; |
| 484 |
setStage("form"); |
| 485 |
setError(null); |
| 486 |
setGenTitle(""); |
| 487 |
setOverwrite(true); |
| 488 |
setRegenerateTitle(true); |
| 489 |
setSelectedInstructionIds([]); |
| 490 |
setGitMode("url"); |
| 491 |
setBrowseUrl(""); |
| 492 |
setGitPrompt(""); |
| 493 |
setFile(null); |
| 494 |
setFilePrompt(""); |
| 495 |
setTranscript(""); |
| 496 |
setTranscribing(false); |
| 497 |
setFileError(""); |
| 498 |
setTranscriptClipped(false); |
| 499 |
transcribeReq.current += 1; |
| 500 |
setSuggestTax({ doc_category: true, doc_tag: true }); |
| 501 |
setSuggestions({}); |
| 502 |
setSelectedKeys(new Set()); |
| 503 |
setSuggestLoading(false); |
| 504 |
try { |
| 505 |
const editorTitle = window.wp?.data?.select("core/editor")?.getEditedPostAttribute("title") || ""; |
| 506 |
setExistingTitle(editorTitle); |
| 507 |
const blocks = window.wp?.data?.select("core/block-editor")?.getBlocks() || []; |
| 508 |
setHasContent(blocks.length > 0); |
| 509 |
} catch (e) { /* editor not ready */ } |
| 510 |
}, [open]); |
| 511 |
|
| 512 |
// Generate a title when the doc has none, or when the user opted to regenerate. |
| 513 |
const titleRequested = !existingTitle || regenerateTitle; |
| 514 |
|
| 515 |
const canPrompt = hasKey && prompt.trim().length > 0; |
| 516 |
const canSource = hasKey && source.trim().length > 20; |
| 517 |
// The URL "Generate from Git" sends: pasted, or resolved by the browse picker. |
| 518 |
const effectiveGitUrl = (gitMode === "browse" ? browseUrl : gitUrl).trim(); |
| 519 |
const canGit = hasKey && gitReady && effectiveGitUrl.length > 8; |
| 520 |
// A recording needs a transcript before it can generate; every other file |
| 521 |
// type goes straight through as it always has. |
| 522 |
const canAttachment = hasKey && !!file && (!isRecording(file) || transcript.trim().length > 0); |
| 523 |
// On the Git tab without a working connection we show only the upsell/connect |
| 524 |
// panel — no Guides, Advanced settings, or generate footer. |
| 525 |
const gitBlocked = tab === "git" && !gitReady; |
| 526 |
const est = Math.round(((prompt + source).length) / 4) + 200; |
| 527 |
|
| 528 |
// Append the chosen language to the prompt at send-time (client-side). |
| 529 |
const langSuffix = () => { |
| 530 |
if (!showLanguage || !lang) return ""; |
| 531 |
const found = languageOptions.find((o) => String(o.value) === String(lang)); |
| 532 |
const label = found ? found.label : lang; |
| 533 |
return " " + sprintf(__("Write the documentation in %s.", "betterdocs"), label); |
| 534 |
}; |
| 535 |
|
| 536 |
// Shared directives sent on every generation: tone, size, and whether the AI |
| 537 |
// should also produce a title (assembled into the prompt by the backend). |
| 538 |
const directives = () => ({ tone, doc_size: docSize, generate_title: titleRequested, instruction_ids: selectedInstructionIds }); |
| 539 |
|
| 540 |
// Quick-prompt chips toggle their snippet into the From Git prompt: append if |
| 541 |
// absent, strip it out (tidying stray blank lines) if already there. |
| 542 |
const toggleQuickPrompt = (text) => setGitPrompt((prev) => |
| 543 |
prev.includes(text) |
| 544 |
? prev.split(text).join("").replace(/\n{2,}/g, "\n").trim() |
| 545 |
: (prev.trim() ? prev.trim() + "\n" + text : text) |
| 546 |
); |
| 547 |
|
| 548 |
// Term suggestions are gated by an API key AND the Docs AI Suite setting (the |
| 549 |
// same gate the REST endpoint enforces). Each taxonomy then has its own toggle. |
| 550 |
const canSuggest = hasKey && !!config.suggest_terms_enabled; |
| 551 |
|
| 552 |
// Taxonomies to actually request: whichever per-taxonomy toggles are on. |
| 553 |
const enabledSuggestTaxes = () => SUGGEST_TAXONOMIES.filter( |
| 554 |
(t) => suggestTax[t.taxonomy] |
| 555 |
); |
| 556 |
|
| 557 |
const toggleTax = (tax) => setSuggestTax((prev) => ({ ...prev, [tax]: !prev[tax] })); |
| 558 |
|
| 559 |
const toggleKey = (key) => setSelectedKeys((prev) => { |
| 560 |
const next = new Set(prev); |
| 561 |
if (next.has(key)) next.delete(key); else next.add(key); |
| 562 |
return next; |
| 563 |
}); |
| 564 |
|
| 565 |
// Fetch suggestions for each enabled taxonomy in parallel off the freshly |
| 566 |
// generated doc. |
| 567 |
const fetchTermSuggestions = (contentHtml, docTitle) => { |
| 568 |
const taxes = enabledSuggestTaxes(); |
| 569 |
setSuggestions({}); |
| 570 |
setSelectedKeys(new Set()); |
| 571 |
if (!taxes.length) { setSuggestLoading(false); return; } |
| 572 |
setSuggestLoading(true); |
| 573 |
Promise.all( |
| 574 |
taxes.map((t) => |
| 575 |
suggestTerms({ taxonomy: t.taxonomy, content: contentHtml, title: docTitle }) |
| 576 |
.then((list) => [t.taxonomy, list]) |
| 577 |
) |
| 578 |
) |
| 579 |
.then((pairs) => { |
| 580 |
const next = {}; |
| 581 |
pairs.forEach(([tax, list]) => { next[tax] = list || []; }); |
| 582 |
setSuggestions(next); |
| 583 |
}) |
| 584 |
.finally(() => setSuggestLoading(false)); |
| 585 |
}; |
| 586 |
|
| 587 |
// Apply every kept suggestion (from an enabled taxonomy) to the (unsaved) doc: |
| 588 |
// create new terms as needed, then assign via editPost keyed by the taxonomy |
| 589 |
// slug — mirroring the native Docs AI Suite panel so tokens update immediately. |
| 590 |
const applyKeptTerms = async () => { |
| 591 |
if (!selectedKeys.size) return; |
| 592 |
const { saveEntityRecord } = dispatch("core"); |
| 593 |
const editor = dispatch("core/editor"); |
| 594 |
const editorSelect = select("core/editor"); |
| 595 |
for (const t of SUGGEST_TAXONOMIES) { |
| 596 |
if (!suggestTax[t.taxonomy]) continue; |
| 597 |
const items = (suggestions[t.taxonomy] || []).filter((s) => selectedKeys.has(`${t.taxonomy}::${s.name}`)); |
| 598 |
if (!items.length) continue; |
| 599 |
const current = (editorSelect.getEditedPostAttribute(t.taxonomy) || []).map(Number); |
| 600 |
const toAdd = []; |
| 601 |
for (const s of items) { |
| 602 |
let termId = s.term_id; |
| 603 |
if (!termId) { |
| 604 |
try { |
| 605 |
const created = await saveEntityRecord("taxonomy", t.taxonomy, { name: s.name }); |
| 606 |
termId = created && created.id; |
| 607 |
} catch (e) { continue; } |
| 608 |
} |
| 609 |
termId = Number(termId); |
| 610 |
if (termId && !current.includes(termId) && !toAdd.includes(termId)) toAdd.push(termId); |
| 611 |
} |
| 612 |
if (toAdd.length) editor.editPost({ [t.taxonomy]: [...current, ...toAdd] }); |
| 613 |
} |
| 614 |
}; |
| 615 |
|
| 616 |
const handleDoc = (data) => { |
| 617 |
const newTitle = titleRequested ? extractFirstHeading(data.content) : ""; |
| 618 |
if (titleRequested) setGenTitle(newTitle); |
| 619 |
const cleaned = cleanGeneratedHtml(data.content); |
| 620 |
setHtml(cleaned); |
| 621 |
setStage("preview"); |
| 622 |
if (canSuggest && enabledSuggestTaxes().length) { |
| 623 |
fetchTermSuggestions(cleaned, newTitle || existingTitle); |
| 624 |
} |
| 625 |
}; |
| 626 |
|
| 627 |
// Refuse an oversize file here rather than uploading it and waiting for the |
| 628 |
// server to say no — the old flow pushed the whole 50 MB up the wire first. |
| 629 |
const pickFile = (picked) => { |
| 630 |
transcribeReq.current += 1; |
| 631 |
setTranscribing(false); |
| 632 |
setTranscript(""); |
| 633 |
setTranscriptClipped(false); |
| 634 |
setFileError(""); |
| 635 |
|
| 636 |
if (!picked) { |
| 637 |
setFile(null); |
| 638 |
return; |
| 639 |
} |
| 640 |
|
| 641 |
const cap = capFor(picked); |
| 642 |
if (picked.size > cap) { |
| 643 |
setFile(null); |
| 644 |
setFileError(sprintf( |
| 645 |
/* translators: 1: file name, 2: maximum allowed size. */ |
| 646 |
__("“%1$s” is larger than the %2$s limit. Trim it, or export a smaller version.", "betterdocs"), |
| 647 |
picked.name, |
| 648 |
asMB(cap) |
| 649 |
)); |
| 650 |
return; |
| 651 |
} |
| 652 |
|
| 653 |
setFile(picked); |
| 654 |
}; |
| 655 |
|
| 656 |
// Step one of the recording flow. Returns the transcript to the panel; no |
| 657 |
// doc is written and no stage change happens, so the author stays in the form. |
| 658 |
const transcribeFile = () => { |
| 659 |
if (!file) return; |
| 660 |
setError(null); |
| 661 |
setFileError(""); |
| 662 |
setTranscribing(true); |
| 663 |
|
| 664 |
const req = ++transcribeReq.current; |
| 665 |
|
| 666 |
callWriteWithAIMultipart("transcribe-attachment", {}, file) |
| 667 |
.then((data) => { |
| 668 |
if (req !== transcribeReq.current) return; |
| 669 |
setTranscript(String((data && data.transcript) || "")); |
| 670 |
setTranscriptClipped(!!(data && data.clipped)); |
| 671 |
setTranscribing(false); |
| 672 |
}) |
| 673 |
.catch((err) => { |
| 674 |
if (req !== transcribeReq.current) return; |
| 675 |
setFileError(normalizeAIError(err)); |
| 676 |
setTranscribing(false); |
| 677 |
}); |
| 678 |
}; |
| 679 |
|
| 680 |
const run = (action) => { |
| 681 |
setError(null); |
| 682 |
setStage(action === "generate-outline" ? "loadingOutline" : "loadingDoc"); |
| 683 |
|
| 684 |
// From Attachment posts a multipart request (the file + directives) and |
| 685 |
// lands on the same doc-preview flow as every other source. |
| 686 |
if (action === "from-attachment") { |
| 687 |
// A recording has already become text; generate from the transcript the |
| 688 |
// author just approved, through the ordinary grounded source path. Only |
| 689 |
// non-media attachments still need the file itself on the wire. |
| 690 |
if (isRecording(file)) { |
| 691 |
callWriteWithAI("from-source", { |
| 692 |
source: transcript, |
| 693 |
source_type: "recording", |
| 694 |
prompt: filePrompt + langSuffix(), |
| 695 |
...directives(), |
| 696 |
}) |
| 697 |
.then(handleDoc) |
| 698 |
.catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); }); |
| 699 |
return; |
| 700 |
} |
| 701 |
|
| 702 |
callWriteWithAIMultipart(action, { prompt: filePrompt + langSuffix(), ...directives() }, file) |
| 703 |
.then(handleDoc) |
| 704 |
.catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); }); |
| 705 |
return; |
| 706 |
} |
| 707 |
|
| 708 |
let payload; |
| 709 |
if (action === "from-source") { |
| 710 |
payload = { source, source_type: srcType, prompt: prompt + langSuffix(), ...directives() }; |
| 711 |
} else if (action === "from-git") { |
| 712 |
payload = { git_url: effectiveGitUrl, prompt: gitPrompt + langSuffix(), ...directives() }; |
| 713 |
} else { |
| 714 |
payload = { prompt: prompt + langSuffix(), ...directives() }; |
| 715 |
} |
| 716 |
|
| 717 |
callWriteWithAI(action, payload) |
| 718 |
.then((data) => { |
| 719 |
if (action === "generate-outline") { |
| 720 |
setOutline(withId(data.outline)); |
| 721 |
setStage("outline"); |
| 722 |
} else { |
| 723 |
handleDoc(data); |
| 724 |
} |
| 725 |
}) |
| 726 |
.catch((err) => { |
| 727 |
setError({ ...(err || {}), message: normalizeAIError(err) }); |
| 728 |
setStage(action === "generate-outline" ? "form" : "previewError"); |
| 729 |
}); |
| 730 |
}; |
| 731 |
|
| 732 |
const expand = () => { |
| 733 |
setError(null); |
| 734 |
setStage("loadingDoc"); |
| 735 |
callWriteWithAI("expand-outline", { |
| 736 |
outline: outline.map((o) => ({ level: o.level, text: o.text })), |
| 737 |
prompt: prompt + langSuffix(), |
| 738 |
...directives(), |
| 739 |
}) |
| 740 |
.then(handleDoc) |
| 741 |
.catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); }); |
| 742 |
}; |
| 743 |
|
| 744 |
const keepAndInsert = async () => { |
| 745 |
await applyKeptTerms(); |
| 746 |
insertContentToEditor({ title: titleRequested ? genTitle : "", html, isOverwrite: overwrite }); |
| 747 |
onClose(); |
| 748 |
}; |
| 749 |
|
| 750 |
const regenerateDoc = () => run(tab === "source" ? "from-source" : tab === "git" ? "from-git" : tab === "attachment" ? "from-attachment" : "generate-doc"); |
| 751 |
const showBack = stage === "outline" || stage === "preview" || stage === "previewError"; |
| 752 |
|
| 753 |
// Live summary shown on the collapsed "Advanced settings" bar. |
| 754 |
const toneLabel = (TONE_OPTIONS.find((o) => o.value === tone) || TONE_OPTIONS[0]).label; |
| 755 |
const sizeLabel = "any" === docSize |
| 756 |
? __("Any size", "betterdocs") |
| 757 |
: (SIZE_OPTIONS.find((o) => o.value === docSize) || SIZE_OPTIONS[0]).label; |
| 758 |
const enabledCount = (existingTitle && regenerateTitle ? 1 : 0) + (hasContent && overwrite ? 1 : 0) + (canSuggest ? enabledSuggestTaxes().length : 0); |
| 759 |
/* translators: 1: tone label, 2: length label, 3: number of enabled toggles. */ |
| 760 |
const advSummary = sprintf(__("%1$s · %2$s · %3$d on", "betterdocs"), toneLabel, sizeLabel, enabledCount); |
| 761 |
|
| 762 |
// Settings rows are conditional; build the list, then render with dividers. |
| 763 |
const settingRows = [ |
| 764 |
<SettingRow key="tone" label={__("Tone", "betterdocs")}> |
| 765 |
<SegControl value={tone} onChange={setTone} options={TONE_OPTIONS} /> |
| 766 |
</SettingRow>, |
| 767 |
<SettingRow key="length" label={__("Length", "betterdocs")}> |
| 768 |
<SegControl value={docSize} onChange={setDocSize} options={SIZE_OPTIONS} /> |
| 769 |
</SettingRow>, |
| 770 |
]; |
| 771 |
if (showLanguage) { |
| 772 |
settingRows.push( |
| 773 |
<SettingRow key="lang" label={__("Language", "betterdocs")}> |
| 774 |
<DropdownSelect value={lang} onChange={setLang} |
| 775 |
options={[{ value: "", label: __("Default (Site Language)", "betterdocs") }, ...languageOptions]} |
| 776 |
style={{ flex: 1 }} /> |
| 777 |
</SettingRow> |
| 778 |
); |
| 779 |
} |
| 780 |
if (existingTitle) { |
| 781 |
settingRows.push( |
| 782 |
<ToggleRow key="title" checked={regenerateTitle} onChange={setRegenerateTitle} |
| 783 |
title={__("Regenerate title", "betterdocs")} subtitle={__("Let AI rewrite the document title", "betterdocs")} /> |
| 784 |
); |
| 785 |
} |
| 786 |
if (hasContent) { |
| 787 |
settingRows.push( |
| 788 |
<ToggleRow key="overwrite" checked={overwrite} onChange={setOverwrite} |
| 789 |
title={__("Overwrite existing doc", "betterdocs")} subtitle={__("Replace current content instead of appending", "betterdocs")} /> |
| 790 |
); |
| 791 |
} |
| 792 |
if (canSuggest) { |
| 793 |
settingRows.push( |
| 794 |
<ToggleRow key="suggest-cat" checked={suggestTax.doc_category} onChange={() => toggleTax("doc_category")} |
| 795 |
title={__("Suggest categories", "betterdocs")} |
| 796 |
subtitle={__("Suggest doc categories after generating", "betterdocs")} />, |
| 797 |
<ToggleRow key="suggest-tag" checked={suggestTax.doc_tag} onChange={() => toggleTax("doc_tag")} |
| 798 |
title={__("Suggest tags", "betterdocs")} |
| 799 |
subtitle={__("Suggest doc tags after generating", "betterdocs")} /> |
| 800 |
); |
| 801 |
} |
| 802 |
|
| 803 |
return ( |
| 804 |
<Modal open={open} onClose={onClose} width={720} labelledBy="bd-wd-title"> |
| 805 |
{/* Header */} |
| 806 |
<div style={{ padding: "22px 26px 0" }}> |
| 807 |
<div style={{ display: "flex", alignItems: "center", gap: 13 }}> |
| 808 |
{showBack ? ( |
| 809 |
<button type="button" onClick={() => setStage("form")} title={__("Back", "betterdocs")} style={backBtn}> |
| 810 |
<AIcon d="M15 18l-6-6 6-6" size={17} sw={2} /> |
| 811 |
</button> |
| 812 |
) : ( |
| 813 |
<div style={{ |
| 814 |
width: 46, height: 46, borderRadius: 13, flexShrink: 0, background: "var(--green)", |
| 815 |
display: "flex", alignItems: "center", justifyContent: "center", |
| 816 |
boxShadow: "0 2px 6px rgba(0,184,132,.35)", |
| 817 |
}}> |
| 818 |
<AIMarkWhite size={22} /> |
| 819 |
</div> |
| 820 |
)} |
| 821 |
<div style={{ flex: 1, minWidth: 0 }}> |
| 822 |
<div id="bd-wd-title" style={{ fontSize: 19, fontWeight: 800, color: "var(--ink)", letterSpacing: -0.3, paddingRight: 30 }}> |
| 823 |
{__("Write Documentation", "betterdocs")} |
| 824 |
</div> |
| 825 |
{!showBack && ( |
| 826 |
<div style={{ fontSize: 13, color: "var(--muted)", marginTop: 2 }}> |
| 827 |
{__("Draft a doc with BetterDocs AI", "betterdocs")} |
| 828 |
</div> |
| 829 |
)} |
| 830 |
</div> |
| 831 |
</div> |
| 832 |
</div> |
| 833 |
|
| 834 |
{/* Tabs + model on one line. The modal width (720) is sized so the |
| 835 |
four source tabs and the "Platform · Model" pill share a single |
| 836 |
row with room to spare — at 640 the pill overflowed and clipped. */} |
| 837 |
{!showBack && stage === "form" && ( |
| 838 |
<div style={{ padding: "18px 26px 0px", borderBottom: "1px solid var(--border-soft)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}> |
| 839 |
<Segmented value={tab} onChange={setTab} tabs={[ |
| 840 |
{ id: "prompt", label: __("Prompt", "betterdocs"), icon: <IcPencil size={15} /> }, |
| 841 |
{ id: "source", label: __("From source", "betterdocs"), icon: <IcSummary size={15} /> }, |
| 842 |
...(showGitTab ? [{ id: "git", label: __("From Git", "betterdocs"), icon: <IcGit size={15} /> }] : []), |
| 843 |
{ id: "attachment", label: __("From Attachment", "betterdocs"), icon: <IcPaperclip size={15} /> }, |
| 844 |
]} /> |
| 845 |
{/* Lift the pill to align with the tab labels, not the bottom |
| 846 |
border — the tabs carry 13px bottom padding, so a centred |
| 847 |
pill otherwise sits low against the divider line. */} |
| 848 |
<div style={{ marginBottom: 10 }}> |
| 849 |
<ModelPill platform={config.platform_label} model={config.model_label || config.model} /> |
| 850 |
</div> |
| 851 |
</div> |
| 852 |
)} |
| 853 |
|
| 854 |
{/* Body — the 320px floor keeps the form/loading/preview steps a |
| 855 |
comfortable size, but the error step's content is tiny, so we let |
| 856 |
it size to content (no forced floor) and the footer sits at the |
| 857 |
modal bottom instead of floating above a large empty gap. */} |
| 858 |
<div style={{ padding: "16px 26px 22px", overflowY: "auto", minHeight: stage === "previewError" ? undefined : 320 }}> |
| 859 |
{stage === "form" && ( |
| 860 |
<> |
| 861 |
{!hasKey && ( |
| 862 |
<Banner tone="error" style={{ marginBottom: 16 }}> |
| 863 |
{__("Please insert your", "betterdocs")}{" "} |
| 864 |
<a href={config.settings_url || "#"} target="_blank" rel="noreferrer" style={{ color: "var(--wp-blue)", fontWeight: 700, textDecoration: "none" }}> |
| 865 |
{__("OpenAI API Key", "betterdocs")} |
| 866 |
</a>{" "} |
| 867 |
{__("to use this Write with AI feature.", "betterdocs")} |
| 868 |
</Banner> |
| 869 |
)} |
| 870 |
{hasKey && error && ( |
| 871 |
<Banner tone="error" style={{ marginBottom: 16 }}> |
| 872 |
<strong style={{ fontWeight: 700 }}>{ERR_LABEL[error.code] || __("Couldn't generate", "betterdocs")}.</strong> {error.message} |
| 873 |
</Banner> |
| 874 |
)} |
| 875 |
|
| 876 |
{tab === "prompt" && ( |
| 877 |
<> |
| 878 |
<FieldLabel>{__("What should this documentation cover?", "betterdocs")}</FieldLabel> |
| 879 |
<TextArea value={prompt} onChange={setPrompt} rows={5} |
| 880 |
placeholder={__("Describe what you want the documentation to cover — e.g. \"A getting-started guide for installing and configuring the plugin.\" BetterDocs AI handles the structure and formatting.", "betterdocs")} /> |
| 881 |
<div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}> |
| 882 |
{__("The clearer your request, the better the result. A title and formatting are added automatically.", "betterdocs")} |
| 883 |
</div> |
| 884 |
</> |
| 885 |
)} |
| 886 |
|
| 887 |
{tab === "source" && ( |
| 888 |
<> |
| 889 |
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 10 }}> |
| 890 |
<FieldLabel style={{ margin: 0 }}>{__("Source content:", "betterdocs")}</FieldLabel> |
| 891 |
<DropdownSelect value={srcType} onChange={setSrcType} options={SOURCE_TYPES} style={{ width: 200 }} /> |
| 892 |
</div> |
| 893 |
<TextArea value={source} onChange={setSource} rows={7} |
| 894 |
placeholder={SOURCE_PLACEHOLDERS[srcType] || SOURCE_PLACEHOLDERS[""]} /> |
| 895 |
<div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}> |
| 896 |
{__("BetterDocs AI uses only what's in your source — it won't invent details.", "betterdocs")} |
| 897 |
</div> |
| 898 |
</> |
| 899 |
)} |
| 900 |
|
| 901 |
{tab === "git" && !isProActive && ( |
| 902 |
/* State 1 — Pro not active: upgrade panel */ |
| 903 |
<div style={{ textAlign: "center", padding: "16px 8px 6px" }}> |
| 904 |
{/* Lock tile */} |
| 905 |
<div style={{ |
| 906 |
width: 56, height: 56, borderRadius: 15, margin: "0 auto", |
| 907 |
background: "#fff7e6", color: "#f0a615", |
| 908 |
display: "flex", alignItems: "center", justifyContent: "center", |
| 909 |
}}> |
| 910 |
<IcLock size={26} sw={1.9} /> |
| 911 |
</div> |
| 912 |
{/* Pro eyebrow */} |
| 913 |
<div style={{ display: "flex", justifyContent: "center", marginTop: 14 }}> |
| 914 |
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, color: "#f0a615", fontSize: 13.5, fontWeight: 800 }}> |
| 915 |
<IcProStar size={14} />{__("Pro", "betterdocs")} |
| 916 |
</span> |
| 917 |
</div> |
| 918 |
{/* Headline */} |
| 919 |
<div style={{ fontSize: 18, fontWeight: 800, color: "var(--ink)", marginTop: 10, letterSpacing: -0.3 }}> |
| 920 |
{__("Writing from Git is a Pro feature", "betterdocs")} |
| 921 |
</div> |
| 922 |
{/* Description */} |
| 923 |
<div style={{ fontSize: 13.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.55, maxWidth: 400, marginLeft: "auto", marginRight: "auto" }}> |
| 924 |
{__("Paste a pull request or repo file URL and let BetterDocs AI turn what you already shipped in Git into a polished doc.", "betterdocs")} |
| 925 |
</div> |
| 926 |
{/* Feature list */} |
| 927 |
<div style={{ display: "inline-flex", flexDirection: "column", gap: 12, textAlign: "left", marginTop: 22 }}> |
| 928 |
{[ |
| 929 |
__("Generate a doc from a Pull Request", "betterdocs"), |
| 930 |
__("Turn a repository file into a new doc", "betterdocs"), |
| 931 |
__("Works with GitHub & GitLab via Git Sync", "betterdocs"), |
| 932 |
].map((feature, i) => ( |
| 933 |
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 13.5, color: "var(--ink-2)" }}> |
| 934 |
<span style={{ color: "var(--green)", display: "flex", flexShrink: 0 }}><IcCheck size={17} sw={2.4} /></span> |
| 935 |
{feature} |
| 936 |
</div> |
| 937 |
))} |
| 938 |
</div> |
| 939 |
{/* CTA */} |
| 940 |
<div style={{ display: "flex", justifyContent: "center", marginTop: 24 }}> |
| 941 |
<PrimaryBtn onClick={() => window.open(UPGRADE_URL, "_blank", "noopener")}> |
| 942 |
{__("Upgrade Now", "betterdocs")}<IcArrowR size={16} sw={2.2} /> |
| 943 |
</PrimaryBtn> |
| 944 |
</div> |
| 945 |
</div> |
| 946 |
)} |
| 947 |
|
| 948 |
{tab === "git" && isProActive && !gitReady && ( |
| 949 |
/* State 2 — Pro active but Git Sync not enabled/connected */ |
| 950 |
<Banner tone="warn"> |
| 951 |
{__("Enable and connect", "betterdocs")}{" "} |
| 952 |
<a href={gitSettingsUrl} target="_blank" rel="noreferrer" style={{ color: "var(--wp-blue)", fontWeight: 700, textDecoration: "none" }}> |
| 953 |
{__("BetterDocs Git Sync", "betterdocs")} |
| 954 |
</a>{" "} |
| 955 |
{__("to generate documentation from a repository.", "betterdocs")} |
| 956 |
</Banner> |
| 957 |
)} |
| 958 |
|
| 959 |
{tab === "git" && gitReady && ( |
| 960 |
/* State 3 — connected: the From Git form */ |
| 961 |
<> |
| 962 |
{/* Source mode: Paste URL | Browse repository */} |
| 963 |
<div style={{ marginBottom: 14 }}> |
| 964 |
{canBrowse |
| 965 |
? <SegControl value={gitMode} onChange={setGitMode} options={GIT_MODES} /> |
| 966 |
: <FieldLabel style={{ margin: 0 }}>{__("Git URL:", "betterdocs")}</FieldLabel>} |
| 967 |
</div> |
| 968 |
|
| 969 |
{(!canBrowse || gitMode === "url") && ( |
| 970 |
<> |
| 971 |
<TextInput value={gitUrl} onChange={setGitUrl} placeholder={GIT_PLACEHOLDER} /> |
| 972 |
<div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}> |
| 973 |
{__("BetterDocs AI fetches the content over your connected Git account and writes from it — it won't invent details.", "betterdocs")} |
| 974 |
</div> |
| 975 |
</> |
| 976 |
)} |
| 977 |
|
| 978 |
{canBrowse && gitMode === "browse" && ( |
| 979 |
<GitBrowse onSelect={setBrowseUrl} settingsUrl={gitSettingsUrl} /> |
| 980 |
)} |
| 981 |
|
| 982 |
{/* Additional prompt + quick starters — layered on the Git source in both modes */} |
| 983 |
<div style={{ marginTop: 16 }}> |
| 984 |
<FieldLabel>{__("Add a prompt (optional)", "betterdocs")}</FieldLabel> |
| 985 |
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 10 }}> |
| 986 |
{QUICK_PROMPTS.map((q) => ( |
| 987 |
<QuickChip key={q.id} active={gitPrompt.includes(q.text)} onClick={() => toggleQuickPrompt(q.text)}> |
| 988 |
{q.label} |
| 989 |
</QuickChip> |
| 990 |
))} |
| 991 |
</div> |
| 992 |
<TextArea value={gitPrompt} onChange={setGitPrompt} rows={3} |
| 993 |
placeholder={__("Add extra instructions for this doc, or tap a suggestion above — e.g. \"Focus on the user-facing steps and keep it beginner-friendly.\"", "betterdocs")} /> |
| 994 |
</div> |
| 995 |
</> |
| 996 |
)} |
| 997 |
|
| 998 |
{tab === "attachment" && ( |
| 999 |
<> |
| 1000 |
<FieldLabel>{__("Upload a file to write from:", "betterdocs")}</FieldLabel> |
| 1001 |
<label style={{ |
| 1002 |
position: "relative", |
| 1003 |
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", |
| 1004 |
gap: 6, padding: "26px 16px", textAlign: "center", cursor: "pointer", |
| 1005 |
borderRadius: 10, border: "1.5px dashed var(--text-color-200, #d0d5dd)", |
| 1006 |
background: "var(--base-background, #fff)", |
| 1007 |
outline: pickerFocus ? "2px solid var(--wp-admin-theme-color, #2271b1)" : "none", |
| 1008 |
outlineOffset: 2, |
| 1009 |
}}> |
| 1010 |
{/* Visually hidden rather than display:none, which took the |
| 1011 |
input out of the tab order — the picker could not be |
| 1012 |
reached from the keyboard at all. */} |
| 1013 |
<input type="file" accept={acceptAttr()} |
| 1014 |
style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap", border: 0, opacity: 0 }} |
| 1015 |
onFocus={(e) => { let visible = true; try { visible = e.target.matches(":focus-visible"); } catch (x) { /* older engines */ } setPickerFocus(visible); }} |
| 1016 |
onBlur={() => setPickerFocus(false)} |
| 1017 |
onChange={(e) => { |
| 1018 |
pickFile(e.target.files && e.target.files[0] ? e.target.files[0] : null); |
| 1019 |
// Cleared so that picking the same file again — after |
| 1020 |
// Remove — still fires a change. |
| 1021 |
e.target.value = ""; |
| 1022 |
}} /> |
| 1023 |
<span style={{ display: "flex", color: "var(--green)" }}><IcPaperclip size={22} /></span> |
| 1024 |
{file |
| 1025 |
? <span style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)", wordBreak: "break-all" }}>{file.name}</span> |
| 1026 |
: <span style={{ fontSize: 14, fontWeight: 700, color: "var(--ink-2)" }}>{__("Click to choose a file", "betterdocs")}</span>} |
| 1027 |
<span style={{ fontSize: 12.5, color: "var(--muted)" }}> |
| 1028 |
{sprintf( |
| 1029 |
/* translators: 1: document/image size limit, 2: recording size limit. */ |
| 1030 |
__("PDF, DOCX, TXT, MD or an image up to %1$s — or a recording (MP3, M4A, WAV, FLAC, OGG, MP4, MPEG, WEBM) up to %2$s", "betterdocs"), |
| 1031 |
asMB(num(config.max_upload_bytes, 5242880)), |
| 1032 |
asMB(num(config.max_media_bytes, 25165824)) |
| 1033 |
)} |
| 1034 |
</span> |
| 1035 |
</label> |
| 1036 |
{fileError && ( |
| 1037 |
<div style={{ marginTop: 8 }}><Banner tone="error">{fileError}</Banner></div> |
| 1038 |
)} |
| 1039 |
{file && ( |
| 1040 |
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}> |
| 1041 |
<span style={{ fontSize: 12.5, color: "var(--muted)" }}>{`${Math.max(1, Math.round(file.size / 1024))} KB`}</span> |
| 1042 |
<TextBtn onClick={() => { transcribeReq.current += 1; setTranscribing(false); setFile(null); setTranscript(""); setTranscriptClipped(false); setFileError(""); }}>{__("Remove", "betterdocs")}</TextBtn> |
| 1043 |
</div> |
| 1044 |
)} |
| 1045 |
|
| 1046 |
{/* Recording → transcribe, correct, then generate. The transcript is |
| 1047 |
shown because speech-to-text mangles product names, and a wrong |
| 1048 |
term here becomes a wrong term in the published doc. */} |
| 1049 |
{file && isRecording(file) && ( |
| 1050 |
<div style={{ marginTop: 14 }}> |
| 1051 |
{!transcript && ( |
| 1052 |
<> |
| 1053 |
<GhostBtn |
| 1054 |
onClick={transcribeFile} |
| 1055 |
disabled={!hasKey || transcribing || !flag(config.supports_transcription)} |
| 1056 |
> |
| 1057 |
{transcribing |
| 1058 |
? __("Transcribing your recording…", "betterdocs") |
| 1059 |
: __("Transcribe recording", "betterdocs")} |
| 1060 |
</GhostBtn> |
| 1061 |
{!flag(config.supports_transcription) && ( |
| 1062 |
<div style={{ marginTop: 8 }}> |
| 1063 |
<Banner tone="warn"> |
| 1064 |
{sprintf( |
| 1065 |
/* translators: %s: the configured AI platform label. */ |
| 1066 |
__("%s can't read audio or video. Switch to OpenAI or Google Gemini in Settings → AI Content Suite.", "betterdocs"), |
| 1067 |
config.platform_label || config.platform || "" |
| 1068 |
)} |
| 1069 |
</Banner> |
| 1070 |
</div> |
| 1071 |
)} |
| 1072 |
</> |
| 1073 |
)} |
| 1074 |
{transcript && ( |
| 1075 |
<> |
| 1076 |
<FieldLabel>{__("Transcript — correct anything misheard, then generate:", "betterdocs")}</FieldLabel> |
| 1077 |
<TextArea value={transcript} onChange={setTranscript} rows={8} /> |
| 1078 |
{transcriptClipped && ( |
| 1079 |
<div style={{ marginTop: 8 }}> |
| 1080 |
<Banner tone="warn"> |
| 1081 |
{__("This recording is longer than BetterDocs AI can use at once, so the transcript stops part-way — the doc will cover only this part. Split the recording to document the rest.", "betterdocs")} |
| 1082 |
</Banner> |
| 1083 |
</div> |
| 1084 |
)} |
| 1085 |
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}> |
| 1086 |
<span style={{ fontSize: 12.5, color: "var(--muted)" }}> |
| 1087 |
{sprintf( |
| 1088 |
/* translators: %d: character count of the transcript. */ |
| 1089 |
__("%d characters", "betterdocs"), |
| 1090 |
transcript.length |
| 1091 |
)} |
| 1092 |
</span> |
| 1093 |
<TextBtn onClick={transcribeFile}>{__("Transcribe again", "betterdocs")}</TextBtn> |
| 1094 |
</div> |
| 1095 |
</> |
| 1096 |
)} |
| 1097 |
</div> |
| 1098 |
)} |
| 1099 |
<div style={{ marginTop: 16 }}> |
| 1100 |
<FieldLabel>{__("Add a prompt (optional)", "betterdocs")}</FieldLabel> |
| 1101 |
<TextArea value={filePrompt} onChange={setFilePrompt} rows={3} |
| 1102 |
placeholder={__("Optional: how should BetterDocs AI treat this file? — e.g. \"Turn this into a step-by-step how-to guide.\"", "betterdocs")} /> |
| 1103 |
</div> |
| 1104 |
<div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}> |
| 1105 |
{file && isRecording(file) |
| 1106 |
? __("BetterDocs AI transcribes the speech, then writes only from that transcript — it won't invent details. Video is read for its audio; what's on screen is not.", "betterdocs") |
| 1107 |
: __("BetterDocs AI reads the file's text and writes only from its content — it won't invent details.", "betterdocs")} |
| 1108 |
</div> |
| 1109 |
</> |
| 1110 |
)} |
| 1111 |
|
| 1112 |
{/* Guides — pick saved instruction sets to layer on the base prompt */} |
| 1113 |
{!gitBlocked && instructionChoices.length > 0 && ( |
| 1114 |
<div style={{ marginTop: 18 }}> |
| 1115 |
<InstructionPicker |
| 1116 |
instructions={instructionChoices} |
| 1117 |
selected={selectedInstructionIds} |
| 1118 |
onChange={setSelectedInstructionIds} |
| 1119 |
/> |
| 1120 |
</div> |
| 1121 |
)} |
| 1122 |
|
| 1123 |
{/* Advanced settings — collapsible (tone · length · language · toggles) */} |
| 1124 |
{!gitBlocked && ( |
| 1125 |
<div style={{ marginTop: 18 }}> |
| 1126 |
<button type="button" onClick={() => setAdvOpen((v) => !v)} aria-expanded={advOpen} style={{ |
| 1127 |
width: "100%", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, |
| 1128 |
padding: "11px 14px", borderRadius: 8, cursor: "pointer", |
| 1129 |
background: "var(--base-background, #fff)", |
| 1130 |
border: "1px solid var(--text-color-200, #eaecf0)", |
| 1131 |
transition: "background .12s, border-color .12s", |
| 1132 |
}}> |
| 1133 |
<span style={{ display: "inline-flex", alignItems: "center", gap: 9, fontSize: 13.5, fontWeight: 700, color: "var(--ink-2)" }}> |
| 1134 |
<IcSliders size={16} />{__("Advanced settings", "betterdocs")} |
| 1135 |
</span> |
| 1136 |
<span style={{ display: "inline-flex", alignItems: "center", gap: 10 }}> |
| 1137 |
<span style={{ fontSize: 12.5, color: "var(--muted)", fontWeight: 600 }}>{advSummary}</span> |
| 1138 |
<span style={{ display: "flex", color: "var(--muted)", transform: advOpen ? "rotate(180deg)" : "none", transition: "transform .15s" }}> |
| 1139 |
<IcChevDown size={16} /> |
| 1140 |
</span> |
| 1141 |
</span> |
| 1142 |
</button> |
| 1143 |
|
| 1144 |
{advOpen && ( |
| 1145 |
<div style={{ marginTop: 10, background: "var(--base-background, #fff)", border: "1px solid var(--text-color-200, #eaecf0)", borderRadius: 8, padding: "2px 16px" }}> |
| 1146 |
{settingRows.map((row, i) => ( |
| 1147 |
<div key={row.key} style={{ borderTop: i ? "1px solid var(--border-soft)" : "none" }}>{row}</div> |
| 1148 |
))} |
| 1149 |
</div> |
| 1150 |
)} |
| 1151 |
</div> |
| 1152 |
)} |
| 1153 |
|
| 1154 |
{/* footer */} |
| 1155 |
{!gitBlocked && ( |
| 1156 |
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 20, paddingTop: 16, borderTop: "1px solid var(--border-soft)" }}> |
| 1157 |
<div style={{ marginRight: "auto" }}><TokenMeter estimated={est} max={config.max_token || 4000} hidden /></div> |
| 1158 |
{tab === "prompt" && ( |
| 1159 |
<> |
| 1160 |
<GhostBtn icon={<IcList size={16} />} onClick={() => run("generate-outline")} disabled={!canPrompt}>{__("Generate Outline", "betterdocs")}</GhostBtn> |
| 1161 |
<PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("generate-doc")} disabled={!canPrompt}>{__("Generate Doc", "betterdocs")}</PrimaryBtn> |
| 1162 |
</> |
| 1163 |
)} |
| 1164 |
{tab === "source" && ( |
| 1165 |
<PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("from-source")} disabled={!canSource}>{__("Generate from source", "betterdocs")}</PrimaryBtn> |
| 1166 |
)} |
| 1167 |
{tab === "git" && ( |
| 1168 |
<PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("from-git")} disabled={!canGit}>{__("Generate from Git", "betterdocs")}</PrimaryBtn> |
| 1169 |
)} |
| 1170 |
{tab === "attachment" && ( |
| 1171 |
<PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("from-attachment")} disabled={!canAttachment}>{__("Generate from attachment", "betterdocs")}</PrimaryBtn> |
| 1172 |
)} |
| 1173 |
</div> |
| 1174 |
)} |
| 1175 |
{!hasKey && <div style={{ fontSize: 12, color: "var(--muted-2)", marginTop: 8, textAlign: "right" }}>{__("Connect an API key in Settings → AI to enable generation.", "betterdocs")}</div>} |
| 1176 |
</> |
| 1177 |
)} |
| 1178 |
|
| 1179 |
{(stage === "loadingDoc" || stage === "loadingOutline") && ( |
| 1180 |
<PreviewAccept mode="doc" status="loading" |
| 1181 |
loadingLabel={stage === "loadingOutline" ? __("Outlining", "betterdocs") : (tab === "source" ? __("Reading your source", "betterdocs") : tab === "git" ? __("Fetching from Git", "betterdocs") : tab === "attachment" ? (isRecording(file) ? __("Writing from your transcript", "betterdocs") : __("Reading your file", "betterdocs")) : __("Generating documentation", "betterdocs"))} |
| 1182 |
onKeep={() => { }} onDiscard={() => setStage("form")} /> |
| 1183 |
)} |
| 1184 |
|
| 1185 |
{stage === "outline" && ( |
| 1186 |
<OutlineEditor items={outline} setItems={setOutline} onExpand={expand} |
| 1187 |
onRegenerate={() => run("generate-outline")} onDiscard={() => setStage("form")} /> |
| 1188 |
)} |
| 1189 |
|
| 1190 |
{(stage === "preview" || stage === "previewError") && ( |
| 1191 |
<PreviewAccept mode="doc" status={stage === "previewError" ? "error" : "ready"} error={error} html={html} |
| 1192 |
keepLabel={__("Keep & insert", "betterdocs")} maxHeight={340} |
| 1193 |
onKeep={keepAndInsert} onRegenerate={regenerateDoc} onDiscard={() => setStage("form")} |
| 1194 |
actions={ |
| 1195 |
<div style={{ width: "100%" }}> |
| 1196 |
{stage === "preview" && canSuggest && enabledSuggestTaxes().length > 0 && ( |
| 1197 |
<SuggestTermsPreview |
| 1198 |
groups={enabledSuggestTaxes().map((t) => ({ taxonomy: t.taxonomy, label: t.label, items: suggestions[t.taxonomy] || [] }))} |
| 1199 |
selected={selectedKeys} |
| 1200 |
onToggle={toggleKey} |
| 1201 |
loading={suggestLoading} |
| 1202 |
/> |
| 1203 |
)} |
| 1204 |
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 18 }}> |
| 1205 |
<TextBtn onClick={() => setStage("form")} style={{ marginRight: "auto" }}>{__("Discard", "betterdocs")}</TextBtn> |
| 1206 |
<GhostBtn icon={<IcRefresh size={15} />} onClick={regenerateDoc}>{__("Regenerate", "betterdocs")}</GhostBtn> |
| 1207 |
<PrimaryBtn icon={<IcCheck size={16} sw={2.4} />} onClick={keepAndInsert} disabled={stage !== "preview"}>{__("Keep & insert", "betterdocs")}</PrimaryBtn> |
| 1208 |
</div> |
| 1209 |
</div> |
| 1210 |
} /> |
| 1211 |
)} |
| 1212 |
</div> |
| 1213 |
</Modal> |
| 1214 |
); |
| 1215 |
}; |
| 1216 |
|
| 1217 |
export default StudioGenerateModal; |
| 1218 |
|