diff --git a/antora.yml b/antora.yml index 3a5f74aec9..31c676dd42 100644 --- a/antora.yml +++ b/antora.yml @@ -25,6 +25,9 @@ asciidoc: exportpdf_service_url: exportpdf_service_url placeholder # TinyMCE AI demo API key tinymceai_demo_api_key: qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc + # TinyMCE AI MCP demo API key - only for MCP live demos, which require additional service access + tinymceai_mcp_demo_api_key: 3ibmjj7hijc8a4pgc2er8hgq781a2q2r81aj5dbj0wzq9a6c + tinymceai_mcp_live_demo_url: https://cdn.tiny.cloud/1/3ibmjj7hijc8a4pgc2er8hgq781a2q2r81aj5dbj0wzq9a6c/tinymce/8/tinymce.min.js # product variables productname: TinyMCE productmajorversion: 8 diff --git a/modules/ROOT/attachments/tinymceai-mcp-demo/content.css b/modules/ROOT/attachments/tinymceai-mcp-demo/content.css new file mode 100644 index 0000000000..63d0a8cd77 --- /dev/null +++ b/modules/ROOT/attachments/tinymceai-mcp-demo/content.css @@ -0,0 +1,366 @@ +/* Main demo content styles */ + +/* Fixing always showing comments */ +.tox-comment[contenteditable=false]:not([data-mce-selected]), span.tox-comment img:not([data-mce-selected]), span.tox-comment span.mce-preview-object:not([data-mce-selected]), span.tox-comment > audio:not([data-mce-selected]), span.tox-comment > video:not([data-mce-selected]) { + outline:3px solid #ffe796 +} +span.tox-comment:not([data-mce-selected]) { + background-color: #ffe796; + outline:0 +} +body:not(.tox-comments-visible) span.tox-comment[data-mce-selected] { + background-color: #ffe796; + outline:0 +} + +/* Fix issue with overlapping comments */ +.tox-comments-visible span.tox-comment[data-mce-selected] { + z-index: 1; + position: relative; +} + +/* Hide comments in AI previews */ +.tox-tinymceai__preview-body span.tox-comment { + background-color: transparent; + outline: 0; +} + +/* Fixing placeholder */ +.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before { + left: calc(var(--root-padding) * 2) !important; +} + +/* Improvement to mergetag making them selectable */ +.mce-mergetag { + user-select: all !important; + -webkit-user-select: all !important; +} + +/* Make mergetag background appear selected on firefox */ +.mce-mergetag::selection { + background-color: #b3d8ff; +} + +/* --- CONTENT STYLES -- */ + +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + --root-padding: 2rem; + --bg-color: #f7faff; + --dash-color: #d4e1f9; + --contrast-text-color: #1a1a1a; + background: var(--bg-color); + padding: var(--root-padding); + + &::before, + &::after { + content: ''; + display: block; + margin: 0 calc(50% - 50vw); + border-bottom: 1px dashed var(--dash-color); + pointer-events: none; + } + @media (width < 750px) { + --root-padding: 1rem; + } +} + +body { + font: 16px / 28px 'Inter', Arial, sans-serif; + max-width: 900px; + margin: 0 auto; + padding: var(--root-padding); + color: #2c2c2c; + background: #fff; + background-clip: padding-box; + border-right: 1px dashed var(--dash-color); + border-left: 1px dashed var(--dash-color); + overflow-x: hidden; +} + +.hero { + container-type: inline-size; + container-name: hero; + position: relative; + display: grid; + grid-template-columns: 6fr 4fr; + gap: 2rem; + margin: 0 calc(var(--root-padding) * -1) 1rem; + padding: calc(var(--root-padding) / 2) var(--root-padding); + background: #fff; + border-bottom: var(--root-padding) solid var(--bg-color); + + &:first-child, + [data-mce-bogus] + &:nth-child(2) { + margin-top: calc(var(--root-padding) * -1 - 1px); + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + width: 100vw; + margin-left: -50vw; + border-top: 1px dashed var(--dash-color); + pointer-events: none; + } + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + width: 100vw; + height: var(--root-padding); + margin-left: -50vw; + border: 1px dashed var(--dash-color); + border-style: dashed none; + pointer-events: none; + } +} + +@media (width >= 750px) { + .hero__aside { + text-align: right; + margin: 0; + padding-top: 1rem; + } +} + +@media (width < 750px) { + .hero__main { + grid-column: 1 / -1; + } + .hero__aside { + grid-column: 1 / -1; + } +} + +h1 { + font-family: 'Inter', Arial, sans-serif; + font-weight: 200; + font-size: 2.6rem; + line-height: 1.2; + margin-bottom: 0.25em; + color: var(--contrast-text-color); + + @media (width < 750px) { + font-size: 2rem; + } +} + +h1 strong { + font-size: inherit; + font-weight: 700; + text-transform: none; + letter-spacing: normal; +} + +h2 { + font-family: 'Inter', Arial, sans-serif; + font-weight: 700; + font-size: 1.35rem; + margin-top: 2.25rem; + margin-bottom: 0.2rem; + color: var(--contrast-text-color); +} + +h3 { + font-family: 'Inter', Arial, sans-serif; + font-weight: 700; + font-size: 1.1rem; + margin-top: 1.5rem; + margin-bottom: 0.2rem; + color: var(--contrast-text-color); +} + +h4 { + margin: 0.75em 0; +} + +h2:first-child, +h3:first-child, +h4:first-child { + margin-top: 0; +} + +p + p { + margin: 0.75em 0; +} + +hr { + border: none; + height: 4px; + background: linear-gradient(135deg, #ED716A 20%, #EEB83F 80%); + margin: 2rem 0; +} + +.smallcaps { + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +em { + font-style: italic; + color: inherit; +} + +.highlight { + color: #335dff; +} + +h1 .highlight { + color: var(--contrast-text-color); + background: linear-gradient(#EEB83F 20%, #ED716A 80%); + padding: 0 0.2em; + margin: 0 -0.2em; +} + +.subtitle { + font-size: 1.15rem; + color: #888cab; + font-weight: 200; + margin: 0; +} + +a { + color: #335dff; + text-decoration: underline; + text-decoration-color: #335dff80; +} + +a:hover { + color: #233477; + text-decoration-color: #233477; +} + +ul, ol { + padding-left: 1.25rem; + margin: 1rem 0; +} + +li { + margin-bottom: 0.5em; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; + font-size: 0.95rem; + border: 1px dashed var(--dash-color); + + thead { + border-bottom: 3px solid #335dff; + } + + th, + thead td { + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + text-align: left; + color: var(--contrast-text-color); + border: 1px dashed var(--dash-color); + + &.center { + text-align: center; + } + } + + thead th, + thead td { + background: var(--bg-color); + padding: 0.6rem 0.75rem; + } + + tbody th, td { + padding: 0.5rem 0.75rem; + border: 1px dashed var(--dash-color); + border-bottom: 1px solid var(--dash-color); + } + + .number { + text-align: right; + } + + tbody tr { + background: #fff; + } + + tbody tr:nth-child(even) { + background: var(--bg-color); + } + + tfoot td { + border: 1px dashed var(--dash-color); + border-bottom: none; + border-top: 2px solid var(--dash-color); + padding-top: 0.65rem; + } +} + +blockquote { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin: 1.25rem 0; + + &::before, + &::after { + content: ''; + width: 60px; + height: 4px; + background: linear-gradient(135deg, #ED716A 20%, #EEB83F 80%); + } +} + +blockquote p { + width: 80%; + margin: 0; + font-size: 1.3rem; +} + +img, +figure.image { + display: block; + max-width: 100%; + height: auto; + margin-bottom: 0.75em; +} +img[align="left"], +figure.image.align-left { + float: left; + margin-right: 0.75em; +} +img[align="right"], +figure.image.align-right { + float: right; + margin-left: 0.75em; +} + +figure figcaption { + font: 12px / 20px 'Inter', Arial, sans-serif; + text-align: center; + color: #888cab; +} + +:not(.tox-tinymceai__annotation) > .placeholder { + background: rgba(from var(--dash-color) r g b / .3); + outline: 1px solid var(--dash-color); + padding: 0.1rem 0.3rem; + margin: 0 -0.3rem; + border-radius: 0.25rem; + display: block; + color: #888cab; + color: lch(from var(--dash-color) calc(l / 2) c h) +} \ No newline at end of file diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/example.js b/modules/ROOT/examples/live-demos/tinymceai-mcp/example.js new file mode 100644 index 0000000000..1c051730e1 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/example.js @@ -0,0 +1,65 @@ +// The MCP server is configured on the AI service, not in the editor. The editor only +// needs the tinymceai plugin and a token provider. +// The demo-only data plumbing (merge tag list, mentions directory, revision history, +// and template storage) is omitted below to keep the AI configuration in focus. +tinymce.init({ + selector: 'textarea#tinymceai-mcp', + height: 'clamp(650px, 80vh, 1200px)', + plugins: [ + 'autolink', 'link', 'emoticons', 'lists', 'table', 'advlist', 'searchreplace', 'wordcount', 'autocorrect', 'tinymcespellchecker', 'charmap', 'fullscreen', 'advcode', 'accordion', 'anchor', 'footnotes', 'powerpaste', 'importword', 'exportpdf', 'exportword', 'markdown', 'a11ychecker', 'typography', 'casechange', 'checklist', 'advtable', 'formatpainter', 'permanentpen', 'tableofcontents', 'help', 'math', 'linkchecker', 'media', 'mediaembed', + 'quickbars', 'tinymceai', 'tinycomments', 'uploadcare' + ], + toolbar: 'undo redo | tinymceai-chat tinymceai-review | ai-quickactions-translate | styles fontsizeinput | bold italic underline forecolor backcolor casechange | link uploadcare table addcomment | align bullist numlist checklist removeformat | code fullscreen help', + quickbars_selection_toolbar: 'tinymceai-quickactions ai-quickactions-improve-writing addcomment', + body_class: 'tox-comments-visible', // Ensure comments show up on init + sidebar_show: 'tinymceai-chat', + toolbar_mode: 'sliding', + visual: false, + table_default_attributes: {}, // Prevents inserting border=1 attribute on a new table + content_css: 'content.css', + user_id: 'james-wilson', + advcode_inline: true, + highlight_on_focus: false, + tinycomments_mode: 'embedded', + tinymceai_default_model: 'claude-4-6-sonnet', + tinymceai_allow_model_selection: false, + tinymceai_chat_welcome_message: 'This demo is connected to an Airtable database containing acquisition deals and related companies information.', + tinymceai_chat_welcome_actions: [ + { text: 'Try using the following actions with this database:' }, + { text: '**Get help editing your document**' }, + { + title: 'Fill with "Nuvo Technologies" account details', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Pull the "Nuvo Technologies" record from "TinyMCE demo data" Airtable database and fill in all placeholders in this document', + displayedPrompt: 'Pull the "Nuvo Technologies" record from Airtable and fill in all placeholders in this document' + }, + icon: 'document-properties' + }, + { text: '**Explore data via chat**' }, + { + title: 'Compile an open deals summary report', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Find all deals in "proposal sent" status and provide a summary in chat with their deal value, notes, confidence level and potential red flags' + }, + icon: 'comment' + } + ], + tinymceai_languages: [ + { title: 'English', language: 'english' }, + { title: 'Chinese (Simplified)', language: 'chinese' }, + { title: 'Spanish', language: 'spanish' }, + { title: 'German', language: 'german' }, + { title: 'Japanese', language: 'japanese' }, + { title: 'Portuguese', language: 'portuguese' }, + { title: 'Korean', language: 'korean' }, + { title: 'Italian', language: 'italian' } + ], + // Required for authentication. Replace with the token endpoint for the AI service. + tinymceai_token_provider: () => { + return fetch('/api/tinymceai-token', { credentials: 'include' }) + .then(resp => resp.text()) + .then(token => ({ token })); + } +}); diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html new file mode 100644 index 0000000000..1404834227 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.html @@ -0,0 +1,46 @@ + diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/index.js b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.js new file mode 100644 index 0000000000..955eca2241 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/index.js @@ -0,0 +1,527 @@ +const apiKey = '{{tinymceai_mcp_demo_api_key}}'; + +// Once per page, set up an auth session for TestDrive so we can fetch a JWT later. +// More than once per page probably won't break anything, but there's a chance things might get out of sync. +// So to ensure demos run well, we recommend only fetching this once per page. +// This function is more complex than we recommend in the docs because we've included error handling. The error will appear as a banner if hit. +// Docs example for reference: const isLoggedIn = fetch(`https://demo.api.tiny.cloud/1/${apiKey}/auth/random`, { method: "POST", credentials: "include" }); +async function getAuthSession() { + const res = await fetch(`https://demo.api.tiny.cloud/1/${apiKey}/auth/random`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error(`TestDrive auth session request failed: ${res.status}`); + const { token } = await res.json(); + return token; +} + +// Fetch a TinyMCE AI JWT from TestDrive. Returns { token } as the plugin's token +// provider requires. +// This is more complex than we recommend in the docs because we've included error handling. The error will appear as a banner if hit. +const tinymceaiTokenProvider = async () => { + // Check we've got an auth session before fetching a JWT. + await getAuthSession(); + const res = await fetch(`https://demo.api.tiny.cloud/1/${apiKey}/jwt/tinymceai`, { credentials: 'include' }); + if (!res.ok) throw new Error(`TestDrive JWT request failed: ${res.status}`); + return res.text().then(token => ({ token })); +}; + +// Tracks the visual viewport height (which excludes the on-screen keyboard on mobile) +// and exposes it as a CSS custom property --vvh. This allows the editor height to +// shrink correctly when the keyboard appears, unlike 100vh which ignores it. +const vv = window.visualViewport; +function updateVvh() { + document.documentElement.style.setProperty('--vvh', vv.height + 'px'); +} +vv.addEventListener('resize', updateVvh); +updateVvh(); + +// The Editor implementation + +// The demo document is provided by the textarea in index.html. +const initialContent = document.querySelector('textarea#tinymceai-mcp').value; + +// Merge tags +const mergetags_list = [ + { + title: 'Recipient', + menu: [ + { value: 'Recipient.FirstName', title: 'First name' }, + { value: 'Recipient.LastName', title: 'Last name' }, + { value: 'Recipient.FullName', title: 'Full name' }, + { value: 'Recipient.Salutation', title: 'Salutation (Mr/Ms/Dr …)' }, + { value: 'Recipient.Email', title: 'Email address' }, + { value: 'Recipient.Phone', title: 'Phone number' }, + { value: 'Recipient.JobTitle', title: 'Job title' }, + { value: 'Recipient.Company', title: 'Company name' }, + ] + }, + { + title: 'Sender', + menu: [ + { value: 'Sender.FirstName', title: 'First name' }, + { value: 'Sender.LastName', title: 'Last name' }, + { value: 'Sender.FullName', title: 'Full name' }, + { value: 'Sender.Email', title: 'Email address' }, + { value: 'Sender.Phone', title: 'Phone number' }, + { value: 'Sender.JobTitle', title: 'Job title' }, + { value: 'Sender.Department', title: 'Department' }, + { value: 'Sender.Signature', title: 'Email signature block' }, + ] + }, + { + title: 'Organization', + menu: [ + { value: 'Org.Name', title: 'Organization name' }, + { value: 'Org.LogoUrl', title: 'Logo URL' }, + { value: 'Org.Website', title: 'Website URL' }, + { value: 'Org.Address', title: 'Street address' }, + { value: 'Org.City', title: 'City' }, + { value: 'Org.Country', title: 'Country' }, + { value: 'Org.SupportEmail', title: 'Support email' }, + { value: 'Org.Phone', title: 'Main phone number' }, + ] + }, + { + title: 'Document', + menu: [ + { value: 'Document.Title', title: 'Document title' }, + { value: 'Document.ID', title: 'Document ID / reference' }, + { value: 'Document.Version', title: 'Version number' }, + { value: 'Document.CreatedDate', title: 'Created date' }, + { value: 'Document.DueDate', title: 'Due / expiry date' }, + { value: 'Document.Status', title: 'Status' }, + { value: 'Document.OwnerName', title: 'Document owner name' }, + ] + }, + { + title: 'Date & Time', + menu: [ + { value: 'Date.Today', title: 'Today\'s date' }, + { value: 'Date.CurrentYear', title: 'Current year' }, + { value: 'Date.CurrentMonth', title: 'Current month' }, + { value: 'Date.CurrentDay', title: 'Day of the week' }, + { value: 'DateTime.Now', title: 'Current date & time' }, + ] + }, + { + title: 'Contract & Legal', + menu: [ + { value: 'Contract.Number', title: 'Contract number' }, + { value: 'Contract.StartDate', title: 'Contract start date' }, + { value: 'Contract.EndDate', title: 'Contract end date' }, + { value: 'Contract.Value', title: 'Contract value' }, + { value: 'Contract.Currency', title: 'Currency' }, + { value: 'Contract.PaymentTerms', title: 'Payment terms' }, + { value: 'Contract.GoverningLaw', title: 'Governing law / jurisdiction' }, + ] + }, + { + title: 'Product & Order', + menu: [ + { value: 'Order.ID', title: 'Order ID' }, + { value: 'Order.Date', title: 'Order date' }, + { value: 'Order.Total', title: 'Order total' }, + { value: 'Order.ItemCount', title: 'Number of items' }, + { value: 'Product.Name', title: 'Product name' }, + { value: 'Product.SKU', title: 'Product SKU' }, + { value: 'Product.Price', title: 'Unit price' }, + { value: 'Order.TrackingURL', title: 'Shipment tracking URL' }, + ] + } +]; + +// Advanced Templates store (sessionStorage-backed) +const SESSION_KEY = 'tinymce_templates'; +const SEED_TEMPLATES = [ + { + id: '1', + title: 'Resolving tickets', + content: '

Hi {{prefix}}Customer.FirstName{{suffix}},

Thank you for reaching out. I\'m happy to let you know that your ticket has been resolved. Please don\'t hesitate to get in touch if you need further assistance.

Best regards,
{{prefix}}Agent.FirstName{{suffix}}

', + }, + { + id: '2', + title: 'Quick replies', + items: [ + { + id: '3', + title: 'Message received', + content: '

Hi {{prefix}}Customer.FirstName{{suffix}},

Thanks for your message — we\'ve received it and will get back to you as soon as possible.

Best regards,
{{prefix}}Agent.FirstName{{suffix}}

', + }, + { + id: '4', + title: 'Progress update', + content: '

Hi {{prefix}}Customer.FirstName{{suffix}},

Just a quick note to let you know we\'re still working on your request. We\'ll follow up with an update shortly.

Best regards,
{{prefix}}Agent.FirstName{{suffix}}

', + } + ] + }, + { + id: 'idheroes', + title: "Hero blocks", + items: [ + { + id: 'heroblue', + title: "Hero with link", + content: ` +
+
+

Hero
Title

Block

+

Write your subtitle here

+
+
+

Link to somewhere

+
+
+ ` + } + ] + } +]; + +const loadTemplates = () => { + try { return JSON.parse(sessionStorage.getItem(SESSION_KEY)) || SEED_TEMPLATES; } + catch { return SEED_TEMPLATES; } +}; +const saveTemplates = (templates) => sessionStorage.setItem(SESSION_KEY, JSON.stringify(templates)); +const nextId = () => Math.random().toString(36).slice(2, 10); + +const flatten = (items) => items.flatMap((t) => t.items ? flatten(t.items) : [t]); + +const findCategory = (items, id) => { + for (const item of items) { + if (item.id === id && item.items) return item; + if (item.items) { const found = findCategory(item.items, id); if (found) return found; } + } + return null; +}; + +const removeById = (items, id) => items.reduce((acc, item) => { + if (item.id === id) return acc; + return [...acc, item.items ? { ...item, items: removeById(item.items, id) } : item]; +}, []); + +// Returns the full template tree for populating the template picker +const advtemplate_list = () => Promise.resolve(loadTemplates()); + +// Returns a single template by id (called when user inserts a template) +const advtemplate_get_template = (id) => { + const tpl = flatten(loadTemplates()).find((t) => t.id === id); + return tpl ? Promise.resolve(tpl) : Promise.reject(new Error(`Template ${id} not found`)); +}; + +// Creates a new empty category and appends it to the top-level list +const advtemplate_create_category = (title) => { + const templates = loadTemplates(); + const category = { id: nextId(), title, items: [] }; + templates.push(category); + saveTemplates(templates); + return Promise.resolve(category); +}; + +// Creates a new template, optionally inside a category +const advtemplate_create_template = (title, content, categoryId) => { + const templates = loadTemplates(); + const tpl = { id: nextId(), title, content }; + if (categoryId) { + const cat = findCategory(templates, categoryId); + if (cat) cat.items.push(tpl); + } else { + templates.push(tpl); + } + saveTemplates(templates); + return Promise.resolve(tpl); +}; + +// Renames an existing category +const advtemplate_rename_category = (id, title) => { + const templates = loadTemplates(); + const cat = findCategory(templates, id); + if (cat) cat.title = title; + saveTemplates(templates); + return Promise.resolve({}); +}; + +// Renames an existing template +const advtemplate_rename_template = (id, title) => { + const templates = loadTemplates(); + const tpl = flatten(templates).find((t) => t.id === id); + if (tpl) tpl.title = title; + saveTemplates(templates); + return Promise.resolve({}); +}; + +// Deletes a single template by id +const advtemplate_delete_template = (id) => { + saveTemplates(removeById(loadTemplates(), id)); + return Promise.resolve({}); +}; + +// Deletes a category and all templates inside it +const advtemplate_delete_category = (id) => { + saveTemplates(removeById(loadTemplates(), id)); + return Promise.resolve({}); +}; + +// Moves a single template to a different category +const advtemplate_move_template = (id, categoryId) => { + const templates = loadTemplates(); + const tpl = flatten(templates).find((t) => t.id === id); + if (!tpl) return Promise.reject(new Error(`Template ${id} not found`)); + const pruned = removeById(templates, id); + const cat = findCategory(pruned, categoryId); + if (cat) cat.items.push(tpl); else pruned.push(tpl); + saveTemplates(pruned); + return Promise.resolve({}); +}; + +// Moves all templates from one category into another +const advtemplate_move_category_items = (id, categoryId) => { + const templates = loadTemplates(); + const src = findCategory(templates, id); + if (!src) return Promise.reject(new Error(`Category ${id} not found`)); + const items = [...src.items]; + src.items = []; + const dest = findCategory(templates, categoryId); + if (dest) dest.items.push(...items); else templates.push(...items); + saveTemplates(templates); + return Promise.resolve({}); +}; + +const revisions = [ +{ + revisionId: "1", + createdAt: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - 1, 14, 32, 0).getTime(), + author: { + id: 'jade-scott', + name: 'Jade Scott', + avatar: 'https://sneak-preview.tiny.cloud/demouserdirectory/images/employee_jade-scott_128_515dc6a1.jpg', + }, + content: initialContent +}, +{ + revisionId: "0", + createdAt: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - 1, 14, 32, 0).getTime(), + author: { + id: 'jade-scott', + name: 'Jade Scott', + avatar: 'https://sneak-preview.tiny.cloud/demouserdirectory/images/employee_jade-scott_128_515dc6a1.jpg', + }, + content: '' +} +]; + +const mentions_fetch = async (query, success) => { + const searchPhrase = query.term.toLowerCase(); + await fetch(`https://demouserdirectory.tiny.cloud/v1/users?q=${encodeURIComponent(searchPhrase)}`) + .then((response) => response.json()) + .then((users) => success(users.data.map((userInfo) => ({ + id: userInfo.id, + name: userInfo.name, + image: userInfo.avatar, + description: userInfo.custom.role + })))) + .catch((error) => console.log(error)); +}; + +const mentions_menu_complete = (editor, userInfo) => { + const span = editor.getDoc().createElement('span'); + span.className = 'mymention'; + span.setAttribute('data-mention-id', userInfo.id); + span.appendChild(editor.getDoc().createTextNode('@' + userInfo.name)); + return span; +}; + +const createCard = (userInfo) => { + const div = document.createElement('div'); + div.innerHTML = ( + '
' + + '' + + '

' + userInfo.name + '

' + + '

' + userInfo.description + '

' + + '
' + ); + return div; +}; + +const mentions_select = async (mention, success) => { + const id = mention.getAttribute('data-mention-id'); + await fetch(`https://demouserdirectory.tiny.cloud/v1/users/${id}`) + .then((response) => response.json()) + .then((userInfo) => { + const card = createCard({ + id: userInfo.id, + name: userInfo.name, + image: userInfo.avatar, + description: userInfo.custom.role + }); + success(card); + }) + .catch((error) => console.error(error)); +}; + +const mentions_menu_hover = async (userInfo, success) => { + const card = createCard(userInfo); + success(card); +}; + +tinymce.IconManager.add('fancy-ai-icons', { + icons: { + 'ai-assistant': '', + 'ai-review': '' + } +}); + +tinymce.init({ + selector: 'textarea#tinymceai-mcp', + plugins: [ + "autolink", "link", "emoticons", "lists", "table", "advlist", "searchreplace", "wordcount", "autocorrect", "tinymcespellchecker", "charmap", "fullscreen", "advcode", "accordion", "anchor", "footnotes", "powerpaste", "importword", "exportpdf", "exportword", "markdown", "a11ychecker", "typography", "casechange", "checklist", "advtable", "formatpainter", "permanentpen", "tableofcontents", "help", "math", "linkchecker", "media", "mediaembed", // general plugins + "quickbars", "tinymceai", "mergetags", "tinycomments", "mentions", "revisionhistory", "suggestededits", "advtemplate", "uploadcare" + ], + toolbar: "undo redo | tinymceai-chat tinymceai-review | ai-quickactions-translate | styles fontsizeinput | bold italic underline forecolor backcolor casechange | link uploadcare table addcomment | align bullist numlist checklist removeformat | code fullscreen help", + mobile: { + toolbar: 'styles bold italic underline forecolor backcolor | link uploadcare tableofcontents accordion hr toc footnotes | align bullist numlist checklist removeformat | showcomments code help', + sidebar_show: '', + height: 'calc(var(--vvh, 100svh) * 0.9)', + statusbar: false, + quickbars_selection_toolbar: false, + quickbars_insert_toolbar: false, + // Resizes the editor container to 90% of the visual viewport height whenever + // the viewport changes (e.g. keyboard appears). Also scrolls the editor into + // view when the keyboard opens to ensure it remains visible. + setup: (editor) => { + editor.on('init', () => { + const container = editor.getContainer(); + if (!container) return; + let prevHeight = vv.height; + function syncHeight() { + const keyboardOpened = prevHeight - vv.height > 100; + prevHeight = vv.height; + container.style.height = `calc(${vv.height}px * 0.9)`; + if (keyboardOpened) { + container.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + } + vv.addEventListener('resize', syncHeight); + }); + } + }, + height: 'clamp(650px, 80vh, 1200px)', + icons: 'fancy-ai-icons', + quickbars_selection_toolbar: 'tinymceai-quickactions ai-quickactions-improve-writing addcomment', + body_class: 'tox-comments-visible', // Ensure comments show up on init + sidebar_show: 'tinymceai-chat', + toolbar_mode: 'sliding', + visual: false, + table_default_attributes: {}, // Prevents inserting border=1 attribute on a new table + formats: { + smallcaps: { inline: 'span', selector: 'p,h1,h2,h3,h4,h5,h6,span', classes: 'smallcaps' }, + subtitle: { inline: 'span', selector: 'p,h1,h2,h3,h4,h5,h6,span', classes: 'subtitle' }, + highlight: { inline: 'span', selector: 'p,h1,h2,h3,h4,h5,h6,span', classes: 'highlight' }, + }, + style_formats_merge: true, + style_formats: [ + { + title: 'Styles', items: [ + { title: 'Small caps', format: 'smallcaps' }, + { title: 'Subtitle', format: 'subtitle' }, + { title: 'Highlight', format: 'highlight' }, + ] + }, + ], + content_css: '{{attachmentsdir}}/tinymceai-mcp-demo/content.css', + user_id: "james-wilson", + spellchecker_dialog: false, + uploadcare_public_key: '630992ad50fe2291c406', + uploadcare_store_type: 'temporary', + uploadcare_cdn_base_url: 'https://tiny.ucarecdn.com', + images_file_types: 'jpeg,jpg,jpe,jfi,jif,jfif,png,gif,bmp,webp,svg', + advcode_inline: true, + highlight_on_focus: false, + mergetags_list: mergetags_list, + tinycomments_mode: 'embedded', + fetch_users: (userIds) => Promise.all(userIds.map((userId) => fetch(`https://demouserdirectory.tiny.cloud/v1/users/${userId}`).then((response) => response.json()).catch(() => ({ id: userId })))), + mentions_item_type: "profile", + mentions_fetch, + mentions_menu_hover, + mentions_menu_complete, + mentions_select, + mentions_selector: '.mymention', + revisionhistory_fetch: () => new Promise((resolve) => { + resolve(revisions); + }), + revisionhistory_display_author: true, + advtemplate_list, + advtemplate_get_template, + advtemplate_create_category, + advtemplate_create_template, + advtemplate_rename_category, + advtemplate_rename_template, + advtemplate_delete_template, + advtemplate_delete_category, + advtemplate_move_template, + advtemplate_move_category_items, + tinymceai_token_provider: tinymceaiTokenProvider, + tinymceai_languages: [ + { title: 'English', language: 'english' }, + { title: 'Chinese (Simplified)', language: 'chinese' }, + { title: 'Spanish', language: 'spanish' }, + { title: 'German', language: 'german' }, + { title: 'Japanese', language: 'japanese' }, + { title: 'Portuguese', language: 'portuguese' }, + { title: 'Swedish', language: 'Burträskmål' }, + { title: 'Korean', language: 'korean' }, + { title: 'Hindi (Devanagari)', language: 'hindi devanagari'}, + { title: 'Italian', language: 'italian' }, + { title: 'Klingon', language: 'klingon' }, + { title: 'Dothraki', language: 'dothraki' }, + ], + tinymceai_chat_welcome_message: 'This demo is connected to an Airtable database containing acquisition deals and related companies information.', + tinymceai_chat_welcome_actions: [ + { text: 'Try using the following actions with this database:' }, + { text: '**Get help editing your document**' }, + { + title: 'Fill with "Nuvo Technologies" account details', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Pull the "Nuvo Technologies" record from "TinyMCE demo data" Airtable database and fill in all placeholders in this document', + displayedPrompt: 'Pull the "Nuvo Technologies" record from Airtable and fill in all placeholders in this document' + }, + icon: 'document-properties' + }, + { + title: 'Analyze and summarize our last closed-lost deal', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Find the closed lost deal record from "TinyMCE demo data" Airtable database and fill in all placeholders in this document. In "M&A deal summary" placeholder list 3 potential reasons for why the deal was lost - use ol element with proper markup', + displayedPrompt: 'Analyze and summarize our last closed-lost deal in Airtable data' + }, + icon: 'document-properties' + }, + { text: '**Explore data via chat**' }, + { + title: 'Compile an open deals summary report', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Find all deals in "proposal sent" status and provide a summary in chat with their deal value, notes, confidence level and potential red flags' + }, + icon: 'comment' + }, + { + title: 'Analyze stalled deals, and identify recurring issues', + command: 'TinyMCEAIChatPrompt', + value: { + prompt: 'Look across all deals and their linked companies. Identify any pattern in which deals stall or lose confidence — for example by industry, deal stage, deal size, or ownership structure. State the pattern you find and cite the specific deals that support it. Respond in chat.' + }, + icon: 'comment' + }, + ], + tinymceai_default_model: 'claude-4-6-sonnet', + tinymceai_allow_model_selection: false, + setup: (editor) => { + // Opens the comments sidebar when clicking a comment marker in the content, + // but only if it isn't already visible (body lacks tox-comments-visible class). + editor.on('click', (e) => { + const commentEl = e.target.closest('.tox-comment'); + if (commentEl && !editor.getBody().classList.contains('tox-comments-visible')) { + tinymce.activeEditor.execCommand('ToggleSidebar', false, 'showcomments'); + } + }); + }, +}); diff --git a/modules/ROOT/examples/live-demos/tinymceai-mcp/style.css b/modules/ROOT/examples/live-demos/tinymceai-mcp/style.css new file mode 100644 index 0000000000..a81cbd8886 --- /dev/null +++ b/modules/ROOT/examples/live-demos/tinymceai-mcp/style.css @@ -0,0 +1,47 @@ +/* Styles the mentions profile card, which renders in the editor user interface. */ + +span.mymention { + color: gray !important; +} + +div.card, +.tox div.card { + width: 240px; + background: white; + border: 1px solid #ccc; + border-radius: 3px; + box-shadow: 0 4px 8px 0 rgba(34, 47, 62, .1); + padding: 8px; + font-size: 14px; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +div.card::after, +.tox div.card::after { + content: ""; + clear: both; + display: table; +} + +div.card h1, +.tox div.card h1 { + font-size: 14px; + font-weight: bold; + margin: 0 0 8px; + padding: 0; + line-height: normal; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +div.card p, +.tox div.card p { + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; +} + +div.card img.avatar, +.tox div.card img.avatar { + width: 48px; + height: 48px; + margin-right: 8px; + float: left; +} diff --git a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc index 128998aa43..c2b5e1ca84 100644 --- a/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc +++ b/modules/ROOT/pages/tinymceai-on-premises-mcp.adoc @@ -12,6 +12,15 @@ These features are configured entirely through environment variables on the AI s [.text-center] image::tinymceai-on-premises/mcp-web-integrations-architecture.svg[MCP and web integrations architecture: TinyMCE editor connects to AI Service through SSE conversations. AI Service connects to LLM provider for inference and to MCP servers and web search and scrape endpoints for tool calling and live web content.,width=100%] +[[demo]] +== Demo + +The following demo shows the editor experience that <> enables. The AI service backing the demo has an MCP server connected to a sample database of acquisition deals and company records. Choosing one of the suggested actions in the Chat sidebar prompts the model to call the MCP server's tools, then either fill in the document placeholders or answer in Chat. + +The editor configuration shown in the *JS* tab applies to on-premises deployments without change. MCP servers are declared on the AI service through <>; the editor requires only the `+tinymceai+` plugin and a token provider. + +liveDemo::tinymceai-mcp[script_url_override="{tinymceai_mcp_live_demo_url}"] + == Prerequisites This page assumes the following are already configured: